diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..1d55b4b0a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,34 @@ +# Exclude unnecessary directories and files from ESLint + +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs +dist/ +packages/*/dist/ +packages/*/dist-server/ + +# Test files +coverage/ +*.coverage.js +packages/*/coverage/ + +# Configuration files +eslint.config.js +.prettierrc +.prettierignore +tsconfig.json + +# Web/Next.js specific (build artifacts) +packages/web/.next/ +packages/web/next-env.d.ts + +# Documentation +docs/ +*.md + +# Misc +.DS_Store +*.swp +.cache diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..7c8fea589 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,98 @@ +name: PR Diff Coverage + +on: + pull_request: + branches: [main] + +concurrency: + group: coverage-${{ github.ref }} + cancel-in-progress: true + +jobs: + detect-changes: + name: Detect changed packages + runs-on: ubuntu-latest + outputs: + core: ${{ steps.filter.outputs.core }} + cli: ${{ steps.filter.outputs.cli }} + web: ${{ steps.filter.outputs.web }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + core: + - 'packages/core/**' + cli: + - 'packages/cli/**' + web: + - 'packages/web/**' + + diff-coverage: + name: Diff Coverage (80% on changed code) + needs: detect-changes + if: needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.cli == 'true' || needs.detect-changes.outputs.web == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install diff-cover + run: pip install diff-cover + + - name: Install tmux + if: needs.detect-changes.outputs.web == 'true' + run: sudo apt-get update && sudo apt-get install -y tmux + + - name: Start tmux server + if: needs.detect-changes.outputs.web == 'true' + run: tmux start-server + + - run: pnpm install --frozen-lockfile + - run: pnpm -r --filter '!@composio/ao-web' build + + - name: Core tests with coverage + if: needs.detect-changes.outputs.core == 'true' + run: pnpm --filter @composio/ao-core exec vitest run --coverage + + - name: CLI tests with coverage + if: needs.detect-changes.outputs.cli == 'true' + run: pnpm --filter @composio/ao-cli exec vitest run --coverage + + - name: Web tests with coverage + if: needs.detect-changes.outputs.web == 'true' + run: pnpm --filter @composio/ao-web exec vitest run --coverage + + - name: Prepare and merge lcov reports + run: | + mkdir -p combined-coverage + touch combined-coverage/lcov.info + + if [ -f packages/core/coverage/lcov.info ]; then + sed 's|^SF:|SF:packages/core/|' packages/core/coverage/lcov.info >> combined-coverage/lcov.info + fi + if [ -f packages/cli/coverage/lcov.info ]; then + sed 's|^SF:|SF:packages/cli/|' packages/cli/coverage/lcov.info >> combined-coverage/lcov.info + fi + if [ -f packages/web/coverage/lcov.info ]; then + sed 's|^SF:|SF:packages/web/|' packages/web/coverage/lcov.info >> combined-coverage/lcov.info + fi + + - name: Check diff coverage (80% on changed lines) + run: | + diff-cover combined-coverage/lcov.info \ + --compare-branch=origin/${{ github.base_ref }} \ + --fail-under=80 \ + --diff-range-notation='...' diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml new file mode 100644 index 000000000..fc544e85b --- /dev/null +++ b/.github/workflows/deploy-vps.yml @@ -0,0 +1,57 @@ +name: Deploy to VPS + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +concurrency: + group: deploy-vps + cancel-in-progress: false + +jobs: + deploy: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + steps: + - name: Verify SHA is current tip of main + env: + GH_TOKEN: ${{ github.token }} + run: | + DEPLOY_SHA="${{ github.event.workflow_run.head_sha }}" + MAIN_SHA=$(gh api repos/${{ github.repository }}/git/ref/heads/main --jq '.object.sha') + if [ "$DEPLOY_SHA" != "$MAIN_SHA" ]; then + echo "Skipping: CI SHA $DEPLOY_SHA is not the current tip of main ($MAIN_SHA). Likely a stale rerun." + exit 0 + fi + echo "SHA verified: $DEPLOY_SHA is the current tip of main." + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ secrets.VPS_HOST }} + username: root + key: ${{ secrets.VPS_SSH_KEY }} + fingerprint: SHA256:mlWd4p7TXI+SnxXMr7THgQprWOk936qRQQdB4P2WsqA + script: | + set -e + DEPLOY_SHA="${{ github.event.workflow_run.head_sha }}" + cd /root/agent-orchestrator + echo "==> Fetching latest changes..." + git fetch origin main + echo "==> Current: $(git rev-parse --short HEAD)" + echo "==> Deploying CI-passed SHA: ${DEPLOY_SHA:0:7}" + git checkout "$DEPLOY_SHA" + echo "==> Updated: $(git rev-parse --short HEAD)" + echo "==> Installing dependencies..." + pnpm install --frozen-lockfile + echo "==> Building..." + pnpm build + echo "==> Restarting services..." + pm2 restart ao --update-env + pm2 restart openclaw-gateway --update-env || true + echo "==> Deploy complete: $(git rev-parse --short HEAD)" diff --git a/.gitignore b/.gitignore index f92763788..1c10a6e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ CLAUDE.md AGENTS.md .claude/ .opencode/ + +# OS-specific files +.DS_Store +Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6e54747e..3ff98cac3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -182,6 +182,23 @@ pnpm --filter @composio/ao-runtime-myplugin build pnpm --filter @composio/ao-runtime-myplugin test ``` +### Publishing to the Marketplace Registry + +To list your plugin in the AO marketplace so others can install it with `ao plugin install`, submit a PR that adds an entry to `packages/cli/src/assets/plugin-registry.json`. + +Each entry requires: + +- **`id`** — short kebab-case name (e.g. `tracker-jira`) +- **`package`** — npm package name +- **`slot`** — one of: `runtime`, `agent`, `workspace`, `tracker`, `scm`, `notifier`, `terminal` +- **`description`** — one-line summary +- **`source`** — always `"registry"` +- **`latestVersion`** — semver string + +Optionally include `setupAction` if post-install configuration is needed (e.g. `"openclaw-setup"`). + +Your plugin package must satisfy the contract in [`docs/PLUGIN_SPEC.md`](docs/PLUGIN_SPEC.md) — export a `PluginModule` with a valid manifest and `create()` function. The package must be published to npm before your registry PR is merged so `ao plugin install` can fetch it. + --- ## Code Conventions diff --git a/README.md b/README.md index e455aebba..4d67f1c3f 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ The orchestrator agent uses the [AO CLI](docs/CLI.md) internally to manage sessi ```yaml # agent-orchestrator.yaml +# Runtime data is auto-derived under ~/.agent-orchestrator/{hash}-{projectId}/ port: 3000 defaults: @@ -138,18 +139,17 @@ See [`agent-orchestrator.yaml.example`](agent-orchestrator.yaml.example) for the ## Plugin Architecture -Eight slots. Every abstraction is swappable. +Seven plugin slots. Lifecycle stays in core. | Slot | Default | Alternatives | | --------- | ----------- | ------------------------ | -| Runtime | tmux | docker, k8s, process | +| Runtime | tmux | process | | Agent | claude-code | codex, aider, opencode | | Workspace | worktree | clone | -| Tracker | github | linear | -| SCM | github | — | -| Notifier | desktop | slack, composio, webhook | +| Tracker | github | linear, gitlab | +| SCM | github | gitlab | +| Notifier | desktop | slack, discord, composio, webhook, openclaw | | Terminal | iterm2 | web | -| Lifecycle | core | — | All interfaces defined in [`packages/core/src/types.ts`](packages/core/src/types.ts). A plugin implements one interface and exports a `PluginModule`. That's it. diff --git a/SETUP.md b/SETUP.md index e745b5daa..bb19d5992 100644 --- a/SETUP.md +++ b/SETUP.md @@ -505,11 +505,11 @@ lsof -ti:3000 | xargs kill **Solution:** ```bash -# Check worktreeDir permissions -ls -la ~/.worktrees +# AO stores runtime data under ~/.agent-orchestrator/ +ls -la ~/.agent-orchestrator -# Create directory if missing -mkdir -p ~/.worktrees +# Create the base directory if missing +mkdir -p ~/.agent-orchestrator # Check disk space df -h @@ -618,10 +618,10 @@ projects: path: ~/backend sessionPrefix: api - mobile: - repo: org/mobile - path: ~/mobile - sessionPrefix: mob + docs: + repo: org/docs + path: ~/docs + sessionPrefix: doc ``` See [examples/multi-project.yaml](./examples/multi-project.yaml) for full example. @@ -817,11 +817,10 @@ ao session ls --json | jq -r '.[] | select(.status == "merged") | .id' | xargs - Yes! Each orchestrator instance should have: -- Different data directory (`dataDir`) - Different dashboard port (`port`) — e.g., 3000 for project A, 3001 for project B -- Different config file +- Different config location or project paths -Terminal WebSocket ports are auto-detected by default, so you typically only need to set `port:` differently. If you need explicit control, you can also set `terminalPort:` and `directTerminalPort:` per config. +AO derives runtime directories from the config location, so separate config locations already produce separate hash-scoped runtime paths under `~/.agent-orchestrator/`. Terminal WebSocket ports are auto-detected by default, so you typically only need to set `port:` differently. If you need explicit control, you can also set `terminalPort:` and `directTerminalPort:` per config. Useful for: diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index f2607c3e1..15bc73d54 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -1,11 +1,9 @@ # Agent Orchestrator Configuration # Copy to agent-orchestrator.yaml and customize. -# Where to store session metadata -dataDir: ~/.agent-orchestrator - -# Where to create workspaces (git worktrees, clones) -worktreeDir: ~/.worktrees +# Runtime data directories are auto-derived from this config location under: +# ~/.agent-orchestrator/{hash}-{projectId}/ +# You usually do not need to configure paths manually. # Web dashboard port port: 3000 @@ -17,14 +15,27 @@ port: 3000 # Default plugins (these are the defaults — you can omit this section) defaults: - runtime: tmux # tmux | process | docker | kubernetes | ssh | e2b - agent: claude-code # claude-code | codex | aider | goose | custom + runtime: tmux # tmux | process + agent: claude-code # claude-code | codex | aider | opencode # orchestrator: # agent: claude-code # worker: # agent: codex - workspace: worktree # worktree | clone | copy - notifiers: [desktop] # desktop | slack | discord | webhook | email | openclaw + workspace: worktree # worktree | clone + notifiers: [desktop] # desktop | slack | discord | webhook | composio | openclaw + +# Installer-managed external plugins (optional) +# plugins: +# - name: owasp-auditor +# source: registry # registry | npm | local +# package: "@ao-plugins/owasp-auditor" +# version: "^0.1.0" +# enabled: true +# +# - name: local-dev-plugin +# source: local +# path: ./plugins/local-dev-plugin +# enabled: true # Projects — at minimum, specify repo and path projects: diff --git a/docs/CLI.md b/docs/CLI.md index c673b9558..171295652 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -10,6 +10,7 @@ ao start # Clone repo, auto-configure, and start ao start ~/other-repo # Add a new project and start ao stop # Stop everything (dashboard, orchestrator, lifecycle worker) ao status # Overview of all sessions +ao status --watch # Live-updating terminal status view ao dashboard # Open web dashboard in browser ``` @@ -36,6 +37,6 @@ ao update # Update local AO install (source install ao config-help # Show full config schema reference ``` -`ao doctor` checks PATH and launcher resolution, required binaries, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. +`ao doctor` checks PATH and launcher resolution, required binaries, configured plugin resolution, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. `ao update` fast-forwards the local install on `main`, reinstalls dependencies, clean-rebuilds core packages, refreshes the launcher, and runs smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks. diff --git a/docs/PLUGIN_SPEC.md b/docs/PLUGIN_SPEC.md new file mode 100644 index 000000000..18f82cca6 --- /dev/null +++ b/docs/PLUGIN_SPEC.md @@ -0,0 +1,114 @@ +# AO Plugin Spec + +This document defines the runtime contract and packaging requirements for Agent Orchestrator plugins. + +## Runtime Contract + +Plugins are standard Node.js modules that export a `PluginModule`: + +```ts +export interface PluginModule { + manifest: PluginManifest; + create(config?: Record): T; + detect?(): boolean; +} +``` + +Minimum manifest shape: + +```ts +export interface PluginManifest { + name: string; + slot: PluginSlot; + description: string; + version: string; +} +``` + +AO accepts either a direct named export or a default export that satisfies this shape. + +## Supported Slots + +Current core slot types: + +- `runtime` +- `agent` +- `workspace` +- `tracker` +- `scm` +- `notifier` +- `terminal` + +The manifest `slot` determines where AO registers the plugin and which config surface can reference it. + +## Packaging Requirements + +Published plugins should: + +- ship built JavaScript, not raw TypeScript-only entrypoints +- export an ESM entrypoint through `exports` or `main` +- declare a semver dependency on `@composio/ao-core` +- keep side effects out of module top-level code where possible + +Recommended package shape: + +```json +{ + "name": "@composio/ao-plugin-example", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"] +} +``` + +## Config Descriptors + +Project config enables plugins through `plugins:` entries: + +```yaml +plugins: + - name: openclaw + source: registry + package: "@composio/ao-plugin-notifier-openclaw" + version: "0.1.1" +``` + +Descriptor fields: + +- `name`: logical plugin name shown in CLI UX +- `source`: one of `registry`, `npm`, or `local` +- `package`: package name for registry/npm-backed plugins +- `version`: requested or installed version for store-backed plugins +- `path`: local filesystem path for `source: local` +- `enabled`: optional flag, defaults to `true` + +## Marketplace Registry + +AO’s bundled marketplace catalog lives at: + +- `packages/cli/src/assets/plugin-registry.json` + +Registry entries provide AO-specific metadata on top of the runtime contract: + +- `id` +- `package` +- `slot` +- `description` +- `source` +- `latestVersion` +- `setupAction` when post-install guidance is needed + +## Installation Model + +Registry and npm plugins install into the AO-managed store: + +- `~/.agent-orchestrator/plugins/` + +That store is shared across projects. `agent-orchestrator.yaml` remains the source of truth for whether a plugin is enabled in a given repo. diff --git a/docs/design/graphql-batching-implementation.md b/docs/design/graphql-batching-implementation.md new file mode 100644 index 000000000..1c8c8eb53 --- /dev/null +++ b/docs/design/graphql-batching-implementation.md @@ -0,0 +1,222 @@ +# GraphQL Batching Implementation for Issue #608 + +## Summary + +This implementation adds GraphQL batch PR enrichment to the orchestrator polling loop, reducing GitHub API calls from N×3 calls to ~1 call per polling cycle. + +## Problem Statement + +The orchestrator runs a status loop that polls GitHub for ALL active sessions every 30 seconds. For each PR, it needs: + +1. PR state (merged, closed, open) - `getPRState()` +2. CI status - `getCISummary()` +3. Review decision - `getReviewDecision()` +4. Merge readiness (optional, for approved PRs) - `getMergeability()` + +With the current implementation: +- 10 active PRs = 30 API calls per poll = 3,600 calls/hour +- 20 active PRs = 60+ API calls per poll = 7,200+ calls/hour + +This exceeds GitHub's rate limit of 5,000 API calls/hour. + +## Solution: GraphQL Batch Query + +Using GraphQL aliases, we can query multiple PRs in a single request: + +```graphql +query BatchPRs( + $pr0Owner: String!, $pr0Name: String!, $pr0Number: Int!, + $pr1Owner: String!, $pr1Name: String!, $pr1Number: Int! +) { + pr0: repository(owner: $pr0Owner, name: $pr0Name) { + pullRequest(number: $pr0Number) { + title, state, additions, deletions, isDraft, + mergeable, mergeStateStatus, reviewDecision, + reviews(last: 5) { nodes { author { login }, state } }, + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } + } + } + pr1: repository(owner: $pr1Owner, name: $pr1Name) { + pullRequest(number: $pr1Number) { + # same fields as pr0 + } + } +} +``` + +## Changes Made + +### 1. Core Type Extensions (`packages/core/src/types.ts`) + +Added new interface for batch enrichment: + +```typescript +export interface PREnrichmentData { + state: PRState; + ciStatus: CIStatus; + reviewDecision: ReviewDecision; + mergeable: boolean; + title?: string; + additions?: number; + deletions?: number; + isDraft?: boolean; + hasConflicts?: boolean; + isBehind?: boolean; + blockers?: string[]; +} +``` + +Extended SCM interface with optional batch method: + +```typescript +export interface SCM { + // ... existing methods + + /** + * Batch fetch PR data for multiple PRs in a single GraphQL query. + * Used by the orchestrator to poll all active sessions efficiently. + */ + enrichSessionsPRBatch?(prs: PRInfo[]): Promise>; +} +``` + +### 2. GraphQL Batch Module (`packages/plugins/scm-github/src/graphql-batch.ts`) + +New module with: +- `generateBatchQuery()` - Dynamically generates GraphQL queries with aliases +- `enrichSessionsPRBatch()` - Main entry point that: + - Deduplicates PRs by key + - Splits into batches of 25 PRs (MAX_BATCH_SIZE) + - Executes queries via `gh api graphql` + - Returns Map + +Key features: +- Batch size limit of 25 PRs per query (well under GitHub's complexity limit) +- Graceful handling of missing/deleted PRs +- Error handling at batch level (one failed PR doesn't break the batch) +- CI status parsing from statusCheckRollup for comprehensive CI detection + +### 3. GitHub Plugin Integration (`packages/plugins/scm-github/src/index.ts`) + +Added implementation of `enrichSessionsPRBatch()`: + +```typescript +async enrichSessionsPRBatch(prs: PRInfo[]): Promise> { + return enrichSessionsPRBatch(prs); +} +``` + +The method is optional in the SCM interface, ensuring backward compatibility. + +### 4. Lifecycle Manager Updates (`packages/core/src/lifecycle-manager.ts`) + +Added batch enrichment to the polling loop: + +1. **Cache variable**: `prEnrichmentCache` - Map cleared at each poll cycle +2. **Populate function**: `populatePREnrichmentCache()` - Groups PRs by SCM plugin and calls batch enrichment +3. **Poll cycle update**: Calls `populatePREnrichmentCache()` before checking sessions +4. **Status detection**: Uses cached data when available, falls back to individual calls on cache miss + +```typescript +// At start of pollAll() +await populatePREnrichmentCache(sessionsToCheck); + +// In determineStatus() +const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; +const cachedData = prEnrichmentCache.get(prKey); +if (cachedData) { + // Use cached data - no API calls +} else { + // Fall back to individual calls +} +``` + +### 5. Unit Tests (`packages/plugins/scm-github/test/graphql-batch.test.ts`) + +Added comprehensive tests for: +- Single PR query generation +- Multiple PR query generation with different aliases +- Empty PR array handling +- Required field inclusion in queries +- Sequential numeric alias generation +- Special characters in owner/repo names + +## Performance Impact + +### API Call Reduction + +| Active PRs | Before | After (Batch) | Reduction | +|-------------|---------|-----------------|------------| +| 5 | 15 | 1 | 93% | +| 10 | 30 | 1 | 97% | +| 20 | 60 | 1 | 98% | +| 50 | 150 | 2 | 99% | + +### Hourly Rate Limit Usage (30s polling) + +| Active PRs | Before (calls) | After (calls) | % of 5,000 Limit | +|-------------|----------------|-----------------|-------------------| +| 10 | 3,600 | 120 | 2.4% ✅ | +| 20 | 7,200 ❌ | 240 | 4.8% ✅ | +| 50 | 18,000 ❌ | 600 | 12% ✅ | + +## Backward Compatibility + +The implementation maintains full backward compatibility: + +1. **Optional SCM method**: `enrichSessionsPRBatch()` is optional in the SCM interface +2. **Graceful fallback**: If batch enrichment fails or isn't available, the lifecycle manager falls back to individual API calls +3. **No breaking changes**: All existing SCM methods (`getPRState`, `getCISummary`, `getReviewDecision`, `getMergeability`) remain unchanged + +## Edge Cases Handled + +| Case | Handling | +|------|----------| +| PR deleted during polling | Returns enrichment data with state "closed" and appropriate blockers | +| GraphQL query failure | Falls back to individual API calls | +| Mixed SCM plugins | Groups PRs by plugin and calls batch enrichment for each group | +| Batch size > MAX_BATCH_SIZE | Splits into multiple batches | +| Cache miss | Falls back to individual API calls | + +## GraphQL Rate Limits + +GitHub GraphQL uses a points-based system. Our implementation: +- Uses ~50 points per PR (estimated) +- Allows ~100 PRs per hour within the 5,000 point limit +- Stays well under complexity limits with MAX_BATCH_SIZE=25 + +Note: Actual point costs should be monitored in production and MAX_BATCH_SIZE adjusted if needed. + +## Future Improvements + +1. **Metrics**: Add observability metrics for batch query success/failure rates +2. **Cache persistence**: Consider caching enrichment data across poll cycles for stable PRs +3. **Dynamic batching**: Auto-tune batch size based on GraphQL point usage +4. **Feature flag**: Add feature flag for gradual rollout + +## Testing + +Run the unit tests: + +```bash +cd packages/plugins/scm-github +npm test graphql-batch.test.ts +``` + +Integration testing should verify: +- Batch queries work with real GitHub repos +- PR state detection is accurate +- CI status parsing matches individual calls +- Review decision detection is accurate +- Error handling works as expected + +## Related Issues + +- Issue #608: GraphQL batching for orchestrator polling +- PR #617: Previous batching optimization (1 call per PR) + +## References + +- GitHub GraphQL API: https://docs.github.com/en/graphql +- GraphQL Aliases: https://graphql.org/learn/queries/#aliases +- gh CLI GraphQL: https://cli.github.com/manual/gh_api_graphql diff --git a/docs/specs/runtime-terminal-port-and-project-id-hardening.html b/docs/specs/runtime-terminal-port-and-project-id-hardening.html new file mode 100644 index 000000000..01958b0b8 --- /dev/null +++ b/docs/specs/runtime-terminal-port-and-project-id-hardening.html @@ -0,0 +1,573 @@ + + + + + + Runtime Terminal Port and Project-ID Hardening + + + +
+

Runtime Terminal Port and Project-ID Hardening

+ +
+
Status: Draft
+
Author: Agent Orchestrator
+
Date: 2026-03-28
+
Target Merge: main
+
+ +
+ +

Overview

+

+ This design documents two production issues that surfaced in first-run and npm-installed flows: +

+
    +
  1. Direct terminal stuck on CONNECTING... when runtime ports differ from client bundle fallback.
  2. +
  3. /api/spawn receiving a session ID as projectId, leading to deep-core Unknown project failures.
  4. +
+

+ The implementation introduces runtime configuration discovery for terminal WebSocket connection and stricter semantic validation for project identifiers at API and page-data boundaries. +

+ +

Problem Statement

+ +

Problem A: Terminal WebSocket Port Drift

+
    +
  • ao start can auto-select terminal ports at runtime (for example 14802/14803) when defaults are occupied.
  • +
  • Direct terminal server listens on DIRECT_TERMINAL_PORT at runtime.
  • +
  • Browser client previously relied on build-time NEXT_PUBLIC_DIRECT_TERMINAL_PORT, with fallback 14801.
  • +
  • In prebuilt Next.js client bundles, NEXT_PUBLIC_* values are embedded at build time.
  • +
  • Result: client can attempt ws://...:14801 while server is on 14803, leaving UI in permanent CONNECTING.
  • +
+ +

Problem B: Project ID / Session ID Domain Confusion

+
    +
  • Session IDs and project IDs share syntax ([a-zA-Z0-9_-]+).
  • +
  • Orchestrator session IDs are generated as ${project.sessionPrefix}-orchestrator, which can visually resemble project keys.
  • +
  • /api/spawn previously validated only identifier shape, not membership in config.projects.
  • +
  • Invalid but syntactically valid IDs reached core spawn logic and failed late with 500.
  • +
+ +

Root Cause Analysis

+

A. Build-Time vs Runtime Config Boundary Mismatch

+
    +
  • The server process controls actual runtime port assignment.
  • +
  • The browser bundle cannot safely depend on build-time env values for runtime-selected ports.
  • +
  • There was no first-party runtime endpoint for client port discovery.
  • +
+ +

B. Namespace Collision and Inconsistent Validation

+
    +
  • Project IDs and session IDs were treated as plain strings at API boundaries.
  • +
  • Some routes sanitize project filters against config; others previously did not.
  • +
  • Validation was format-only in /api/spawn instead of semantic (is configured project).
  • +
+ +

Goals

+
    +
  1. Make direct terminal connection deterministic across runtime-selected ports in prebuilt deployments.
  2. +
  3. Ensure /api/spawn rejects non-configured project IDs early and predictably.
  4. +
  5. Normalize dashboard project filter values so invalid query state cannot poison project context.
  6. +
  7. Preserve backwards compatibility for existing default-port setups.
  8. +
+ +

Non-Goals

+
    +
  1. Redesign session ID format.
  2. +
  3. Introduce full typed ID wrappers across all packages in this change.
  4. +
  5. Remove existing reverse-proxy path-based WS support.
  6. +
+ +

Proposed Design

+ +

1. Runtime Terminal Config Endpoint

+

Add GET /api/runtime/terminal (dynamic, no-store):

+
    +
  • terminalPort from TERMINAL_PORT (normalized, fallback 14800)
  • +
  • directTerminalPort from DIRECT_TERMINAL_PORT (normalized, fallback 14801)
  • +
  • proxyWsPath from TERMINAL_WS_PATH/NEXT_PUBLIC_TERMINAL_WS_PATH (normalized path or null)
  • +
+ +

2. Runtime-Aware DirectTerminal Connection

+

Update client connection flow:

+
    +
  1. Resolve build-time values if available.
  2. +
  3. Fetch /api/runtime/terminal before socket connect when needed.
  4. +
  5. Parse and normalize returned values.
  6. +
  7. Build WS URL from runtime values.
  8. +
  9. Reuse the same runtime-aware logic on reconnect attempts.
  10. +
+

Default ports remain unchanged; runtime-shifted ports become deterministic.

+ +

3. Semantic Project Validation in /api/spawn

+

Before calling spawn, verify config.projects[projectId] exists. If missing:

+
    +
  • Return 404 with Unknown project: <id>
  • +
  • Record structured observability failure reason
  • +
  • Do not invoke core spawn path
  • +
+ +

4. Dashboard Project Filter Normalization

+
    +
  • keep "all"
  • +
  • keep only configured project IDs
  • +
  • otherwise fallback to first valid configured project (or primary fallback)
  • +
+ +

Flow Chart

+ + + + +
+
Terminal Connection Flow
+
+ +
+ 1. User runs ao start <project-path> +
+
+ +
+ 2. CLI runtime setup — buildDashboardEnv() + Picks TERMINAL_PORT / DIRECT_TERMINAL_PORT at runtime +
+
+ +
+ 3. start-all launches servers + Next.js server + direct-terminal-ws on DIRECT_TERMINAL_PORT +
+
+ +
+ 4. Browser opens session page +
+
+ +
+ 5. resolveConnectionConfig() + Build-time NEXT_PUBLIC_* values available and valid? +
+
+ +
+
+ Yes +
+
+ Use build-time values directly +
+
+
+ No +
+
+ GET /api/runtime/terminal + Read runtime env ports, normalize, return config +
+
+
+ +
merge
+ +
+ 6. Client builds WS URL from resolved runtime config +
+
+ +
+ 7. WebSocket connect to direct-terminal-ws +
+
+ +
+ 8. Resolve tmux session + Exact match first → hash-prefixed suffix fallback +
+
+ +
+ 9. PTY attach succeeds — terminal interactive (CONNECTED) +
+ +
+
+ + +
+
Spawn Path
+
+ +
+ A. User triggers spawn + Dashboard / mobile / API caller +
+
+ +
+ B. POST /api/spawn with body.projectId +
+
+ +
+ C. validateIdentifier(projectId) + Syntax check — format only +
+
+ +
+ D. Semantic guard: config.projects[projectId] exists? +
+
+ +
+
+ Yes +
+
+ sessionManager.spawn() +
+
+
+ 201 Created + session payload + Dashboard/SSE shows active state +
+
+
+ No +
+
+ 404Unknown project: <id> + Stop early — core spawn not invoked +
+
+
+ +
+
+ + +
+
Project Filter Normalization
+
+
+ Incoming ?project=<value> from dashboard query +
+ + + + + + + + + + + + + + + + + + + + + +
ConditionResult
value == "all"Keep "all"
value ∈ configured projectsKeep value
otherwise (e.g. session ID like mono-orchestrator)Fallback to primary valid project
+

+ Invalid values cannot become active project context. +

+
+
+ +

Alternatives Considered

+
    +
  1. Keep fixed ports only (14800/14801) and disable auto-shift. Rejected: blocks multi-instance startup and fails on legitimate port conflicts.
  2. +
  3. Continue relying on NEXT_PUBLIC_* runtime injection. Rejected: production client bundles are build-time materialized.
  4. +
  5. Accept any projectId in /api/spawn and let core throw. Rejected: late 500 errors and poor API ergonomics.
  6. +
+ +

Risks and Mitigations

+
    +
  1. Runtime endpoint unavailable. Mitigation: client retains safe fallback and reconnect logic.
  2. +
  3. Reverse-proxy deployments with custom WS path. Mitigation: preserve proxy-path precedence and include runtime proxy field.
  4. +
  5. Behavior change for invalid project query. Mitigation: normalization affects only unknown IDs; valid IDs and all remain unchanged.
  6. +
+ +

Validation Plan

+
    +
  • GET /api/runtime/terminal returns runtime env ports.
  • +
  • POST /api/spawn returns 404 for unknown project and does not call spawn.
  • +
  • Project filter normalization keeps valid IDs, keeps all, and falls back on unknown IDs.
  • +
  • Existing DirectTerminal URL construction tests remain green.
  • +
+ +

Rollout Plan

+
    +
  1. Merge patch to main.
  2. +
  3. Release new npm package version containing web/client and API updates.
  4. +
  5. Announce behavior note: +
      +
    • default-port users unaffected
    • +
    • runtime-shifted port users no longer hit CONNECTING deadlock
    • +
    +
  6. +
+ +

Release Checklist (Commands)

+
# 1) Push branch and open PR
+git push origin fix-runtime-terminal-projectid-hardening
+
+# 2) Ensure patch changeset exists (this PR includes one)
+ls .changeset/five-lamps-heal.md
+
+# 3) CI validation (already required by repo workflows)
+pnpm --filter @composio/ao-web test -- \
+  src/__tests__/api-routes.test.ts \
+  src/lib/__tests__/dashboard-page-data.test.ts \
+  src/components/__tests__/DirectTerminal.test.ts
+
+# 4) After PR merge: version packages from changesets
+pnpm changeset version
+
+# 5) Commit version bumps and changelogs
+git add .
+git commit -m "chore(release): version packages for runtime terminal + spawn hardening"
+
+# 6) Publish
+pnpm release
+
+# 7) Post-release smoke check
+npm i -g @composio/ao@latest
+ao start <project-path>
+# verify terminal works when direct terminal port is non-default
+ +

Acceptance Criteria

+
    +
  1. Direct terminal connects in npm prebuilt flow even when runtime direct port is not 14801.
  2. +
  3. /api/spawn never returns 500 for unknown-but-valid-format project IDs.
  4. +
  5. Invalid project query values do not become active project state.
  6. +
  7. Existing default-port flows remain functional without configuration changes.
  8. +
+
+ + diff --git a/eslint.config.js b/eslint.config.js index 7477b9e01..b21609542 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,6 +7,7 @@ export default tseslint.config( { ignores: [ "**/dist/**", + "**/dist-server/**", "**/node_modules/**", "**/.next/**", "**/coverage/**", @@ -15,7 +16,6 @@ export default tseslint.config( "packages/web/postcss.config.mjs", "test-clipboard*.mjs", "test-clipboard*.sh", - "packages/mobile/**", ], }, diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index fcb3ebfbb..2149ce5d2 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,11 @@ # @composio/ao +## 0.2.2 + +### Patch Changes + +- @composio/ao-cli@0.2.2 + ## 0.2.1 ### Patch Changes diff --git a/packages/ao/package.json b/packages/ao/package.json index daf903300..47cf9e8eb 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,6 +1,6 @@ { "name": "@composio/ao", - "version": "0.2.1", + "version": "0.2.2", "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 1d50b2af6..3ebefb021 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # @composio/ao-cli +## 0.2.2 + +### Patch Changes + +- Updated dependencies [5315e4e] + - @composio/ao-web@0.2.2 + ## 0.2.1 ### Patch Changes diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index d965a7684..244c6aafd 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -1,9 +1,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Command } from "commander"; -const { mockRunRepoScript, mockFindConfigFile } = vi.hoisted(() => ({ +const { + mockRunRepoScript, + mockFindConfigFile, + mockLoadConfig, + mockCreatePluginRegistry, + mockDetectOpenClawInstallation, + mockValidateToken, + mockRegistry, +} = vi.hoisted(() => ({ mockRunRepoScript: vi.fn(), mockFindConfigFile: vi.fn(), + mockLoadConfig: vi.fn(), + mockCreatePluginRegistry: vi.fn(), + mockDetectOpenClawInstallation: vi.fn(), + mockValidateToken: vi.fn(), + mockRegistry: { + loadFromConfig: vi.fn(), + list: vi.fn(), + get: vi.fn(), + }, })); vi.mock("../../src/lib/script-runner.js", () => ({ @@ -11,28 +28,107 @@ vi.mock("../../src/lib/script-runner.js", () => ({ })); vi.mock("@composio/ao-core", () => ({ + createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args), findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), - loadConfig: vi.fn(), + getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability", + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), })); vi.mock("../../src/lib/openclaw-probe.js", () => ({ - probeGateway: vi.fn(), - validateToken: vi.fn(), + detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args), + validateToken: (...args: unknown[]) => mockValidateToken(...args), })); import { registerDoctor } from "../../src/commands/doctor.js"; +function manifest(slot: string, name: string) { + return { slot, name, description: `${name} plugin`, version: "1.0.0" }; +} + +function makeConfig() { + return { + configPath: "/tmp/agent-orchestrator.yaml", + port: 3000, + readyThresholdMs: 300_000, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["alerts"], + orchestrator: { agent: "codex" }, + worker: { agent: "claude-code" }, + }, + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "app", + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + tracker: { plugin: "github" }, + scm: { plugin: "github" }, + orchestrator: { agent: "codex" }, + worker: { agent: "claude-code" }, + }, + }, + notifiers: { + alerts: { plugin: "slack" }, + }, + notificationRouting: { + urgent: ["alerts"], + action: ["alerts"], + warning: ["alerts"], + info: ["alerts"], + }, + reactions: {}, + }; +} + describe("doctor command", () => { let program: Command; + let consoleLogSpy: ReturnType; + let processExitSpy: ReturnType; beforeEach(() => { program = new Command(); program.exitOverride(); registerDoctor(program); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + mockRunRepoScript.mockReset(); mockRunRepoScript.mockResolvedValue(0); + mockFindConfigFile.mockReset(); mockFindConfigFile.mockReturnValue(null); + + mockLoadConfig.mockReset(); + + mockCreatePluginRegistry.mockReset(); + mockCreatePluginRegistry.mockReturnValue(mockRegistry); + + mockRegistry.loadFromConfig.mockReset(); + mockRegistry.loadFromConfig.mockResolvedValue(undefined); + mockRegistry.list.mockReset(); + mockRegistry.list.mockReturnValue([]); + mockRegistry.get.mockReset(); + mockRegistry.get.mockReturnValue(null); + + mockDetectOpenClawInstallation.mockReset(); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "running", + gatewayUrl: "http://127.0.0.1:18789", + probe: { httpStatus: 200 }, + }); + mockValidateToken.mockReset(); + mockValidateToken.mockResolvedValue({ valid: true }); }); afterEach(() => { @@ -50,4 +146,108 @@ describe("doctor command", () => { expect(mockRunRepoScript).toHaveBeenCalledWith("ao-doctor.sh", ["--fix"]); }); + + it("checks configured plugin references when config is present", async () => { + const config = makeConfig(); + mockFindConfigFile.mockReturnValue(config.configPath); + mockLoadConfig.mockReturnValue(config); + + mockRegistry.list.mockImplementation((slot: string) => { + switch (slot) { + case "runtime": + return [manifest("runtime", "tmux")]; + case "agent": + return [manifest("agent", "claude-code"), manifest("agent", "codex")]; + case "workspace": + return [manifest("workspace", "worktree")]; + case "tracker": + return [manifest("tracker", "github")]; + case "scm": + return [manifest("scm", "github")]; + case "notifier": + return [manifest("notifier", "slack")]; + default: + return []; + } + }); + + await program.parseAsync(["node", "test", "doctor"]); + + expect(mockCreatePluginRegistry).toHaveBeenCalledTimes(1); + expect(mockRegistry.loadFromConfig).toHaveBeenCalledWith(config, expect.any(Function)); + + const output = consoleLogSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain('defaults.runtime -> runtime plugin "tmux"'); + expect(output).toContain('projects.my-app.scm.plugin -> scm plugin "github"'); + expect(output).toContain('defaults.notifiers: alerts (plugin: slack) -> notifier plugin "slack"'); + }); + + it("fails when a referenced plugin cannot be loaded", async () => { + const config = makeConfig(); + config.projects["my-app"].scm = { plugin: "gitlab" }; + mockFindConfigFile.mockReturnValue(config.configPath); + mockLoadConfig.mockReturnValue(config); + + mockRegistry.list.mockImplementation((slot: string) => { + switch (slot) { + case "runtime": + return [manifest("runtime", "tmux")]; + case "agent": + return [manifest("agent", "claude-code"), manifest("agent", "codex")]; + case "workspace": + return [manifest("workspace", "worktree")]; + case "tracker": + return [manifest("tracker", "github")]; + case "scm": + return [manifest("scm", "github")]; + case "notifier": + return [manifest("notifier", "slack")]; + default: + return []; + } + }); + + await expect(program.parseAsync(["node", "test", "doctor"])).rejects.toThrow("process.exit(1)"); + + const output = consoleLogSpy.mock.calls.map((call) => call[0]).join("\n"); + expect(output).toContain('projects.my-app.scm.plugin references scm plugin "gitlab"'); + }); + + it("resolves notifier aliases when sending test notifications", async () => { + const config = makeConfig(); + const mockNotifier = { notify: vi.fn().mockResolvedValue(undefined) }; + mockFindConfigFile.mockReturnValue(config.configPath); + mockLoadConfig.mockReturnValue(config); + + mockRegistry.list.mockImplementation((slot: string) => { + switch (slot) { + case "runtime": + return [manifest("runtime", "tmux")]; + case "agent": + return [manifest("agent", "claude-code"), manifest("agent", "codex")]; + case "workspace": + return [manifest("workspace", "worktree")]; + case "tracker": + return [manifest("tracker", "github")]; + case "scm": + return [manifest("scm", "github")]; + case "notifier": + return [manifest("notifier", "slack")]; + default: + return []; + } + }); + mockRegistry.get.mockImplementation((slot: string, name: string) => { + if (slot === "notifier" && name === "slack") { + return mockNotifier; + } + return null; + }); + + await program.parseAsync(["node", "test", "doctor", "--test-notify"]); + + expect(mockRegistry.get).toHaveBeenCalledWith("notifier", "slack"); + expect(mockNotifier.notify).toHaveBeenCalledTimes(1); + expect(processExitSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/__tests__/commands/plugin.test.ts b/packages/cli/__tests__/commands/plugin.test.ts new file mode 100644 index 000000000..ec54c939d --- /dev/null +++ b/packages/cli/__tests__/commands/plugin.test.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "commander"; +import { parse as parseYaml } from "yaml"; +import type { PluginManifest, PluginModule } from "@composio/ao-core"; + +const { + mockFindConfigFile, + mockGetLatestPublishedPackageVersion, + mockImportPluginModuleFromSource, + mockInstallPackageIntoStore, + mockReadInstalledPackageVersion, + mockRunSetupAction, + mockUninstallPackageFromStore, +} = vi.hoisted(() => ({ + mockFindConfigFile: vi.fn(), + mockGetLatestPublishedPackageVersion: vi.fn(), + mockImportPluginModuleFromSource: vi.fn(), + mockInstallPackageIntoStore: vi.fn(), + mockReadInstalledPackageVersion: vi.fn(), + mockRunSetupAction: vi.fn(), + mockUninstallPackageFromStore: vi.fn(), +})); + +vi.mock("@composio/ao-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + }; +}); + +vi.mock("../../src/lib/plugin-store.js", () => ({ + getLatestPublishedPackageVersion: (...args: unknown[]) => + mockGetLatestPublishedPackageVersion(...args), + importPluginModuleFromSource: (...args: unknown[]) => + mockImportPluginModuleFromSource(...args), + installPackageIntoStore: (...args: unknown[]) => mockInstallPackageIntoStore(...args), + readInstalledPackageVersion: (...args: unknown[]) => mockReadInstalledPackageVersion(...args), + uninstallPackageFromStore: (...args: unknown[]) => mockUninstallPackageFromStore(...args), +})); + +vi.mock("../../src/commands/setup.js", () => ({ + runSetupAction: (...args: unknown[]) => mockRunSetupAction(...args), +})); + +import { registerPlugin } from "../../src/commands/plugin.js"; + +const OPENCLAW_PACKAGE = "@composio/ao-plugin-notifier-openclaw"; +const GOOSE_PACKAGE = "@example/ao-plugin-agent-goose"; + +function makePlugin(slot: PluginManifest["slot"], name: string): PluginModule { + return { + manifest: { + name, + slot, + description: `Test ${slot} plugin: ${name}`, + version: "0.0.1", + }, + create: vi.fn(() => ({ name })), + }; +} + +function createProgram(): Command { + const program = new Command(); + registerPlugin(program); + return program; +} + +function writeConfig(configPath: string, extra: string[] = []): void { + writeFileSync( + configPath, + [ + "port: 3000", + "defaults:", + " runtime: tmux", + " agent: claude-code", + " workspace: worktree", + " notifiers: [desktop]", + ...extra, + "projects:", + " my-app:", + " name: my-app", + " repo: owner/repo", + ` path: ${join(tmpdir(), "my-app")}`, + ].join("\n"), + ); +} + +describe("plugin command", () => { + let tempDir: string; + let configPath: string; + let registryCachePath: string; + const storeVersions = new Map(); + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "ao-plugin-command-test-")); + configPath = join(tempDir, "agent-orchestrator.yaml"); + registryCachePath = join(tempDir, "plugin-registry-cache.json"); + writeConfig(configPath); + process.env["AO_PLUGIN_REGISTRY_CACHE_PATH"] = registryCachePath; + + mockFindConfigFile.mockReturnValue(configPath); + mockRunSetupAction.mockReset(); + mockGetLatestPublishedPackageVersion.mockReset(); + mockImportPluginModuleFromSource.mockReset(); + mockInstallPackageIntoStore.mockReset(); + mockReadInstalledPackageVersion.mockReset(); + mockUninstallPackageFromStore.mockReset(); + storeVersions.clear(); + + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + vi.spyOn(console, "log").mockImplementation(() => {}); + + mockReadInstalledPackageVersion.mockImplementation((packageName: string) => { + return storeVersions.get(packageName) ?? null; + }); + + mockInstallPackageIntoStore.mockImplementation(async (packageName: string, version?: string) => { + const resolved = version ?? "0.0.1"; + storeVersions.set(packageName, resolved); + return resolved; + }); + + mockUninstallPackageFromStore.mockImplementation(async (packageName: string) => { + return storeVersions.delete(packageName); + }); + + mockGetLatestPublishedPackageVersion.mockImplementation(async (packageName: string) => { + if (packageName === GOOSE_PACKAGE) return "1.1.0"; + return "0.0.1"; + }); + + mockImportPluginModuleFromSource.mockImplementation(async (specifier: string) => { + if (specifier === OPENCLAW_PACKAGE) return { default: makePlugin("notifier", "openclaw") }; + if (specifier === GOOSE_PACKAGE) return { default: makePlugin("agent", "goose") }; + throw new Error(`Not found: ${specifier}`); + }); + }); + + afterEach(() => { + delete process.env["AO_PLUGIN_REGISTRY_CACHE_PATH"]; + delete process.env["AO_PLUGIN_REGISTRY_URL"]; + rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("refreshes the marketplace registry cache and uses it for list/search", async () => { + const program = createProgram(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => [ + { + id: "tracker-jira", + package: "@example/ao-plugin-tracker-jira", + slot: "tracker", + description: "Tracker plugin: Jira issues", + source: "registry", + latestVersion: "0.3.0", + }, + ], + }); + vi.stubGlobal("fetch", fetchMock); + + await program.parseAsync(["node", "test", "plugin", "list", "--refresh"]); + + let output = vi + .mocked(console.log) + .mock.calls.map((call) => call.join(" ")) + .join("\n"); + expect(output).toContain("tracker-jira"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + vi.mocked(console.log).mockClear(); + + const searchProgram = createProgram(); + await searchProgram.parseAsync(["node", "test", "plugin", "search", "jira"]); + + output = vi + .mocked(console.log) + .mock.calls.map((call) => call.join(" ")) + .join("\n"); + expect(output).toContain("tracker-jira"); + }); + + it("creates a plugin scaffold in non-interactive mode", async () => { + const targetDir = join(tempDir, "acme-alerts"); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "plugin", + "create", + targetDir, + "--name", + "Acme Alerts", + "--slot", + "notifier", + "--description", + "Notifier plugin for Acme alerts", + "--author", + "Alice", + "--package-name", + "@alice/ao-plugin-notifier-acme-alerts", + "--non-interactive", + ]); + + expect(existsSync(join(targetDir, "package.json"))).toBe(true); + expect(existsSync(join(targetDir, "src", "index.ts"))).toBe(true); + expect(existsSync(join(targetDir, "README.md"))).toBe(true); + + const packageJson = JSON.parse(readFileSync(join(targetDir, "package.json"), "utf-8")) as { + name: string; + author?: string; + dependencies?: Record; + }; + expect(packageJson.name).toBe("@alice/ao-plugin-notifier-acme-alerts"); + expect(packageJson.author).toBe("Alice"); + expect(packageJson.dependencies?.["@composio/ao-core"]).toBe("^0.2.0"); + + const entrypoint = readFileSync(join(targetDir, "src", "index.ts"), "utf-8"); + expect(entrypoint).toContain('slot: "notifier" as const'); + expect(entrypoint).toContain('name: "acme-alerts"'); + }); + + it("installs a marketplace plugin through the AO-managed store before writing config", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "plugin", "install", "notifier-openclaw"]); + + const parsed = parseYaml(readFileSync(configPath, "utf-8")) as { + plugins?: Array>; + }; + expect(parsed.plugins).toHaveLength(1); + expect(parsed.plugins?.[0]).toMatchObject({ + name: "openclaw", + source: "registry", + package: OPENCLAW_PACKAGE, + version: "0.1.1", + }); + expect(mockInstallPackageIntoStore).toHaveBeenCalledWith(OPENCLAW_PACKAGE, "0.1.1"); + + // Install now always runs setup (auto-detect in non-TTY instead of deferring) + expect(mockRunSetupAction).toHaveBeenCalled(); + }); + + it("updates an npm plugin and persists the resolved store version", async () => { + writeConfig(configPath, [ + "plugins:", + " - name: goose", + " source: npm", + ` package: "${GOOSE_PACKAGE}"`, + " version: 1.0.0", + ]); + storeVersions.set(GOOSE_PACKAGE, "1.0.0"); + + const program = createProgram(); + await program.parseAsync(["node", "test", "plugin", "update", "goose"]); + + const parsed = parseYaml(readFileSync(configPath, "utf-8")) as { + plugins?: Array>; + }; + expect(parsed.plugins?.[0]).toMatchObject({ + name: "goose", + source: "npm", + package: GOOSE_PACKAGE, + version: "1.1.0", + }); + expect(storeVersions.get(GOOSE_PACKAGE)).toBe("1.1.0"); + expect(mockInstallPackageIntoStore).toHaveBeenCalledWith(GOOSE_PACKAGE, "1.1.0"); + }); + + it("rolls back the store version when an update fails verification", async () => { + writeConfig(configPath, [ + "plugins:", + " - name: goose", + " source: npm", + ` package: "${GOOSE_PACKAGE}"`, + " version: 1.0.0", + ]); + storeVersions.set(GOOSE_PACKAGE, "1.0.0"); + mockImportPluginModuleFromSource.mockImplementation(async (specifier: string) => { + if (specifier === GOOSE_PACKAGE) return {}; + throw new Error(`Not found: ${specifier}`); + }); + + const program = createProgram(); + + await expect( + program.parseAsync(["node", "test", "plugin", "update", "goose"]), + ).rejects.toThrow("Failed to update plugin"); + + const parsed = parseYaml(readFileSync(configPath, "utf-8")) as { + plugins?: Array>; + }; + expect(parsed.plugins?.[0]?.["version"]).toBe("1.0.0"); + expect(storeVersions.get(GOOSE_PACKAGE)).toBe("1.0.0"); + expect(mockInstallPackageIntoStore).toHaveBeenLastCalledWith(GOOSE_PACKAGE, "1.0.0"); + }); +}); diff --git a/packages/cli/__tests__/commands/send.test.ts b/packages/cli/__tests__/commands/send.test.ts index 990d9ceef..0f7a09975 100644 --- a/packages/cli/__tests__/commands/send.test.ts +++ b/packages/cli/__tests__/commands/send.test.ts @@ -36,6 +36,11 @@ vi.mock("../../src/lib/plugins.js", () => ({ processName: "claude", detectActivity: mockDetectActivity, }), + getAgentByNameFromRegistry: () => ({ + name: "claude-code", + processName: "claude", + detectActivity: mockDetectActivity, + }), })); vi.mock("../../src/lib/session-utils.js", () => ({ @@ -53,6 +58,7 @@ vi.mock("@composio/ao-core", () => ({ vi.mock("../../src/lib/create-session-manager.js", () => ({ getSessionManager: async () => mockSessionManager, + getPluginRegistry: async () => ({ get: vi.fn(), list: vi.fn(), register: vi.fn() }), })); import { Command } from "commander"; diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index a5c776f5d..ef399e226 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -19,9 +19,10 @@ const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = v mockMkdirSync: vi.fn(), })); -const { mockProbeGateway, mockValidateToken } = vi.hoisted(() => ({ +const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = vi.hoisted(() => ({ mockProbeGateway: vi.fn(), mockValidateToken: vi.fn(), + mockDetectOpenClawInstallation: vi.fn(), })); vi.mock("@composio/ao-core", () => ({ @@ -42,6 +43,7 @@ vi.mock("node:fs", async (importOriginal) => { vi.mock("../../src/lib/openclaw-probe.js", () => ({ probeGateway: (...args: unknown[]) => mockProbeGateway(...args), validateToken: (...args: unknown[]) => mockValidateToken(...args), + detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args), DEFAULT_OPENCLAW_URL: "http://127.0.0.1:18789", HOOKS_PATH: "/hooks/agent", })); @@ -282,6 +284,60 @@ describe("setup openclaw command", () => { expect(writtenYaml).toContain("wakeMode: now"); }); + it("defaults OpenClaw routing to urgent + action only", 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; + const parsed = parseYaml(writtenYaml) as { + notificationRouting?: Record; + }; + + expect(parsed.notificationRouting?.["urgent"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["action"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["warning"]).not.toContain("openclaw"); + expect(parsed.notificationRouting?.["info"]).not.toContain("openclaw"); + }); + + it("supports overriding the routing preset 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", + "tok", + "--routing-preset", + "all", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notificationRouting?: Record; + }; + + expect(parsed.notificationRouting?.["urgent"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["action"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["warning"]).toContain("openclaw"); + expect(parsed.notificationRouting?.["info"]).toContain("openclaw"); + }); + it("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => { const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); @@ -427,7 +483,12 @@ describe("setup openclaw command", () => { expect(mockWriteFileSync).toHaveBeenCalled(); }); - it("exits when --url missing in non-interactive mode", async () => { + it("exits when --url missing and gateway unreachable in non-interactive mode", async () => { + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "missing", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: false, error: "ECONNREFUSED" }, + }); const program = createProgram(); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index c9074cc07..3a54a4282 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -66,6 +66,7 @@ vi.mock("../../src/lib/metadata.js", () => ({ let tmpDir: string; let configPath: string; +let cwdSpy: ReturnType | undefined; import { Command } from "commander"; import { registerSpawn } from "../../src/commands/spawn.js"; @@ -126,6 +127,8 @@ beforeEach(() => { }); afterEach(() => { + cwdSpy?.mockRestore(); + cwdSpy = undefined; const projectBaseDir = getProjectBaseDir(configPath, join(tmpDir, "main-repo")); if (projectBaseDir) { rmSync(projectBaseDir, { recursive: true, force: true }); @@ -198,6 +201,58 @@ describe("spawn command", () => { }); }); + it("auto-detects the project from a nested cwd in multi-project configs", async () => { + (mockConfigRef.current as Record).projects = { + frontend: { + name: "Frontend", + repo: "org/frontend", + path: join(tmpDir, "frontend"), + defaultBranch: "main", + sessionPrefix: "fe", + }, + backend: { + name: "Backend", + repo: "org/backend", + path: join(tmpDir, "backend"), + defaultBranch: "main", + sessionPrefix: "be", + }, + }; + + const backendSubdir = join(tmpDir, "backend", "packages", "api"); + mkdirSync(backendSubdir, { recursive: true }); + cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(backendSubdir); + + const fakeSession: Session = { + id: "be-1", + projectId: "backend", + status: "spawning", + activity: null, + branch: "feat/INT-42", + issueId: "INT-42", + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "hash-be-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + await program.parseAsync(["node", "test", "spawn", "INT-42"]); + + expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith( + expect.objectContaining({ configPath: expect.any(String) }), + "backend", + ); + expect(mockSessionManager.spawn).toHaveBeenCalledWith({ + projectId: "backend", + issueId: "INT-42", + }); + }); + it("spawns without issueId when none provided", async () => { const fakeSession: Session = { id: "app-1", diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index c2e87a592..a869dc56a 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -44,6 +44,10 @@ const { mockStopLifecycleWorker: vi.fn(), })); +const { mockDetectOpenClawInstallation } = vi.hoisted(() => ({ + mockDetectOpenClawInstallation: vi.fn(), +})); + vi.mock("../../src/lib/shell.js", () => ({ tmux: vi.fn(), exec: mockExec, @@ -149,6 +153,10 @@ vi.mock("../../src/lib/project-detection.js", () => ({ formatProjectTypeForDisplay: vi.fn().mockReturnValue(""), })); +vi.mock("../../src/lib/openclaw-probe.js", () => ({ + detectOpenClawInstallation: (...args: unknown[]) => mockDetectOpenClawInstallation(...args), +})); + // Mock node:child_process — start.ts imports spawn for dashboard + browser open vi.mock("node:child_process", async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports @@ -215,6 +223,12 @@ beforeEach(() => { }); mockStopLifecycleWorker.mockReset(); mockStopLifecycleWorker.mockResolvedValue(true); + mockDetectOpenClawInstallation.mockReset(); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "missing", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: false, error: "not running" }, + }); mockSpawn.mockClear(); }); @@ -367,6 +381,50 @@ describe("start command — project resolution", () => { }); }); +describe("start command — OpenClaw preflight", () => { + it("warns when OpenClaw is configured but offline", async () => { + mockConfigRef.current = { + ...makeConfig({ "my-app": makeProject() }), + notifiers: { + openclaw: { + plugin: "openclaw", + url: "http://127.0.0.1:18789/hooks/agent", + }, + }, + }; + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "installed-but-stopped", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: false, error: "not running" }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]); + + const output = vi + .mocked(console.log) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(output).toContain("OpenClaw is configured but the gateway is not reachable"); + }); + + it("suggests setup when OpenClaw is running but not configured", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "running", + gatewayUrl: "http://127.0.0.1:18789", + probe: { reachable: true, httpStatus: 200 }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]); + + const output = vi + .mocked(console.log) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(output).toContain("ao setup openclaw"); + }); +}); + // --------------------------------------------------------------------------- // URL detection — `ao start ` triggers handleUrlStart // --------------------------------------------------------------------------- diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 3139922f4..5a47c6259 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -14,7 +14,6 @@ import { type Session, type SessionManager, type ActivityState, - getSessionsDir, } from "@composio/ao-core"; const { @@ -28,6 +27,7 @@ const { mockGetReviewDecision, mockGetPendingComments, mockSessionManager, + mockGetPluginRegistry, sessionsDirRef, } = vi.hoisted(() => ({ mockTmux: vi.fn(), @@ -49,6 +49,7 @@ const { send: vi.fn(), claimPR: vi.fn(), }, + mockGetPluginRegistry: vi.fn(), sessionsDirRef: { current: "" }, })); @@ -95,6 +96,13 @@ vi.mock("../../src/lib/plugins.js", () => ({ getSessionInfo: mockIntrospect, getActivityState: mockGetActivityState, }), + getAgentByNameFromRegistry: () => ({ + name: "claude-code", + processName: "claude", + detectActivity: () => "idle", + getSessionInfo: mockIntrospect, + getActivityState: mockGetActivityState, + }), getSCM: () => ({ name: "github", detectPR: mockDetectPR, @@ -115,6 +123,26 @@ vi.mock("../../src/lib/plugins.js", () => ({ mergePR: vi.fn(), closePR: vi.fn(), }), + getSCMFromRegistry: () => ({ + name: "github", + detectPR: mockDetectPR, + getCISummary: mockGetCISummary, + getReviewDecision: mockGetReviewDecision, + getPendingComments: mockGetPendingComments, + getAutomatedComments: vi.fn().mockResolvedValue([]), + getCIChecks: vi.fn().mockResolvedValue([]), + getReviews: vi.fn().mockResolvedValue([]), + getMergeability: vi.fn().mockResolvedValue({ + mergeable: true, + ciPassing: true, + approved: false, + noConflicts: true, + blockers: [], + }), + getPRState: vi.fn().mockResolvedValue("open"), + mergePR: vi.fn(), + closePR: vi.fn(), + }), })); /** Parse a key=value metadata file into a Record. */ @@ -179,6 +207,7 @@ function makeSession(overrides: Partial & { id: string; projectId: stri vi.mock("../../src/lib/create-session-manager.js", () => ({ getSessionManager: async (): Promise => mockSessionManager as SessionManager, + getPluginRegistry: (...args: unknown[]) => mockGetPluginRegistry(...args), })); let tmpDir: string; @@ -189,6 +218,9 @@ import { registerStatus } from "../../src/commands/status.js"; let program: Command; let consoleSpy: ReturnType; +let setIntervalSpy: ReturnType | undefined; +let clearIntervalSpy: ReturnType | undefined; +let processOnceSpy: ReturnType | undefined; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-")); @@ -221,8 +253,8 @@ beforeEach(() => { reactions: {}, } as Record; - // Calculate and create sessions directory for hash-based architecture - sessionsDir = getSessionsDir(configPath, join(tmpDir, "main-repo")); + // Keep test metadata under the temp fixture directory instead of ~/.agent-orchestrator. + sessionsDir = join(tmpDir, "sessions"); mkdirSync(sessionsDir, { recursive: true }); sessionsDirRef.current = sessionsDir; @@ -254,6 +286,9 @@ beforeEach(() => { mockSessionManager.get.mockReset(); mockSessionManager.spawn.mockReset(); mockSessionManager.send.mockReset(); + mockGetPluginRegistry.mockReset(); + // Default registry: no tracker + mockGetPluginRegistry.mockResolvedValue({ get: vi.fn().mockReturnValue(null), list: vi.fn(), register: vi.fn() }); // Default: list reads from sessionsDir mockSessionManager.list.mockImplementation(async () => { @@ -262,6 +297,12 @@ beforeEach(() => { }); afterEach(() => { + setIntervalSpy?.mockRestore(); + setIntervalSpy = undefined; + clearIntervalSpy?.mockRestore(); + clearIntervalSpy = undefined; + processOnceSpy?.mockRestore(); + processOnceSpy = undefined; rmSync(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -540,6 +581,81 @@ describe("status command", () => { expect(parsed[0].pendingThreads).toBe(0); }); + it("rejects --watch with --json", async () => { + await expect(program.parseAsync(["node", "test", "status", "--watch", "--json"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c[0]) + .join("\n"); + expect(errors).toContain("--watch cannot be used with --json"); + }); + + it("rejects non-positive watch intervals", async () => { + await expect(program.parseAsync(["node", "test", "status", "--watch", "--interval", "0"])).rejects.toThrow( + "process.exit(1)", + ); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c[0]) + .join("\n"); + expect(errors).toContain("--interval must be a positive integer"); + }); + + it("ignores --interval entirely when --watch is not set", async () => { + mockTmux.mockResolvedValue(null); + mockSessionManager.list.mockResolvedValue([]); + + // Invalid value (0) should NOT cause an error without --watch + await expect( + program.parseAsync(["node", "test", "status", "--interval", "0"]), + ).resolves.not.toThrow(); + + // Valid value should also be silently ignored without --watch + await expect( + program.parseAsync(["node", "test", "status", "--interval", "10"]), + ).resolves.not.toThrow(); + }); + + it("schedules watch refreshes with the requested interval", async () => { + mockTmux.mockResolvedValue(null); + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as never); + + await program.parseAsync(["node", "test", "status", "--watch", "--interval", "3"]); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 3000); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("Refreshing every 3s. Press Ctrl+C to exit."); + }); + + it("cleans up the watch timer on shutdown signals", async () => { + mockTmux.mockResolvedValue(null); + + const watchTimer = { id: "watch-timer" } as unknown as ReturnType; + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => watchTimer); + clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined); + + const signalHandlers = new Map void>(); + processOnceSpy = vi.spyOn(process, "once").mockImplementation((event, listener) => { + if (event === "SIGINT" || event === "SIGTERM") { + signalHandlers.set(event, listener as () => void); + } + return process; + }); + + await program.parseAsync(["node", "test", "status", "--watch"]); + + expect(signalHandlers.has("SIGINT")).toBe(true); + expect(signalHandlers.has("SIGTERM")).toBe(true); + + expect(() => signalHandlers.get("SIGINT")?.()).toThrow("process.exit(0)"); + expect(clearIntervalSpy).toHaveBeenCalledWith(watchTimer); + }); + it("falls back to PR number from metadata URL when SCM fails", async () => { writeFileSync( join(sessionsDir, "app-1"), @@ -830,4 +946,220 @@ describe("status command", () => { project: "my-app", }); }); + + // ── lines 262-266: loadConfig() throws → fallback to tmux discovery ─────── + it("falls back to tmux session discovery when loadConfig throws", async () => { + // The vi.mock for @composio/ao-core uses `() => mockConfigRef.current`. + // Setting current to a throwing getter makes loadConfig throw. + // Simpler: use a Proxy-based trick — but easiest is a getter that throws. + const originalCurrent = mockConfigRef.current; + Object.defineProperty(mockConfigRef, "current", { + get() { + throw new Error("no config file"); + }, + configurable: true, + }); + + // No tmux sessions — fallback should print the banner with "No config found" + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "list-sessions") return null; + return null; + }); + mockIntrospect.mockResolvedValue(null); + + try { + await program.parseAsync(["node", "test", "status"]); + } finally { + // Restore mockConfigRef.current to a plain data property + Object.defineProperty(mockConfigRef, "current", { + value: originalCurrent, + writable: true, + configurable: true, + }); + } + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("No config found"); + expect(output).toContain("Falling back to session discovery"); + }); + + // ── lines 269-271: unknown --project flag ─────────────────────────────── + it("exits with error when --project refers to an unknown project", async () => { + mockTmux.mockResolvedValue(null); + mockSessionManager.list.mockResolvedValue([]); + + await expect( + program.parseAsync(["node", "test", "status", "--project", "no-such-project"]), + ).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c[0]) + .join("\n"); + expect(errors).toContain("Unknown project: no-such-project"); + }); + + // ── lines 388, 390-396, 402-405: tracker unverified-issues warning ──────── + it("shows unverified issues warning when tracker returns merged-unverified issues", async () => { + const mockListIssues = vi.fn().mockResolvedValue([{ id: "ISS-1" }, { id: "ISS-2" }]); + const mockTracker = { listIssues: mockListIssues }; + + mockConfigRef.current = { + ...(mockConfigRef.current as Record), + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: join(tmpDir, "main-repo"), + defaultBranch: "main", + sessionPrefix: "app", + scm: { plugin: "github" }, + tracker: { plugin: "linear" }, + }, + }, + } as Record; + + // Use the hoisted mockGetPluginRegistry fn to surface our tracker + mockGetPluginRegistry.mockResolvedValueOnce({ + get: vi.fn().mockReturnValue(mockTracker), + list: vi.fn(), + register: vi.fn(), + }); + + mockSessionManager.list.mockResolvedValue([]); + mockTmux.mockResolvedValue(null); + + await program.parseAsync(["node", "test", "status"]); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n"); + expect(output).toContain("awaiting verification"); + expect(mockListIssues).toHaveBeenCalledWith( + { state: "open", labels: ["merged-unverified"], limit: 20 }, + expect.objectContaining({ tracker: { plugin: "linear" } }), + ); + }); + + // ── line 398: tracker listIssues() rejects → swallowed silently ─────────── + it("handles tracker listIssues failure gracefully without crashing", async () => { + const mockListIssues = vi.fn().mockRejectedValue(new Error("tracker down")); + const mockTracker = { listIssues: mockListIssues }; + + mockConfigRef.current = { + ...(mockConfigRef.current as Record), + projects: { + "my-app": { + name: "My App", + repo: "org/my-app", + path: join(tmpDir, "main-repo"), + defaultBranch: "main", + sessionPrefix: "app", + scm: { plugin: "github" }, + tracker: { plugin: "linear" }, + }, + }, + } as Record; + + mockGetPluginRegistry.mockResolvedValueOnce({ + get: vi.fn().mockReturnValue(mockTracker), + list: vi.fn(), + register: vi.fn(), + }); + + mockSessionManager.list.mockResolvedValue([]); + mockTmux.mockResolvedValue(null); + + // Must not throw + await expect(program.parseAsync(["node", "test", "status"])).resolves.not.toThrow(); + }); + + // ── lines 65-69 (isTTY branch) + 255-256 (maybeClearScreen on refresh) ─── + it("writes clear-screen escape when stdout is a TTY during watch refresh", async () => { + mockTmux.mockResolvedValue(null); + mockSessionManager.list.mockResolvedValue([]); + + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + let capturedCallback: (() => void) | undefined; + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => { + capturedCallback = fn as () => void; + return 77 as never; + }); + clearIntervalSpy = vi + .spyOn(globalThis, "clearInterval") + .mockImplementation(() => undefined); + processOnceSpy = vi.spyOn(process, "once").mockImplementation((_e, _l) => process); + + await program.parseAsync(["node", "test", "status", "--watch", "--interval", "5"]); + + expect(capturedCallback).toBeDefined(); + // Fire interval callback — this calls renderStatus(true) which calls maybeClearScreen() + capturedCallback!(); + // Allow promises to settle + await new Promise((r) => setTimeout(r, 20)); + + expect(writeSpy).toHaveBeenCalledWith("\x1Bc"); + + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + writeSpy.mockRestore(); + }); + + // ── lines 424-425: watch guard skips render when already in progress ────── + it("skips a watch refresh when the previous render is still in progress", async () => { + let renderCount = 0; + let unblockSlowRender!: () => void; + const slowRenderFinished = new Promise((res) => { + unblockSlowRender = res; + }); + + mockSessionManager.list.mockImplementation(async () => { + renderCount++; + if (renderCount === 2) { + // First watch-refresh (second overall list call) — block deliberately + await slowRenderFinished; + } + return []; + }); + + mockTmux.mockResolvedValue(null); + + let capturedCallback: (() => void) | undefined; + setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => { + capturedCallback = fn as () => void; + return 55 as never; + }); + clearIntervalSpy = vi + .spyOn(globalThis, "clearInterval") + .mockImplementation(() => undefined); + processOnceSpy = vi.spyOn(process, "once").mockImplementation((_e, _l) => process); + + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + + await program.parseAsync(["node", "test", "status", "--watch"]); + + // First interval tick — starts a slow render + capturedCallback!(); + await new Promise((r) => setTimeout(r, 0)); + + const countAfterFirst = renderCount; + + // Second tick while first is still pending — `rendering` guard should block it + capturedCallback!(); + await new Promise((r) => setTimeout(r, 0)); + expect(renderCount).toBe(countAfterFirst); // no additional list() call + + // Unblock slow render + unblockSlowRender(); + await new Promise((r) => setTimeout(r, 20)); + + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + }); }); diff --git a/packages/cli/__tests__/lib/openclaw-probe.test.ts b/packages/cli/__tests__/lib/openclaw-probe.test.ts index 037d428a2..85b9fad92 100644 --- a/packages/cli/__tests__/lib/openclaw-probe.test.ts +++ b/packages/cli/__tests__/lib/openclaw-probe.test.ts @@ -1,9 +1,39 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { probeGateway, validateToken } from "../../src/lib/openclaw-probe.js"; + +const { mockExistsSync, mockSpawnSync } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockSpawnSync: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + existsSync: (...args: unknown[]) => mockExistsSync(...args), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + spawnSync: (...args: unknown[]) => mockSpawnSync(...args), + }; +}); + +import { + detectOpenClawInstallation, + probeGateway, + validateToken, +} from "../../src/lib/openclaw-probe.js"; describe("openclaw-probe", () => { afterEach(() => { vi.unstubAllGlobals(); + mockExistsSync.mockReset(); + mockExistsSync.mockReturnValue(false); + mockSpawnSync.mockReset(); + mockSpawnSync.mockReturnValue({ status: 1, stdout: "" }); }); describe("probeGateway", () => { @@ -65,6 +95,51 @@ describe("openclaw-probe", () => { expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789"); }); + + it("strips /hooks/agent when probing a hooks URL", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal("fetch", fetchMock); + + await probeGateway("http://127.0.0.1:18789/hooks/agent"); + + expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789"); + }); + }); + + describe("detectOpenClawInstallation", () => { + it("reports missing when binary/config are absent and gateway is down", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const result = await detectOpenClawInstallation(); + + expect(result.state).toBe("missing"); + expect(result.binaryPath).toBeUndefined(); + expect(result.configPath).toBeUndefined(); + }); + + it("reports installed-but-stopped when binary exists but gateway is down", async () => { + mockSpawnSync.mockReturnValue({ status: 0, stdout: "/usr/local/bin/openclaw\n" }); + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const result = await detectOpenClawInstallation(); + + expect(result.state).toBe("installed-but-stopped"); + expect(result.binaryPath).toBe("/usr/local/bin/openclaw"); + }); + + it("reports running when gateway is reachable", async () => { + mockSpawnSync.mockReturnValue({ status: 0, stdout: "/usr/local/bin/openclaw\n" }); + mockExistsSync.mockReturnValue(true); + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const result = await detectOpenClawInstallation(); + + expect(result.state).toBe("running"); + expect(result.configPath).toContain(".openclaw/openclaw.json"); + }); }); describe("validateToken", () => { diff --git a/packages/cli/__tests__/lib/plugins.test.ts b/packages/cli/__tests__/lib/plugins.test.ts index d558aa2d5..101ec2b8f 100644 --- a/packages/cli/__tests__/lib/plugins.test.ts +++ b/packages/cli/__tests__/lib/plugins.test.ts @@ -1,15 +1,20 @@ import { describe, it, expect } from "vitest"; -import { getAgent, getAgentByName } from "../../src/lib/plugins.js"; -import type { OrchestratorConfig } from "@composio/ao-core"; +import { + getAgent, + getAgentByName, + getAgentByNameFromRegistry, + getSCMFromRegistry, +} from "../../src/lib/plugins.js"; +import type { Agent, OrchestratorConfig, PluginRegistry, SCM } from "@composio/ao-core"; function makeConfig( defaultAgent: string, - projects?: Record, + projects?: Record, ): OrchestratorConfig { return { - dataDir: "/tmp", - worktreeDir: "/tmp/wt", + configPath: "/tmp/agent-orchestrator.yaml", port: 3000, + readyThresholdMs: 300_000, defaults: { runtime: "tmux", agent: defaultAgent, workspace: "worktree", notifiers: [] }, projects: Object.fromEntries( Object.entries(projects ?? { app: {} }).map(([id, p]) => [ @@ -23,6 +28,23 @@ function makeConfig( } as OrchestratorConfig; } +function makeRegistry(entries: { + agent?: Record; + scm?: Record; +}): PluginRegistry { + return { + register: () => {}, + get: (slot, name) => { + if (slot === "agent") return (entries.agent?.[name] ?? null) as Agent | null; + if (slot === "scm") return (entries.scm?.[name] ?? null) as SCM | null; + return null; + }, + list: () => [], + loadBuiltins: async () => {}, + loadFromConfig: async () => {}, + }; +} + describe("getAgent", () => { it("returns claude-code agent by default", () => { const config = makeConfig("claude-code"); @@ -75,3 +97,57 @@ describe("getAgentByName", () => { expect(() => getAgentByName("unknown")).toThrow("Unknown agent plugin: unknown"); }); }); + +describe("registry-backed resolution", () => { + it("returns an agent from the shared registry", () => { + const registry = makeRegistry({ + agent: { + goose: { + name: "goose", + processName: "goose", + instructions: "", + launch: async () => { + throw new Error("not implemented"); + }, + detectActivity: () => "idle", + getSessionInfo: async () => null, + } as unknown as Agent, + }, + }); + + expect(getAgentByNameFromRegistry(registry, "goose").name).toBe("goose"); + }); + + it("returns an scm plugin from the shared registry", () => { + const registry = makeRegistry({ + scm: { + gitlab: { + name: "gitlab", + getPR: async () => null, + detectPR: async () => null, + getPRState: async () => "open", + getReviewDecision: async () => null, + getPendingComments: async () => 0, + getAutomatedComments: async () => [], + getCIChecks: async () => [], + getCISummary: async () => null, + getReviews: async () => [], + getMergeability: async () => ({ + mergeable: true, + ciPassing: true, + approved: true, + noConflicts: true, + blockers: [], + }), + mergePR: async () => {}, + closePR: async () => {}, + } as unknown as SCM, + }, + }); + const config = makeConfig("claude-code", { + myapp: { agent: "claude-code", scm: { plugin: "gitlab" } }, + }); + + expect(getSCMFromRegistry(registry, config, "myapp").name).toBe("gitlab"); + }); +}); diff --git a/packages/cli/__tests__/lib/project-resolution.test.ts b/packages/cli/__tests__/lib/project-resolution.test.ts new file mode 100644 index 000000000..d087d4ba7 --- /dev/null +++ b/packages/cli/__tests__/lib/project-resolution.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { findProjectForDirectory } from "../../src/lib/project-resolution.js"; + +describe("findProjectForDirectory", () => { + it("returns a project when cwd is inside a project subdirectory", () => { + const projectId = findProjectForDirectory( + { + frontend: { path: "/repos/frontend" }, + backend: { path: "/repos/backend" }, + }, + "/repos/backend/packages/api", + ); + + expect(projectId).toBe("backend"); + }); + + it("prefers the deepest matching project path", () => { + const projectId = findProjectForDirectory( + { + monorepo: { path: "/repos/mono" }, + docs: { path: "/repos/mono/docs" }, + }, + "/repos/mono/docs/guides", + ); + + expect(projectId).toBe("docs"); + }); + + it("returns null when cwd is outside every configured project", () => { + const projectId = findProjectForDirectory( + { + frontend: { path: "/repos/frontend" }, + }, + "/repos/backend", + ); + + expect(projectId).toBeNull(); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 50371acab..b0609d3c7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@composio/ao-cli", - "version": "0.2.1", + "version": "0.2.2", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", @@ -25,7 +25,7 @@ "node": ">=20.0.0" }, "scripts": { - "build": "tsc", + "build": "tsc && cp -r src/assets dist/", "dev": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/cli/src/assets/plugin-registry.json b/packages/cli/src/assets/plugin-registry.json new file mode 100644 index 000000000..4265d912c --- /dev/null +++ b/packages/cli/src/assets/plugin-registry.json @@ -0,0 +1,59 @@ +[ + { + "id": "agent-codex", + "package": "@composio/ao-plugin-agent-codex", + "slot": "agent", + "description": "Agent plugin: OpenAI Codex CLI", + "source": "registry", + "latestVersion": "0.2.0" + }, + { + "id": "agent-aider", + "package": "@composio/ao-plugin-agent-aider", + "slot": "agent", + "description": "Agent plugin: Aider CLI", + "source": "registry", + "latestVersion": "0.2.0" + }, + { + "id": "agent-opencode", + "package": "@composio/ao-plugin-agent-opencode", + "slot": "agent", + "description": "Agent plugin: OpenCode CLI", + "source": "registry", + "latestVersion": "0.2.0" + }, + { + "id": "notifier-openclaw", + "package": "@composio/ao-plugin-notifier-openclaw", + "slot": "notifier", + "description": "Notifier plugin: OpenClaw webhook notifications", + "source": "registry", + "setupAction": "openclaw-setup", + "latestVersion": "0.1.1" + }, + { + "id": "notifier-discord", + "package": "@composio/ao-plugin-notifier-discord", + "slot": "notifier", + "description": "Notifier plugin: Discord notifications", + "source": "registry", + "latestVersion": "0.1.0" + }, + { + "id": "tracker-gitlab", + "package": "@composio/ao-plugin-tracker-gitlab", + "slot": "tracker", + "description": "Tracker plugin: GitLab issues", + "source": "registry", + "latestVersion": "0.1.1" + }, + { + "id": "scm-gitlab", + "package": "@composio/ao-plugin-scm-gitlab", + "slot": "scm", + "description": "SCM plugin: GitLab pull/merge requests", + "source": "registry", + "latestVersion": "0.1.1" + } +] diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index cd61ef62a..8f494c77d 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig } from "@composio/ao-core"; @@ -58,11 +60,21 @@ export function registerDashboard(program: Command): void { config.directTerminalPort, ); - const child = spawn("npx", ["next", "dev", "-p", String(port)], { - cwd: webDir, - stdio: ["inherit", "inherit", "pipe"], - env, - }); + // In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND + // the terminal WebSocket servers via concurrently. Without the WS servers, + // the live terminal in the dashboard won't work. + const isDevMode = existsSync(resolve(webDir, "server")); + const child = isDevMode + ? spawn("pnpm", ["run", "dev"], { + cwd: webDir, + stdio: ["inherit", "inherit", "pipe"], + env, + }) + : spawn("npx", ["next", "dev", "-p", String(port)], { + cwd: webDir, + stdio: ["inherit", "inherit", "pipe"], + env, + }); const stderrChunks: string[] = []; diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 65e416327..d0e7fcc07 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,8 +1,20 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import type { Command } from "commander"; import chalk from "chalk"; -import { findConfigFile, loadConfig, type Notifier, type OrchestratorConfig } from "@composio/ao-core"; +import { + createPluginRegistry, + findConfigFile, + getObservabilityBaseDir, + loadConfig, + type Notifier, + type OrchestratorConfig, + type PluginRegistry, + type PluginSlot, +} from "@composio/ao-core"; import { runRepoScript } from "../lib/script-runner.js"; -import { probeGateway, validateToken } from "../lib/openclaw-probe.js"; +import { detectOpenClawInstallation, validateToken } from "../lib/openclaw-probe.js"; +import { importPluginModuleFromSource } from "../lib/plugin-store.js"; // --------------------------------------------------------------------------- // Helpers — match the PASS / WARN / FAIL style of ao-doctor.sh @@ -30,10 +42,194 @@ function makeFailCounter(): { fail: (msg: string) => void; count: () => number } }; } +type CheckedPluginSlot = Extract< + PluginSlot, + "runtime" | "agent" | "workspace" | "tracker" | "scm" | "notifier" +>; + +interface PluginReference { + slot: CheckedPluginSlot; + pluginName: string; + source: string; +} + +interface NotifierTarget { + label: string; + pluginName: string; +} + +async function loadPluginRegistry(config: OrchestratorConfig): Promise { + const registry = createPluginRegistry(); + await registry.loadFromConfig(config, importPluginModuleFromSource); + return registry; +} + +function addPluginReference( + refs: PluginReference[], + slot: CheckedPluginSlot, + pluginName: string | undefined, + source: string, +): void { + if (!pluginName) return; + refs.push({ slot, pluginName, source }); +} + +function resolveNotifierTarget(config: OrchestratorConfig, ref: string): NotifierTarget { + const configured = config.notifiers?.[ref]; + if (configured?.plugin) { + return { label: ref, pluginName: configured.plugin }; + } + return { label: ref, pluginName: ref }; +} + +function collectPluginReferences(config: OrchestratorConfig): PluginReference[] { + const refs: PluginReference[] = []; + + addPluginReference(refs, "runtime", config.defaults.runtime, "defaults.runtime"); + addPluginReference(refs, "agent", config.defaults.agent, "defaults.agent"); + addPluginReference(refs, "workspace", config.defaults.workspace, "defaults.workspace"); + addPluginReference(refs, "agent", config.defaults.orchestrator?.agent, "defaults.orchestrator.agent"); + addPluginReference(refs, "agent", config.defaults.worker?.agent, "defaults.worker.agent"); + + for (const notifierName of config.defaults.notifiers ?? []) { + const target = resolveNotifierTarget(config, notifierName); + addPluginReference( + refs, + "notifier", + target.pluginName, + `defaults.notifiers: ${target.label} (plugin: ${target.pluginName})`, + ); + } + + for (const [priority, notifierNames] of Object.entries(config.notificationRouting ?? {})) { + for (const notifierName of notifierNames) { + const target = resolveNotifierTarget(config, notifierName); + addPluginReference( + refs, + "notifier", + target.pluginName, + `notificationRouting.${priority}: ${target.label} (plugin: ${target.pluginName})`, + ); + } + } + + for (const [name, notifierConfig] of Object.entries(config.notifiers ?? {})) { + addPluginReference( + refs, + "notifier", + notifierConfig.plugin, + `notifiers.${name} (plugin: ${notifierConfig.plugin})`, + ); + } + + for (const [projectId, project] of Object.entries(config.projects)) { + addPluginReference(refs, "runtime", project.runtime, `projects.${projectId}.runtime`); + addPluginReference(refs, "agent", project.agent, `projects.${projectId}.agent`); + addPluginReference(refs, "workspace", project.workspace, `projects.${projectId}.workspace`); + addPluginReference( + refs, + "agent", + project.orchestrator?.agent, + `projects.${projectId}.orchestrator.agent`, + ); + addPluginReference(refs, "agent", project.worker?.agent, `projects.${projectId}.worker.agent`); + addPluginReference( + refs, + "tracker", + project.tracker?.plugin, + `projects.${projectId}.tracker.plugin`, + ); + addPluginReference(refs, "scm", project.scm?.plugin, `projects.${projectId}.scm.plugin`); + } + + return refs; +} + +async function checkPluginResolution( + config: OrchestratorConfig, + fail: (msg: string) => void, +): Promise { + console.log(""); + console.log("Plugin resolution:"); + + const registry = await loadPluginRegistry(config); + const loadedBySlot = new Map>(); + const slots: CheckedPluginSlot[] = [ + "runtime", + "agent", + "workspace", + "tracker", + "scm", + "notifier", + ]; + + for (const slot of slots) { + loadedBySlot.set( + slot, + new Set(registry.list(slot).map((manifest) => manifest.name)), + ); + } + + const references = collectPluginReferences(config); + if (references.length === 0) { + warn("No plugin references found in config."); + return registry; + } + + for (const ref of references) { + const loaded = loadedBySlot.get(ref.slot); + if (loaded?.has(ref.pluginName)) { + pass(`${ref.source} -> ${ref.slot} plugin "${ref.pluginName}"`); + } else { + fail( + `${ref.source} references ${ref.slot} plugin "${ref.pluginName}", but it could not be loaded. ` + + `Fix: install the plugin or correct the config value.`, + ); + } + } + + return registry; +} + // --------------------------------------------------------------------------- // Notifier connectivity checks (Gap 2) // --------------------------------------------------------------------------- +interface OpenClawHealthSummary { + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastFailureError: string | null; + totalSent: number; + totalFailed: number; +} + +function formatTimestamp(timestamp: string | null): string | null { + if (!timestamp) return null; + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString(); +} + +function readOpenClawHealth(config: OrchestratorConfig): OpenClawHealthSummary | null { + if (!config.configPath) return null; + const healthPath = join(getObservabilityBaseDir(config.configPath), "openclaw-health.json"); + if (!existsSync(healthPath)) return null; + + try { + const raw = readFileSync(healthPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + if (typeof parsed !== "object" || parsed === null) return null; + return { + lastSuccessAt: typeof parsed.lastSuccessAt === "string" ? parsed.lastSuccessAt : null, + lastFailureAt: typeof parsed.lastFailureAt === "string" ? parsed.lastFailureAt : null, + lastFailureError: typeof parsed.lastFailureError === "string" ? parsed.lastFailureError : null, + totalSent: typeof parsed.totalSent === "number" ? parsed.totalSent : 0, + totalFailed: typeof parsed.totalFailed === "number" ? parsed.totalFailed : 0, + }; + } catch { + return null; + } +} + async function checkOpenClawNotifier( config: OrchestratorConfig, fail: (msg: string) => void, @@ -53,33 +249,58 @@ async function checkOpenClawNotifier( 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`, + const installation = await detectOpenClawInstallation(url); + if (installation.state === "running") { + pass( + `OpenClaw gateway detected at ${installation.gatewayUrl} (HTTP ${installation.probe.httpStatus})`, + ); + } else if (installation.state === "installed-but-stopped") { + const installHint = installation.binaryPath + ? `installed at ${installation.binaryPath}` + : "configured on this machine"; + fail(`OpenClaw is ${installHint} but the gateway is not running at ${installation.gatewayUrl}`); + } else { + fail( + `OpenClaw is not installed locally and the gateway is not reachable at ${installation.gatewayUrl}. ` + + `Fix: install/start OpenClaw or update the notifier URL`, ); - 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", ); + } else if (installation.state === "running") { + const tokenResult = await validateToken(installation.gatewayUrl, token); + if (!tokenResult.valid) { + fail(`OpenClaw token validation failed: ${tokenResult.error}`); + } else { + pass("OpenClaw token is valid"); + } + } + + const health = readOpenClawHealth(config); + if (!health) { + warn("No OpenClaw notification history recorded yet"); return; } - const tokenResult = await validateToken(url, token); - if (!tokenResult.valid) { - fail(`OpenClaw token validation failed: ${tokenResult.error}`); - return; + const lastSuccess = formatTimestamp(health.lastSuccessAt); + if (lastSuccess) { + pass(`OpenClaw last successful notification: ${lastSuccess}`); + } else { + warn("OpenClaw has not recorded a successful notification yet"); } - pass("OpenClaw token is valid"); + if (health.lastFailureAt) { + const lastFailure = formatTimestamp(health.lastFailureAt); + warn( + `OpenClaw last failure: ${lastFailure ?? health.lastFailureAt} (${health.lastFailureError ?? "unknown error"})`, + ); + } + + pass(`OpenClaw notification totals: ${health.totalSent} sent, ${health.totalFailed} failed`); } async function checkNotifierConnectivity( @@ -114,29 +335,35 @@ async function checkNotifierConnectivity( async function sendTestNotifications( config: OrchestratorConfig, + registry: PluginRegistry, 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 ?? {}); + const configuredNotifiers = Object.entries(config.notifiers ?? {}); + const targets = new Map(); - // Combine both lists (defaults + configured) and deduplicate - const allNames = [...new Set([...activeNotifierNames, ...configuredNotifiers])]; + for (const [name, notifierConfig] of configuredNotifiers) { + targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin }); + } - if (allNames.length === 0) { + for (const name of activeNotifierNames) { + const target = resolveNotifierTarget(config, name); + if (!targets.has(target.pluginName)) { + targets.set(target.pluginName, target); + } + } + + if (targets.size === 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`); + console.log(`\nSending test notification to ${targets.size} notifier(s)...\n`); - for (const name of allNames) { - const notifier = registry.get("notifier", name); + for (const target of targets.values()) { + const notifier = registry.get("notifier", target.pluginName); if (!notifier) { - warn(`${name}: plugin not loaded (may not be installed)`); + warn(`${target.label}: plugin "${target.pluginName}" not loaded (may not be installed)`); continue; } @@ -153,10 +380,10 @@ async function sendTestNotifications( }; await notifier.notify(testEvent); - pass(`${name}: test notification sent`); + pass(`${target.label}: test notification sent`); } catch (err) { const message = err instanceof Error ? err.message : String(err); - fail(`${name}: ${message}`); + fail(`${target.label}: ${message}`); } } } @@ -192,18 +419,20 @@ export function registerDoctor(program: Command): void { const configPath = findConfigFile(); if (configPath) { let config: ReturnType | undefined; + let registry: PluginRegistry | undefined; try { config = loadConfig(configPath); + registry = await checkPluginResolution(config, fail); await checkNotifierConnectivity(config, fail); } catch (err) { const message = err instanceof Error ? err.message : String(err); - warn(`Notifier connectivity check failed: ${message}`); + fail(`Config-aware doctor checks failed: ${message}`); } // 3. Send test notifications if requested (separate catch for accurate errors) - if (opts.testNotify && config) { + if (opts.testNotify && config && registry) { try { - await sendTestNotifications(config, fail); + await sendTestNotifications(config, registry, fail); } catch (err) { const message = err instanceof Error ? err.message : String(err); fail(`Sending test notifications failed: ${message}`); diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts new file mode 100644 index 000000000..0c7c8ed58 --- /dev/null +++ b/packages/cli/src/commands/plugin.ts @@ -0,0 +1,602 @@ +import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import type { Command } from "commander"; +import { + createPluginRegistry, + findConfigFile, + loadConfig, + type PluginSlot, + type InstalledPluginConfig, + type OrchestratorConfig, +} from "@composio/ao-core"; +import { parseDocument } from "yaml"; +import { + buildPluginDescriptor, + findMarketplacePlugin, + loadMarketplaceCatalog, + normalizeImportedPluginModule, + refreshMarketplaceCatalog, +} from "../lib/plugin-marketplace.js"; +import { + getLatestPublishedPackageVersion, + importPluginModuleFromSource, + installPackageIntoStore, + readInstalledPackageVersion, + uninstallPackageFromStore, +} from "../lib/plugin-store.js"; +import { + buildDefaultPackageName, + normalizePluginName, + resolveScaffoldDirectory, + scaffoldPlugin, +} from "../lib/plugin-scaffold.js"; +import { runSetupAction } from "./setup.js"; + +function findInstalledPlugin( + plugins: InstalledPluginConfig[], + descriptor: InstalledPluginConfig, +): InstalledPluginConfig | undefined { + return plugins.find((plugin) => { + if (descriptor.package && plugin.package === descriptor.package) return true; + if (descriptor.path && plugin.path === descriptor.path) return true; + return plugin.name === descriptor.name; + }); +} + +function upsertInstalledPlugin( + plugins: InstalledPluginConfig[], + descriptor: InstalledPluginConfig, +): InstalledPluginConfig[] { + const existing = findInstalledPlugin(plugins, descriptor); + if (!existing) { + return [...plugins, descriptor]; + } + + return plugins.map((plugin) => (plugin === existing ? { ...existing, ...descriptor } : plugin)); +} + +function writePluginsConfig(configPath: string, plugins: InstalledPluginConfig[]): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawConfig = (doc.toJS() as Record) ?? {}; + if (plugins.length === 0) { + delete rawConfig.plugins; + } else { + rawConfig.plugins = plugins; + } + doc.contents = doc.createNode(rawConfig) as typeof doc.contents; + const rendered = doc.toString({ indent: 2 }); + const tempPath = `${configPath}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tempPath, rendered, "utf-8"); + renameSync(tempPath, configPath); +} + +function matchesPluginReference(plugin: InstalledPluginConfig, reference: string): boolean { + const marketplacePlugin = findMarketplacePlugin(reference); + return ( + plugin.name === reference || + plugin.package === reference || + plugin.path === reference || + (marketplacePlugin !== undefined && + (plugin.package === marketplacePlugin.package || plugin.name === marketplacePlugin.id)) + ); +} + +async function rollbackManagedPackageInstall( + packageName: string, + previousVersion: string | null, +): Promise { + if (previousVersion) { + await installPackageIntoStore(packageName, previousVersion); + return; + } + + await uninstallPackageFromStore(packageName); +} + +function formatInstallSummary(descriptor: InstalledPluginConfig, configPath: string): string { + const version = descriptor.version ? ` @ ${descriptor.version}` : ""; + return `Installed ${descriptor.name}${version} (${descriptor.source}) into ${configPath}`; +} + +async function installOrVerifyPlugin( + config: OrchestratorConfig, + descriptor: InstalledPluginConfig, + specifier: string, +): Promise { + if (!descriptor.package || descriptor.source === "local") { + return verifyPluginDescriptor(config, descriptor, specifier); + } + + const previousVersion = readInstalledPackageVersion(descriptor.package); + let installCompleted = false; + + try { + const installedVersion = await installPackageIntoStore(descriptor.package, descriptor.version); + installCompleted = true; + return await verifyPluginDescriptor( + config, + { ...descriptor, version: installedVersion }, + descriptor.package, + ); + } catch (err) { + if (installCompleted) { + try { + await rollbackManagedPackageInstall(descriptor.package, previousVersion); + } catch (rollbackErr) { + const message = err instanceof Error ? err.message : String(err); + const rollbackMessage = + rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); + throw new Error( + `${message}\nRollback failed for ${descriptor.package}: ${rollbackMessage}`, + { cause: rollbackErr }, + ); + } + } + throw err; + } +} + +async function resolveTargetVersion(plugin: InstalledPluginConfig): Promise { + if (!plugin.package) { + throw new Error(`Plugin ${plugin.name} does not have a package-backed source and cannot be updated.`); + } + + const marketplacePlugin = + findMarketplacePlugin(plugin.package) ?? findMarketplacePlugin(plugin.name); + if (marketplacePlugin?.latestVersion) { + return marketplacePlugin.latestVersion; + } + + return getLatestPublishedPackageVersion(plugin.package); +} + +async function updateManagedPlugin( + configPath: string, + config: OrchestratorConfig, + plugin: InstalledPluginConfig, +): Promise<"updated" | "skipped"> { + if (!plugin.package || plugin.source === "local") { + throw new Error(`Plugin ${plugin.name} is local-only and cannot be updated through the AO store.`); + } + + const currentVersion = readInstalledPackageVersion(plugin.package) ?? plugin.version ?? null; + const targetVersion = await resolveTargetVersion(plugin); + + if (currentVersion === targetVersion) { + if (plugin.version !== currentVersion && currentVersion) { + writePluginsConfig( + configPath, + upsertInstalledPlugin(config.plugins ?? [], { ...plugin, version: currentVersion }), + ); + } + console.log(chalk.dim(`${plugin.name} is already up to date (${targetVersion}).`)); + return "skipped"; + } + + const previousVersion = readInstalledPackageVersion(plugin.package); + let installCompleted = false; + + try { + const installedVersion = await installPackageIntoStore(plugin.package, targetVersion); + installCompleted = true; + const verifiedDescriptor = await verifyPluginDescriptor( + config, + { ...plugin, version: installedVersion }, + plugin.package, + ); + writePluginsConfig( + configPath, + upsertInstalledPlugin(config.plugins ?? [], verifiedDescriptor), + ); + console.log( + chalk.green( + `Updated ${verifiedDescriptor.name} from ${currentVersion ?? "not installed"} to ${installedVersion}`, + ), + ); + return "updated"; + } catch (err) { + if (installCompleted) { + try { + await rollbackManagedPackageInstall(plugin.package, previousVersion); + } catch (rollbackErr) { + const message = err instanceof Error ? err.message : String(err); + const rollbackMessage = + rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); + throw new Error( + `${message}\nRollback failed for ${plugin.package}: ${rollbackMessage}`, + { cause: rollbackErr }, + ); + } + } + throw err; + } +} + +async function verifyPluginDescriptor( + config: OrchestratorConfig, + descriptor: InstalledPluginConfig, + specifier: string, +): Promise { + const imported = normalizeImportedPluginModule(await importPluginModuleFromSource(specifier)); + if (!imported) { + throw new Error(`Imported module ${specifier} does not export a valid AO plugin`); + } + + const normalizedDescriptor: InstalledPluginConfig = { + ...descriptor, + name: imported.manifest.name, + }; + + const registry = createPluginRegistry(); + const tempConfig: OrchestratorConfig = { + ...config, + plugins: upsertInstalledPlugin(config.plugins ?? [], normalizedDescriptor), + }; + const originalWarn = console.warn; + console.warn = () => {}; + try { + await registry.loadFromConfig(tempConfig, importPluginModuleFromSource); + } finally { + console.warn = originalWarn; + } + + const registered = registry.get(imported.manifest.slot, imported.manifest.name); + if (!registered) { + throw new Error( + `Plugin ${imported.manifest.name} imported successfully but did not load through the registry`, + ); + } + + return normalizedDescriptor; +} + +function printPluginListFromCatalog( + config: OrchestratorConfig | null, + installedOnly: boolean, + catalog: ReturnType, + slotFilter?: string, +): void { + const installed = config?.plugins ?? []; + + if (installedOnly) { + const filtered = slotFilter + ? installed.filter((p) => { + const entry = catalog.find((e) => e.id === p.name || e.package === p.package); + return entry?.slot === slotFilter; + }) + : installed; + + if (filtered.length === 0) { + console.log(chalk.dim(slotFilter ? `No installed plugins with type "${slotFilter}".` : "No plugins are installed in this config.")); + return; + } + + for (const plugin of filtered) { + const source = plugin.source === "local" ? plugin.path : plugin.package; + const version = plugin.version ? ` @ ${plugin.version}` : ""; + console.log( + `${chalk.green(plugin.name)}${chalk.dim(version)} ${chalk.dim(`(${plugin.source}) ${source ?? ""}`)}`, + ); + } + return; + } + + const filteredCatalog = slotFilter ? catalog.filter((p) => p.slot === slotFilter) : catalog; + + if (filteredCatalog.length === 0) { + console.log(chalk.dim(slotFilter ? `No marketplace plugins with type "${slotFilter}".` : "No marketplace plugins found.")); + return; + } + + for (const plugin of filteredCatalog) { + const isInstalled = installed.some( + (entry) => entry.package === plugin.package || entry.name === plugin.id, + ); + const marker = isInstalled ? chalk.green("installed") : chalk.dim("available"); + console.log(`${chalk.cyan(plugin.id)} ${chalk.dim(`(${plugin.slot})`)} ${marker}`); + console.log(chalk.dim(` ${plugin.description}`)); + } +} + +export function registerPlugin(program: Command): void { + const plugin = program.command("plugin").description("Browse and manage AO plugins"); + + plugin + .command("list") + .description("List bundled marketplace plugins") + .option("--installed", "Show only plugins installed in the current config") + .option("--type ", "Filter by plugin slot (e.g. agent, notifier, tracker)") + .option("--refresh", "Fetch the latest registry and update the local marketplace cache") + .action(async (opts: { installed?: boolean; type?: string; refresh?: boolean }) => { + let config: OrchestratorConfig | null = null; + const configPath = findConfigFile(); + if (configPath) { + config = loadConfig(configPath); + } + const catalog = opts.refresh === true ? await refreshMarketplaceCatalog() : loadMarketplaceCatalog(); + printPluginListFromCatalog(config, opts.installed === true, catalog, opts.type); + }); + + plugin + .command("search") + .description("Search the bundled marketplace catalog") + .argument("", "Search term") + .action((query: string) => { + const normalizedQuery = query.trim().toLowerCase(); + const matches = loadMarketplaceCatalog().filter((plugin) => { + return ( + plugin.id.toLowerCase().includes(normalizedQuery) || + plugin.package.toLowerCase().includes(normalizedQuery) || + plugin.description.toLowerCase().includes(normalizedQuery) || + plugin.slot.toLowerCase().includes(normalizedQuery) + ); + }); + + if (matches.length === 0) { + console.log(chalk.dim(`No marketplace plugins matched "${query}".`)); + return; + } + + for (const match of matches) { + console.log(`${chalk.cyan(match.id)} ${chalk.dim(`(${match.slot})`)}`); + console.log(chalk.dim(` ${match.description}`)); + } + }); + + plugin + .command("create") + .description("Scaffold a new AO plugin package") + .argument("[directory]", "Target directory for the new plugin") + .option("--name ", "Display/plugin name") + .option("--slot ", "Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal") + .option("--description ", "Short plugin description") + .option("--author ", "Package author") + .option("--package-name ", "npm package name") + .option("--non-interactive", "Skip prompts and require explicit values for required fields") + .action( + async ( + directory: string | undefined, + opts: { + name?: string; + slot?: string; + description?: string; + author?: string; + packageName?: string; + nonInteractive?: boolean; + }, + ) => { + const slotOptions: PluginSlot[] = [ + "runtime", + "agent", + "workspace", + "tracker", + "scm", + "notifier", + "terminal", + ]; + const isInteractive = process.stdin.isTTY && opts.nonInteractive !== true; + let name = opts.name; + let slot = slotOptions.find((candidate) => candidate === opts.slot); + let description = opts.description; + let author = opts.author ?? process.env["USER"] ?? process.env["USERNAME"]; + let packageName = opts.packageName; + + if (isInteractive) { + const clack = await import("@clack/prompts"); + clack.intro(chalk.bgCyan(chalk.black(" ao plugin create "))); + + if (!name) { + const value = await clack.text({ + message: "Plugin display name:", + placeholder: "My Plugin", + validate: (input) => { + if (!input) return "Plugin name is required"; + try { + normalizePluginName(input); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + }, + }); + if (clack.isCancel(value)) { + clack.cancel("Plugin creation cancelled."); + process.exit(0); + } + name = value as string; + } + + if (!slot) { + const value = await clack.select({ + message: "Plugin slot:", + options: slotOptions.map((candidate) => ({ value: candidate, label: candidate })), + }); + if (clack.isCancel(value)) { + clack.cancel("Plugin creation cancelled."); + process.exit(0); + } + slot = value as PluginSlot; + } + + if (!description) { + const value = await clack.text({ + message: "Short description:", + placeholder: `AO ${slot} plugin`, + validate: (input) => (!input ? "Description is required" : undefined), + }); + if (clack.isCancel(value)) { + clack.cancel("Plugin creation cancelled."); + process.exit(0); + } + description = value as string; + } + + if (!author) { + const value = await clack.text({ + message: "Author:", + placeholder: "Your Name", + }); + if (clack.isCancel(value)) { + clack.cancel("Plugin creation cancelled."); + process.exit(0); + } + author = (value as string) || undefined; + } + + if (!packageName) { + const value = await clack.text({ + message: "Package name:", + initialValue: buildDefaultPackageName(slot, name), + validate: (input) => (!input ? "Package name is required" : undefined), + }); + if (clack.isCancel(value)) { + clack.cancel("Plugin creation cancelled."); + process.exit(0); + } + packageName = value as string; + } + } + + if (!name) throw new Error("--name is required"); + if (!slot) throw new Error("--slot is required"); + if (!description) throw new Error("--description is required"); + + const targetDirectory = resolveScaffoldDirectory(name, directory); + const createdDir = scaffoldPlugin({ + author, + description, + directory: targetDirectory, + displayName: name, + packageName: packageName ?? buildDefaultPackageName(slot, name), + slot, + }); + + console.log(chalk.green(`Created plugin scaffold at ${createdDir}`)); + console.log(chalk.dim(` Package: ${packageName ?? buildDefaultPackageName(slot, name)}`)); + console.log(chalk.dim(` Slot: ${slot}`)); + console.log(chalk.dim(" Next: npm install && npm run build")); + }, + ); + + plugin + .command("install") + .description("Install a plugin into the current config") + .argument("", "Marketplace id, package name, or local path") + .option("--url ", "OpenClaw webhook URL (passed to setup when installing notifier-openclaw)") + .option("--token ", "OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)") + .action(async (reference: string, opts: { url?: string; token?: string }) => { + const configPath = findConfigFile(); + if (!configPath) { + throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first."); + } + + const config = loadConfig(configPath); + const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath); + const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier); + const previousPlugins = config.plugins ?? []; + const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor); + writePluginsConfig(configPath, nextPlugins); + + console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath))); + + if (setupAction === "openclaw-setup") { + // Always run setup — interactive in TTY, auto-detect in non-TTY. + // Non-interactive setup will auto-detect OpenClaw on localhost if + // no --url is given and the gateway is reachable. + try { + await runSetupAction({ url: opts.url, token: opts.token }); + } catch (err) { + // Rollback: restore previous plugin list so a failed setup + // doesn't leave a half-configured notifier enabled in config. + writePluginsConfig(configPath, previousPlugins); + console.log(chalk.dim("Rolled back plugin config after setup failure.")); + throw err; + } + } + }); + + plugin + .command("update") + .description("Update installer-managed plugins in the current config") + .argument("[reference]", "Installed plugin name, marketplace id, or package name") + .option("--all", "Update all installer-managed plugins in the current config") + .action(async (reference: string | undefined, opts: { all?: boolean }) => { + const configPath = findConfigFile(); + if (!configPath) { + throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first."); + } + + if (!reference && opts.all !== true) { + throw new Error("Specify a plugin reference or pass --all."); + } + + let config = loadConfig(configPath); + const managedPlugins = (config.plugins ?? []).filter((plugin) => plugin.source !== "local"); + const targets = opts.all + ? managedPlugins + : managedPlugins.filter((plugin) => reference && matchesPluginReference(plugin, reference)); + + if (targets.length === 0) { + throw new Error( + opts.all + ? "No installer-managed plugins are configured in this project." + : `Plugin ${reference} is not installed in this config`, + ); + } + + const failures: string[] = []; + let updated = 0; + let skipped = 0; + + for (const target of targets) { + try { + const result = await updateManagedPlugin(configPath, config, target); + if (result === "updated") { + updated++; + } else { + skipped++; + } + config = loadConfig(configPath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + failures.push(`${target.name}: ${message}`); + } + } + + if (updated > 0 || skipped > 0) { + console.log( + chalk.dim( + `Plugin update summary: ${updated} updated, ${skipped} already current, ${failures.length} failed.`, + ), + ); + } + + if (failures.length > 0) { + throw new Error(`Failed to update plugin(s):\n ${failures.join("\n ")}`); + } + }); + + plugin + .command("uninstall") + .description("Remove a plugin from the current config") + .argument("", "Installed plugin name, package name, or local path") + .action((reference: string) => { + const configPath = findConfigFile(); + if (!configPath) { + throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first."); + } + + const config = loadConfig(configPath); + const nextPlugins = (config.plugins ?? []).filter((plugin) => { + return !matchesPluginReference(plugin, reference); + }); + + if (nextPlugins.length === (config.plugins ?? []).length) { + throw new Error(`Plugin ${reference} is not installed in this config`); + } + + writePluginsConfig(configPath, nextPlugins); + console.log(chalk.green(`Removed ${reference} from ${configPath}`)); + }); +} diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index dff39abe1..86dec8c37 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -10,8 +10,8 @@ import { loadConfig, } from "@composio/ao-core"; import { exec, tmux } from "../lib/shell.js"; -import { getAgentByName } from "../lib/plugins.js"; -import { getSessionManager } from "../lib/create-session-manager.js"; +import { getAgentByName, getAgentByNameFromRegistry } from "../lib/plugins.js"; +import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js"; /** * Resolve session context: tmux target name and Agent plugin. @@ -26,6 +26,7 @@ async function resolveSessionContext(sessionName: string): Promise<{ }> { try { const config = loadConfig(); + const registry = await getPluginRegistry(config); const sm = await getSessionManager(config); const session = await sm.get(sessionName); if (session) { @@ -37,7 +38,7 @@ async function resolveSessionContext(sessionName: string): Promise<{ return { tmuxTarget, runtimeName, - agent: getAgentByName(agentName), + agent: getAgentByNameFromRegistry(registry, agentName), session, sessionManager: sm, }; diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 0c6cf88d5..12e486216 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -51,17 +51,30 @@ export function registerSession(program: Command): void { continue; } - for (const s of projectSessions) { - // Get live branch from worktree if available - let branchStr = s.branch || ""; - if (s.workspacePath) { - const liveBranch = await git(["branch", "--show-current"], s.workspacePath); - if (liveBranch) branchStr = liveBranch; - } + // Pre-fetch all branches and activities in parallel + const branches = await Promise.all( + projectSessions.map(async (s) => { + if (s.workspacePath) { + return git(["branch", "--show-current"], s.workspacePath).catch(() => null); + } + return null; + }), + ); - // Get tmux activity age - const tmuxTarget = s.runtimeHandle?.id ?? s.id; - const activityTs = await getTmuxActivity(tmuxTarget); + const activities = await Promise.all( + projectSessions.map((s) => { + const tmuxTarget = s.runtimeHandle?.id ?? s.id; + return getTmuxActivity(tmuxTarget).catch(() => null); + }), + ); + + for (let i = 0; i < projectSessions.length; i++) { + const s = projectSessions[i]; + const liveBranch = branches[i]; + const activityTs = activities[i]; + + // Priority: live branch from workspace > metadata branch > empty string + const branchStr = (s.workspacePath && liveBranch) ? liveBranch : (s.branch || ""); const age = activityTs ? formatAge(activityTs) : "-"; const parts = [chalk.green(s.id), chalk.dim(`(${age})`)]; diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 451c92445..31388df8e 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -18,6 +18,7 @@ import { findConfigFile } from "@composio/ao-core"; import { probeGateway, validateToken, + detectOpenClawInstallation, DEFAULT_OPENCLAW_URL, HOOKS_PATH, } from "../lib/openclaw-probe.js"; @@ -40,11 +41,27 @@ interface SetupOptions { url?: string; token?: string; nonInteractive?: boolean; + routingPreset?: OpenClawRoutingPreset; } +type OpenClawRoutingPreset = "urgent-only" | "urgent-action" | "all"; + interface ResolvedConfig { url: string; token: string; + routingPreset: OpenClawRoutingPreset; +} + +const OPENCLAW_ROUTING_PRESETS = { + "urgent-only": ["urgent"], + "urgent-action": ["urgent", "action"], + all: ["urgent", "action", "warning", "info"], +} as const; + +const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const; + +function isRoutingPreset(value: string | undefined): value is OpenClawRoutingPreset { + return value === "urgent-only" || value === "urgent-action" || value === "all"; } function normalizeOpenClawHooksUrl(url: string): string { @@ -174,7 +191,26 @@ async function interactiveSetup(existingUrl?: string): Promise { } } - return { url, token: tokenValue }; + const routingPreset = await clack.select({ + message: "Which notifications should OpenClaw receive?", + initialValue: "urgent-action", + options: [ + { value: "urgent-action", label: "Urgent + action (recommended)" }, + { value: "urgent-only", label: "Urgent only" }, + { value: "all", label: "All priorities" }, + ], + }); + + if (clack.isCancel(routingPreset)) { + clack.cancel("Setup cancelled."); + throw new SetupAbortedError("Setup cancelled.", 0); + } + + return { + url, + token: tokenValue, + routingPreset: routingPreset as OpenClawRoutingPreset, + }; } // --------------------------------------------------------------------------- @@ -182,14 +218,22 @@ async function interactiveSetup(existingUrl?: string): Promise { // --------------------------------------------------------------------------- async function nonInteractiveSetup(opts: SetupOptions): Promise { - const rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; + let rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"]; + // Auto-detect OpenClaw if no URL was given explicitly 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", - ); + const installation = await detectOpenClawInstallation(); + if (installation.state === "running") { + rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`; + console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`)); + } else { + throw new SetupAbortedError( + "Error: OpenClaw gateway not reachable and no --url provided.\n" + + " Start OpenClaw first, or pass --url explicitly:\n" + + " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive", + ); + } } let url = rawUrl; @@ -209,7 +253,9 @@ async function nonInteractiveSetup(opts: SetupOptions): Promise // 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 }; + const routingPreset = isRoutingPreset(opts.routingPreset) ? opts.routingPreset : "urgent-action"; + + return { url, token: resolvedToken, routingPreset }; } // --------------------------------------------------------------------------- @@ -252,12 +298,14 @@ function writeOpenClawConfig( rawConfig.defaults.notifiers.push("openclaw"); } - // Add "openclaw" to notificationRouting so notifications actually fire - // (AO prefers per-priority routing over defaults.notifiers) + const defaultsWithoutOpenClaw = (rawConfig.defaults.notifiers as string[]).filter( + (name) => name !== "openclaw", + ); + + // AO routes notifications by priority. Preserve existing notifiers and only + // adjust OpenClaw membership across the standard priority buckets. 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"])]; + const base = [...new Set(defaultsWithoutOpenClaw)]; rawConfig.notificationRouting = { urgent: [...base], action: [...base], @@ -265,14 +313,22 @@ function writeOpenClawConfig( 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"); + for (const priority of NOTIFICATION_PRIORITIES) { + if (!Array.isArray(rawConfig.notificationRouting[priority])) { + rawConfig.notificationRouting[priority] = []; } } } + const selectedPriorities = new Set(OPENCLAW_ROUTING_PRESETS[resolved.routingPreset]); + for (const priority of NOTIFICATION_PRIORITIES) { + const list = rawConfig.notificationRouting[priority] as string[]; + rawConfig.notificationRouting[priority] = list.filter((name) => name !== "openclaw"); + if (selectedPriorities.has(priority)) { + rawConfig.notificationRouting[priority].push("openclaw"); + } + } + // Update the document tree from the modified plain object while preserving comments doc.setIn(["notifiers"], rawConfig.notifiers); doc.setIn(["defaults"], rawConfig.defaults); @@ -438,7 +494,11 @@ export function registerSetup(program: Command): void { .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)") + .option( + "--routing-preset ", + "OpenClaw routing preset: urgent-only | urgent-action | all", + ) + .option("--non-interactive", "Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)") .action(async (opts: SetupOptions) => { try { await runSetupAction(opts); @@ -452,7 +512,7 @@ export function registerSetup(program: Command): void { }); } -async function runSetupAction(opts: SetupOptions): Promise { +export async function runSetupAction(opts: SetupOptions): Promise { const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; // --- Find existing config ------------------------------------------------ diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 3c981e5c1..9d54635ce 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -9,7 +9,6 @@ import { getSiblings, formatPlanTree, TERMINAL_STATUSES, - expandHome, type OrchestratorConfig, type DecomposerConfig, DEFAULT_DECOMPOSER_CONFIG, @@ -19,6 +18,7 @@ import { banner } from "../lib/format.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { ensureLifecycleWorker } from "../lib/lifecycle-service.js"; import { preflight } from "../lib/preflight.js"; +import { findProjectForDirectory } from "../lib/project-resolution.js"; /** * Auto-detect the project ID from the config. @@ -43,10 +43,9 @@ function autoDetectProject(config: OrchestratorConfig): string { // Try matching cwd to a project path const cwd = resolve(process.cwd()); - for (const [id, project] of Object.entries(config.projects)) { - if (project.path && resolve(expandHome(project.path)) === cwd) { - return id; - } + const matchedProjectId = findProjectForDirectory(config.projects, cwd); + if (matchedProjectId) { + return matchedProjectId; } throw new Error( diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 507116960..f1c14a95a 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -41,6 +41,7 @@ import { findWebDir, buildDashboardEnv, waitForPortAndOpen, + openUrl, isPortAvailable, findFreePort, MAX_PORT_SCAN, @@ -52,11 +53,16 @@ import { isHumanCaller } from "../lib/caller-context.js"; import { detectEnvironment } from "../lib/detect-env.js"; import { detectAgentRuntime, detectAvailableAgents, type DetectedAgent } from "../lib/detect-agent.js"; import { detectDefaultBranch } from "../lib/git-utils.js"; +import { promptConfirm, promptSelect } from "../lib/prompts.js"; import { detectProjectType, generateRulesFromTemplates, formatProjectTypeForDisplay, } from "../lib/project-detection.js"; +import { formatCommandError } from "../lib/cli-errors.js"; +import { detectOpenClawInstallation } from "../lib/openclaw-probe.js"; +import { applyOpenClawCredentials } from "../lib/credential-resolver.js"; +import { findProjectForDirectory } from "../lib/project-resolution.js"; const DEFAULT_PORT = 3000; const IS_TTY = Boolean(process.stdin.isTTY && process.stdout.isTTY); @@ -101,32 +107,22 @@ async function resolveProject( // Multiple projects — try matching cwd to a project path // Note: loadConfig() already expands ~ in project paths via expandPaths() const currentDir = resolve(cwd()); - for (const [id, proj] of Object.entries(config.projects)) { - if (resolve(proj.path) === currentDir) { - return { projectId: id, project: proj }; - } + const matchedProjectId = findProjectForDirectory(config.projects, currentDir); + if (matchedProjectId) { + return { projectId: matchedProjectId, project: config.projects[matchedProjectId] }; } // No match — prompt if interactive, otherwise error if (isHumanCaller()) { - console.log(chalk.yellow(`\nMultiple projects configured. Which one would you like to ${action}?\n`)); - projectIds.forEach((id, i) => console.log(` ${i + 1}. ${config.projects[id].name ?? id} (${id})`)); - - const { createInterface } = await import("node:readline/promises"); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const choice = await rl.question(`\n Choose project [1-${projectIds.length}]: `); - - const idx = Number.parseInt(choice.trim(), 10); - if (!Number.isFinite(idx) || idx < 1 || idx > projectIds.length) { - throw new Error("Please enter a valid number from the list"); - } - - const projectId = projectIds[idx - 1]; - return { projectId, project: config.projects[projectId] }; - } finally { - rl.close(); - } + const projectId = await promptSelect( + `Choose project to ${action}:`, + projectIds.map((id) => ({ + value: id, + label: config.projects[id].name ?? id, + hint: id, + })), + ); + return { projectId, project: config.projects[projectId] }; } else { throw new Error( `Multiple projects configured. Specify which one to ${action}:\n ${projectIds.map((id) => `ao ${action} ${id}`).join("\n ")}`, @@ -170,24 +166,60 @@ function canPromptForInstall(): boolean { return isHumanCaller() && IS_TTY; } +function genericInstallHints(command: string): string[] { + switch (command) { + case "node": + case "npm": + return ["Install Node.js/npm from https://nodejs.org/"]; + case "pnpm": + return [ + "corepack enable && corepack prepare pnpm@latest --activate", + "npm install -g pnpm", + ]; + case "pipx": + return [ + "python3 -m pip install --user pipx", + "python3 -m pipx ensurepath", + ]; + default: + return []; + } +} + +/** + * Prompt the user to optionally switch orchestrator/worker agents at startup. + * Shows only agents detected on the current system (reuses detectAvailableAgents). + * Returns the chosen agents + */ +async function promptAgentSelection(): Promise<{ + orchestratorAgent: string; + workerAgent: string +} | null> { + if (canPromptForInstall()) { + const available = await detectAvailableAgents(); + if (available.length === 0) { + console.log(chalk.yellow("No agent runtimes detected — using existing config.")); + return null; + } + + const agentOptions = available.map((a) => ({ value: a.name, label: a.displayName })); + + const orchestratorAgent = await promptSelect("Orchestrator agent:", agentOptions); + const workerAgent = await promptSelect("Worker agent:", agentOptions); + + return { orchestratorAgent, workerAgent }; + } else { + return null; + } +} + async function askYesNo( question: string, defaultYes = true, nonInteractiveDefault = defaultYes, ): Promise { if (!canPromptForInstall()) return nonInteractiveDefault; - - const { createInterface } = await import("node:readline/promises"); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const suffix = defaultYes ? "[Y/n]" : "[y/N]"; - const answer = await rl.question(`${question} ${suffix}: `); - const normalized = answer.trim().toLowerCase(); - if (!normalized) return defaultYes; - return normalized === "y" || normalized === "yes"; - } finally { - rl.close(); - } + return await promptConfirm(question, defaultYes); } function gitInstallAttempts(): InstallAttempt[] { @@ -246,7 +278,16 @@ function ghInstallAttempts(): InstallAttempt[] { async function runInteractiveCommand(cmd: string, args: string[]): Promise { await new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: "inherit" }); - child.once("error", reject); + child.once("error", (err) => { + reject( + formatCommandError(err, { + cmd, + args, + action: "run an interactive installer", + installHints: genericInstallHints(cmd), + }), + ); + }); child.once("close", (code) => { if (code === 0) { resolve(); @@ -338,40 +379,37 @@ async function promptInstallAgentRuntime(available: DetectedAgent[]): Promise { - const command = [option.cmd, ...option.args].join(" "); - console.log(` ${i + 1}. ${option.label} (${option.id}) — ${command}`); - }); - console.log(` ${skipOption}. Skip for now\n`); + const choice = await promptSelect( + "Choose runtime to install:", + [ + ...AGENT_INSTALL_OPTIONS.map((option) => ({ + value: option.id, + label: option.label, + hint: [option.cmd, ...option.args].join(" "), + })), + { value: "skip", label: "Skip for now" }, + ], + ); + if (choice === "skip") { + return available; + } - const { createInterface } = await import("node:readline/promises"); - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const selected = AGENT_INSTALL_OPTIONS.find((option) => option.id === choice); + if (!selected) { + return available; + } + + console.log(chalk.dim(` Installing ${selected.label}...`)); try { - const choice = await rl.question(` Choose runtime to install [1-${skipOption}]: `); - const idx = Number.parseInt(choice.trim(), 10); - if (!Number.isFinite(idx) || idx < 1 || idx > skipOption) { - return available; + await runInteractiveCommand(selected.cmd, selected.args); + const refreshed = await detectAvailableAgents(); + if (refreshed.length > 0) { + console.log(chalk.green(` ✓ ${selected.label} installed successfully`)); } - if (idx === skipOption) { - return available; - } - - const selected = AGENT_INSTALL_OPTIONS[idx - 1]; - console.log(chalk.dim(` Installing ${selected.label}...`)); - try { - await runInteractiveCommand(selected.cmd, selected.args); - const refreshed = await detectAvailableAgents(); - if (refreshed.length > 0) { - console.log(chalk.green(` ✓ ${selected.label} installed successfully`)); - } - return refreshed; - } catch { - console.log(chalk.yellow(` ⚠ Could not install ${selected.label} automatically.`)); - return available; - } - } finally { - rl.close(); + return refreshed; + } catch { + console.log(chalk.yellow(` ⚠ Could not install ${selected.label} automatically.`)); + return available; } } @@ -742,7 +780,15 @@ async function startDashboard( } child.on("error", (err) => { - console.error(chalk.red("Dashboard failed to start:"), err.message); + const cmd = isDevMode ? "pnpm" : "node"; + const args = isDevMode ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")]; + const formatted = formatCommandError(err, { + cmd, + args, + action: "start the AO dashboard", + installHints: genericInstallHints(cmd), + }); + console.error(chalk.red("Dashboard failed to start:"), formatted.message); // Emit synthetic exit so callers listening on "exit" can clean up child.emit("exit", 1, null); }); @@ -806,6 +852,43 @@ async function ensureTmux(): Promise { process.exit(1); } +async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise { + const openclawConfig = config.notifiers?.["openclaw"]; + const openclawConfigured = + openclawConfig !== null && openclawConfig !== undefined && + typeof openclawConfig === "object" && + openclawConfig.plugin === "openclaw"; + const configuredUrl = + openclawConfigured && typeof openclawConfig.url === "string" ? openclawConfig.url : undefined; + + try { + const installation = configuredUrl + ? await detectOpenClawInstallation(configuredUrl) + : await detectOpenClawInstallation(); + + if (openclawConfigured) { + if (installation.state !== "running") { + console.log( + chalk.yellow( + `⚠ OpenClaw is configured but the gateway is not reachable at ${installation.gatewayUrl}. Notifications may fail until it is running.`, + ), + ); + } + return; + } + + if (installation.state === "running") { + console.log( + chalk.yellow( + `⚠ OpenClaw is running at ${installation.gatewayUrl} but AO is not configured to use it. Run \`ao setup openclaw\` if you want OpenClaw notifications.`, + ), + ); + } + } catch { + // OpenClaw probing is advisory for `ao start`; never block startup on it. + } +} + /** * Shared startup logic: launch dashboard + orchestrator session, print summary. * Used by both normal and URL-based start flows. @@ -822,6 +905,21 @@ async function runStartup( if (runtime === "tmux") { await ensureTmux(); } + await warnAboutOpenClawStatus(config); + + // Only inject OpenClaw credentials when the project actually uses OpenClaw. + // This avoids exposing API keys to projects/plugins that don't need them. + const openclawNotifier = config.notifiers?.["openclaw"]; + const hasOpenClaw = + openclawNotifier !== null && openclawNotifier !== undefined && + typeof openclawNotifier === "object" && openclawNotifier.plugin === "openclaw"; + if (hasOpenClaw) { + const injectedKeys = applyOpenClawCredentials(); + if (injectedKeys.length > 0) { + const names = injectedKeys.map((k) => k.key).join(", "); + console.log(chalk.dim(` Resolved from OpenClaw config: ${names}`)); + } + } const sessionId = `${project.sessionPrefix}-orchestrator`; const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false; @@ -1011,6 +1109,7 @@ export function registerStart(program: Command): void { .option("--no-dashboard", "Skip starting the dashboard server") .option("--no-orchestrator", "Skip starting the orchestrator agent") .option("--rebuild", "Clean and rebuild dashboard before starting") + .option("--interactive", "Prompt to configure config settings") .action( async ( projectArg?: string, @@ -1018,6 +1117,7 @@ export function registerStart(program: Command): void { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; + interactive?: boolean; }, ) => { try { @@ -1101,25 +1201,22 @@ export function registerStart(program: Command): void { console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`); console.log(` Projects: ${running.projects.join(", ")}\n`); - // Interactive menu - const { createInterface } = await import("node:readline/promises"); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - console.log(" 1. Open dashboard (keep current)"); - console.log(" 2. Start new orchestrator on this project"); - console.log(" 3. Override — restart everything"); - console.log(" 4. Quit\n"); - const choice = await rl.question(" Choice [1-4]: "); - rl.close(); + const choice = await promptSelect( + "AO is already running. What do you want to do?", + [ + { value: "open", label: "Open dashboard", hint: "Keep the current instance" }, + { value: "new", label: "Start new orchestrator", hint: "Add a new session for this project" }, + { value: "restart", label: "Restart everything", hint: "Stop the current instance first" }, + { value: "quit", label: "Quit" }, + ], + "open", + ); - if (choice.trim() === "1") { + if (choice === "open") { const url = `http://localhost:${running.port}`; - const [cmd, args]: [string, string[]] = - process.platform === "win32" - ? ["cmd.exe", ["/c", "start", "", url]] - : [process.platform === "linux" ? "xdg-open" : "open", [url]]; - spawn(cmd, args, { stdio: "ignore" }); + openUrl(url); process.exit(0); - } else if (choice.trim() === "2") { + } else if (choice === "new") { // Generate unique orchestrator: same project, new session const rawYaml = readFileSync(config.configPath, "utf-8"); const rawConfig = yamlParse(rawYaml); @@ -1149,7 +1246,7 @@ export function registerStart(program: Command): void { projectId = newId; project = config.projects[newId]; // Continue to startup below - } else if (choice.trim() === "3") { + } else if (choice === "restart") { try { process.kill(running.pid, "SIGTERM"); } catch { /* already dead */ } if (!(await waitForExit(running.pid, 5000))) { console.log(chalk.yellow(" Process didn't exit cleanly, sending SIGKILL...")); @@ -1172,9 +1269,26 @@ export function registerStart(program: Command): void { } } + // ── Agent selection prompt (Step 10)── + const agentOverride = opts?.interactive ? await promptAgentSelection() : null; + if (agentOverride) { + const { orchestratorAgent, workerAgent } = agentOverride; + + const rawYaml = readFileSync(config.configPath, "utf-8"); + const rawConfig = yamlParse(rawYaml); + const proj = rawConfig.projects[projectId]; + proj.orchestrator = { ...(proj.orchestrator ?? {}), agent: orchestratorAgent }; + proj.worker = { ...(proj.worker ?? {}), agent: workerAgent }; + writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 })); + console.log(chalk.dim(` ✓ Saved to ${config.configPath}\n`)); + + config = loadConfig(config.configPath); + project = config.projects[projectId]; + } + const actualPort = await runStartup(config, projectId, project, opts); - // ── Register in running.json (Step 10) ── + // ── Register in running.json (Step 11) ── await register({ pid: process.pid, configPath: config.configPath, diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 785009ae1..625e06370 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -23,8 +23,8 @@ import { reviewDecisionIcon, padCol, } from "../lib/format.js"; -import { getAgentByName, getSCM } from "../lib/plugins.js"; -import { getSessionManager } from "../lib/create-session-manager.js"; +import { getAgentByName, getAgentByNameFromRegistry, getSCMFromRegistry } from "../lib/plugins.js"; +import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js"; interface SessionInfo { name: string; @@ -44,6 +44,30 @@ interface SessionInfo { activity: ActivityState | null; } +interface StatusOptions { + project?: string; + json?: boolean; + watch?: boolean; + interval?: string; +} + +const DEFAULT_WATCH_INTERVAL_SECONDS = 5; + +function parseWatchIntervalSeconds(value?: string): number { + if (!value) return DEFAULT_WATCH_INTERVAL_SECONDS; + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error("--interval must be a positive integer number of seconds."); + } + return parsed; +} + +function maybeClearScreen(): void { + if (process.stdout.isTTY) { + process.stdout.write("\x1Bc"); + } +} + async function gatherSessionInfo( session: Session, agent: Agent, @@ -210,157 +234,217 @@ export function registerStatus(program: Command): void { .description("Show all sessions with branch, activity, PR, and CI status") .option("-p, --project ", "Filter by project ID") .option("--json", "Output as JSON") - .action(async (opts: { project?: string; json?: boolean }) => { - let config: ReturnType; - try { - config = loadConfig(); - } catch { - console.log(chalk.yellow("No config found. Run `ao init` first.")); - console.log(chalk.dim("Falling back to session discovery...\n")); - await showFallbackStatus(); - return; - } - - if (opts.project && !config.projects[opts.project]) { - console.error(chalk.red(`Unknown project: ${opts.project}`)); + .option("-w, --watch", "Refresh the status view continuously") + .option("--interval ", "Refresh interval in seconds (default: 5)") + .action(async (opts: StatusOptions) => { + if (opts.watch && opts.json) { + console.error(chalk.red("--watch cannot be used with --json.")); process.exit(1); } - // Use session manager to list sessions (metadata-based, not tmux-based) - const sm = await getSessionManager(config); - const sessions = await sm.list(opts.project); - - if (!opts.json) { - console.log(banner("AGENT ORCHESTRATOR STATUS")); - console.log(); + let watchIntervalSeconds = DEFAULT_WATCH_INTERVAL_SECONDS; + if (opts.watch) { + try { + watchIntervalSeconds = parseWatchIntervalSeconds(opts.interval); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } } - // Group sessions by project - const byProject = new Map(); - for (const s of sessions) { - const list = byProject.get(s.projectId) ?? []; - list.push(s); - byProject.set(s.projectId, list); - } + const renderStatus = async (refreshing = false): Promise => { + if (refreshing) { + maybeClearScreen(); + } - // Show projects that have no sessions too (if not filtered) - const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); - const jsonOutput: SessionInfo[] = []; - let totalWorkers = 0; - let totalOrchestrators = 0; + let config: ReturnType; + try { + config = loadConfig(); + } catch { + console.log(chalk.yellow("No config found. Run `ao init` first.")); + console.log(chalk.dim("Falling back to session discovery...\n")); + await showFallbackStatus(); + return; + } - for (const projectId of projectIds) { - const projectConfig = config.projects[projectId]; - if (!projectConfig) continue; + if (opts.project && !config.projects[opts.project]) { + console.error(chalk.red(`Unknown project: ${opts.project}`)); + process.exit(1); + } - const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => - a.id.localeCompare(b.id), - ); - - // Resolve agent and SCM for this project - const agentName = projectConfig.agent ?? config.defaults.agent; - const agent = getAgentByName(agentName); - const scm = getSCM(config, projectId); + // Use session manager to list sessions (metadata-based, not tmux-based) + const sm = await getSessionManager(config); + const registry = await getPluginRegistry(config); + const sessions = await sm.list(opts.project); if (!opts.json) { - console.log(header(projectConfig.name || projectId)); - } - - if (projectSessions.length === 0) { - if (!opts.json) { - console.log(chalk.dim(" (no active sessions)")); + console.log(banner("AGENT ORCHESTRATOR STATUS")); + if (opts.watch) { + console.log( + chalk.dim( + `Refreshing every ${watchIntervalSeconds}s. Press Ctrl+C to exit.`, + ), + ); + console.log(); + } else { console.log(); } - continue; } - // Gather all session info in parallel - const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config)); - const sessionInfos = await Promise.all(infoPromises); + // Group sessions by project + const byProject = new Map(); + for (const s of sessions) { + const list = byProject.get(s.projectId) ?? []; + list.push(s); + byProject.set(s.projectId, list); + } - const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator"); - const workers = sessionInfos.filter((info) => info.role === "worker"); + // Show projects that have no sessions too (if not filtered) + const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); + const jsonOutput: SessionInfo[] = []; + let totalWorkers = 0; + let totalOrchestrators = 0; - totalWorkers += workers.length; - totalOrchestrators += orchestrators.length; + for (const projectId of projectIds) { + const projectConfig = config.projects[projectId]; + if (!projectConfig) continue; - for (const info of sessionInfos) { - if (opts.json) { - jsonOutput.push(info); + const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => + a.id.localeCompare(b.id), + ); + + // Resolve agent and SCM for this project via the shared registry + const agentName = projectConfig.agent ?? config.defaults.agent; + const agent = getAgentByNameFromRegistry(registry, agentName); + const scm = getSCMFromRegistry(registry, config, projectId); + + if (!opts.json) { + console.log(header(projectConfig.name || projectId)); } - } - if (opts.json) { - continue; - } - - if (orchestrators.length > 0) { - for (const info of orchestrators) { - printOrchestratorRow(info); + if (projectSessions.length === 0) { + if (!opts.json) { + console.log(chalk.dim(" (no active sessions)")); + console.log(); + } + continue; } - } - if (workers.length === 0) { - console.log(chalk.dim(" (no active sessions)")); - console.log(); - continue; - } + // Gather all session info in parallel + const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config)); + const sessionInfos = await Promise.all(infoPromises); - printTableHeader(); - for (const info of workers) { - printSessionRow(info); - } - console.log(); - } + const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator"); + const workers = sessionInfos.filter((info) => info.role === "worker"); - if (opts.json) { - console.log(JSON.stringify(jsonOutput, null, 2)); - } else { - console.log( - chalk.dim( - ` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` + - (totalOrchestrators > 0 - ? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}` - : ""), - ), - ); + totalWorkers += workers.length; + totalOrchestrators += orchestrators.length; - // Check for issues awaiting verification across all projects - try { - const { createPluginRegistry } = await import("@composio/ao-core"); - const registry = createPluginRegistry(); - await registry.loadFromConfig(config, (pkg: string) => import(pkg)); - - let unverifiedTotal = 0; - for (const projectId of projectIds) { - const project: ProjectConfig | undefined = config.projects[projectId]; - if (!project?.tracker) continue; - const tracker = registry.get("tracker", project.tracker.plugin); - if (!tracker?.listIssues) continue; - try { - const issues = await tracker.listIssues( - { state: "open", labels: ["merged-unverified"], limit: 20 }, - project, - ); - unverifiedTotal += issues.length; - } catch { - // Tracker query failed — not critical + for (const info of sessionInfos) { + if (opts.json) { + jsonOutput.push(info); } } - if (unverifiedTotal > 0) { - console.log( - chalk.yellow( - ` ⚠ ${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`, - ), - ); + if (opts.json) { + continue; } - } catch { - // Plugin registry or tracker unavailable — skip silently + + if (orchestrators.length > 0) { + for (const info of orchestrators) { + printOrchestratorRow(info); + } + } + + if (workers.length === 0) { + console.log(chalk.dim(" (no active sessions)")); + console.log(); + continue; + } + + printTableHeader(); + for (const info of workers) { + printSessionRow(info); + } + console.log(); } - console.log(); + if (opts.json) { + console.log(JSON.stringify(jsonOutput, null, 2)); + } else { + console.log( + chalk.dim( + ` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` + + (totalOrchestrators > 0 + ? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}` + : ""), + ), + ); + + // Check for issues awaiting verification across all projects + try { + let unverifiedTotal = 0; + for (const projectId of projectIds) { + const project: ProjectConfig | undefined = config.projects[projectId]; + if (!project?.tracker) continue; + const tracker = registry.get("tracker", project.tracker.plugin); + if (!tracker?.listIssues) continue; + try { + const issues = await tracker.listIssues( + { state: "open", labels: ["merged-unverified"], limit: 20 }, + project, + ); + unverifiedTotal += issues.length; + } catch { + // Tracker query failed — not critical + } + } + + if (unverifiedTotal > 0) { + console.log( + chalk.yellow( + ` ⚠ ${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`, + ), + ); + } + } catch { + // Plugin registry or tracker unavailable — skip silently + } + + console.log(); + } + }; + + await renderStatus(); + + if (!opts.watch) { + return; } + + let rendering = false; + const watchTimer = setInterval(() => { + if (rendering) return; + rendering = true; + void renderStatus(true) + .catch((err) => { + console.error( + chalk.red( + `Watch refresh failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + }) + .finally(() => { + rendering = false; + }); + }, watchIntervalSeconds * 1000); + + const shutdown = (): void => { + clearInterval(watchTimer); + process.exit(0); + }; + + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); }); } @@ -380,13 +464,13 @@ async function showFallbackStatus(): Promise { // Use claude-code as default agent for fallback introspection const agent = getAgentByName("claude-code"); - for (const session of allTmux.sort()) { - const activityTs = await getTmuxActivity(session); - const lastActivity = activityTs ? formatAge(activityTs) : "-"; - console.log(` ${chalk.green(session)} ${chalk.dim(`(${lastActivity})`)}`); + const sortedSessions = allTmux.sort(); + + // Pre-fetch activity and introspection in parallel + const details = await Promise.all( + sortedSessions.map(async (session) => { + const activityTsPromise = getTmuxActivity(session).catch(() => null); - // Try introspection even without config - try { const sessionObj: Session = { id: session, projectId: "", @@ -402,12 +486,27 @@ async function showFallbackStatus(): Promise { lastActivityAt: new Date(), metadata: {}, }; - const introspection = await agent.getSessionInfo(sessionObj); - if (introspection?.summary) { - console.log(` ${chalk.dim("Claude:")} ${introspection.summary.slice(0, 65)}`); - } - } catch { - // Not critical + + const introspectionPromise = agent.getSessionInfo(sessionObj).catch(() => null); + + const [activityTs, introspection] = await Promise.all([ + activityTsPromise, + introspectionPromise, + ]); + + return { activityTs, introspection }; + }), + ); + + for (let i = 0; i < sortedSessions.length; i++) { + const session = sortedSessions[i]; + const { activityTs, introspection } = details[i]; + + const lastActivity = activityTs ? formatAge(activityTs) : "-"; + console.log(` ${chalk.green(session)} ${chalk.dim(`(${lastActivity})`)}`); + + if (introspection?.summary) { + console.log(` ${chalk.dim("Claude:")} ${introspection.summary.slice(0, 65)}`); } } console.log(); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 4b639ed63..40d6ee043 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -7,6 +7,7 @@ import { type PluginRegistry, loadConfig, } from "@composio/ao-core"; +import { importPluginModuleFromSource } from "../lib/plugin-store.js"; /** * Resolve the target project from config. @@ -57,7 +58,7 @@ async function getTracker( // directly, so we replicate the same pattern from create-session-manager. const { createPluginRegistry } = await import("@composio/ao-core"); const registry = createPluginRegistry(); - await registry.loadFromConfig(config, (pkg: string) => import(pkg)); + await registry.loadFromConfig(config, importPluginModuleFromSource); const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 594ec58bc..8fb960663 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ 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 { registerPlugin } from "./commands/plugin.js"; import { getConfigInstruction } from "./lib/config-instruction.js"; const program = new Command(); @@ -40,6 +41,7 @@ registerVerify(program); registerDoctor(program); registerUpdate(program); registerSetup(program); +registerPlugin(program); program .command("config-help") diff --git a/packages/cli/src/lib/cli-errors.ts b/packages/cli/src/lib/cli-errors.ts new file mode 100644 index 000000000..40cddb406 --- /dev/null +++ b/packages/cli/src/lib/cli-errors.ts @@ -0,0 +1,34 @@ +export interface CommandErrorOptions { + cmd: string; + args?: string[]; + action?: string; + installHints?: string[]; +} + +function formatHints(hints: string[] | undefined): string { + if (!hints || hints.length === 0) return ""; + return `\nInstall hint${hints.length === 1 ? "" : "s"}:\n ${hints.join("\n ")}`; +} + +export function formatCommandError(err: unknown, options: CommandErrorOptions): Error { + const command = [options.cmd, ...(options.args ?? [])].join(" ").trim(); + const action = options.action ?? "run the command"; + const code = + err && typeof err === "object" && "code" in err ? String((err as { code?: unknown }).code) : undefined; + const message = err instanceof Error ? err.message : String(err); + + if (code === "ENOENT") { + return new Error( + `${options.cmd} is not installed or not on PATH, so AO could not ${action}.${formatHints(options.installHints)}`, + ); + } + + if (code === "EACCES") { + return new Error( + `${options.cmd} exists but AO could not execute it due to a permission error while trying to ${action}. ` + + `Check that the binary is executable and accessible to this user.${formatHints(options.installHints)}`, + ); + } + + return new Error(`Failed to ${action}: ${command}${message ? `\n${message}` : ""}`); +} diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index 004bd1eb3..1ad8b3493 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -8,11 +8,13 @@ export function getConfigInstruction(): string { # File: agent-orchestrator.yaml # ── Top-level settings ────────────────────────────────────────────── +# Runtime data paths are auto-derived from the config location under: +# ~/.agent-orchestrator/{hash}-{projectId}/ -port: 3000 # Dashboard port (default: 3000, auto-finds free port if busy) -terminalPort: 3001 # Terminal WebSocket port (default: 3001) -directTerminalPort: 3003 # Direct terminal WebSocket port (default: 3003) -readyThresholdMs: 300000 # Ms before "ready" session becomes "idle" (default: 5 min) +port: 3000 # Dashboard port +terminalPort: 14800 # Optional terminal WebSocket port override +directTerminalPort: 14801 # Optional direct terminal WebSocket port override +readyThresholdMs: 300000 # Ms before "ready" becomes "idle" (default: 5 min) # ── Default plugins ───────────────────────────────────────────────── # These apply to all projects unless overridden per-project. @@ -21,12 +23,26 @@ defaults: runtime: tmux # tmux | process agent: claude-code # claude-code | aider | codex | opencode workspace: worktree # worktree | clone - notifiers: # List of active notifier plugins + notifiers: - desktop # desktop | discord | slack | webhook | composio | openclaw orchestrator: - agent: claude-code # Agent for orchestrator sessions (optional override) + agent: claude-code # Optional override for orchestrator sessions worker: - agent: claude-code # Agent for worker sessions (optional override) + agent: claude-code # Optional override for worker sessions + +# ── Installer-managed marketplace plugins (optional) ─────────────── +# External plugins are declared here. Built-ins do not need entries. + +plugins: + - name: owasp-auditor + source: registry # registry | npm | local + package: "@ao-plugins/owasp-auditor" + version: "^0.1.0" + enabled: true + - name: local-dev-plugin + source: local + path: ./plugins/local-dev-plugin + enabled: true # ── Projects ──────────────────────────────────────────────────────── # Each key is a project ID (typically the repo directory name). @@ -46,7 +62,7 @@ projects: # ── Agent configuration (optional) ──────────────────────────── agentConfig: - permissions: auto # auto | manual — agent permission mode + permissions: permissionless # permissionless | default | auto-edit | suggest model: claude-sonnet-4-20250514 # ── Agent rules (optional) ──────────────────────────────────── @@ -88,8 +104,9 @@ projects: # ── Per-project reaction overrides (optional) ───────────────── # reactions: - # ci-failure: - # enabled: true + # ci-failed: + # auto: true + # retries: 2 # ── Notification channels (optional) ──────────────────────────────── @@ -112,13 +129,15 @@ notifiers: # Route notifications by priority level. notificationRouting: - critical: + urgent: - desktop - slack - high: - - desktop - low: + action: - desktop + warning: + - slack + info: + - composio # ── Available plugins ─────────────────────────────────────────────── # diff --git a/packages/cli/src/lib/create-session-manager.ts b/packages/cli/src/lib/create-session-manager.ts index 70419c144..208f6f146 100644 --- a/packages/cli/src/lib/create-session-manager.ts +++ b/packages/cli/src/lib/create-session-manager.ts @@ -16,25 +16,37 @@ import { type PluginRegistry, type LifecycleManager, } from "@composio/ao-core"; +import { importPluginModuleFromSource } from "./plugin-store.js"; -let registryPromise: Promise | null = null; +const registryPromises = new Map>(); + +function getRegistryCacheKey(config: OrchestratorConfig): string { + return config.configPath || "__default__"; +} /** * Get or create the plugin registry. * Caches the Promise (not the resolved value) so concurrent callers * await the same initialization rather than racing. */ -async function getRegistry(config: OrchestratorConfig): Promise { +export async function getPluginRegistry(config: OrchestratorConfig): Promise { + const cacheKey = getRegistryCacheKey(config); + let registryPromise = registryPromises.get(cacheKey); + if (!registryPromise) { registryPromise = (async () => { const registry = createPluginRegistry(); - // Pass CLI's import context so pnpm strict resolution can find plugin packages. - // Core can't resolve @composio/ao-plugin-* from its own module context because - // they aren't in core's dependencies. The CLI has them as workspace deps. - await registry.loadFromConfig(config, (pkg: string) => import(pkg)); + // Prefer the AO-managed plugin store when a package is installed there, + // but still fall back to the CLI/workspace dependency tree for built-ins. + await registry.loadFromConfig(config, importPluginModuleFromSource); return registry; - })(); + })().catch((err) => { + registryPromises.delete(cacheKey); + throw err; + }); + registryPromises.set(cacheKey, registryPromise); } + return registryPromise; } @@ -45,7 +57,7 @@ async function getRegistry(config: OrchestratorConfig): Promise export async function getSessionManager( config: OrchestratorConfig, ): Promise { - const registry = await getRegistry(config); + const registry = await getPluginRegistry(config); return createSessionManager({ config, registry }); } @@ -57,7 +69,7 @@ export async function getLifecycleManager( config: OrchestratorConfig, projectId?: string, ): Promise { - const registry = await getRegistry(config); + const registry = await getPluginRegistry(config); const sessionManager = createSessionManager({ config, registry }); return createLifecycleManager({ config, registry, sessionManager, projectId }); } diff --git a/packages/cli/src/lib/credential-resolver.ts b/packages/cli/src/lib/credential-resolver.ts new file mode 100644 index 000000000..133ab6224 --- /dev/null +++ b/packages/cli/src/lib/credential-resolver.ts @@ -0,0 +1,119 @@ +/** + * Shared credential resolver — resolves API keys from multiple sources. + * + * Resolution order per key: + * 1. Environment variable (already set in process.env) + * 2. OpenClaw config (~/.openclaw/openclaw.json → keys section) + * + * Call `applyOpenClawCredentials()` early in CLI startup so spawned agent + * sessions inherit the resolved values via the parent process environment. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Keys that AO agents commonly need and OpenClaw may already store. */ +const RESOLVABLE_KEYS = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GITHUB_TOKEN", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", +] as const; + +type ResolvableKey = (typeof RESOLVABLE_KEYS)[number]; + +interface ResolvedCredential { + key: ResolvableKey; + value: string; + source: "env" | "openclaw"; +} + +interface AppliedCredential { + key: ResolvableKey; + source: "openclaw"; +} + +function readOpenClawKeys(): Record { + const keys: Record = {}; + + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + if (!existsSync(configPath)) return keys; + + try { + const raw = readFileSync(configPath, "utf-8"); + const config = JSON.parse(raw) as Record; + + // OpenClaw stores keys in a top-level "keys" object: + // { "keys": { "ANTHROPIC_API_KEY": "sk-ant-...", ... } } + const keysSection = config.keys; + if (keysSection && typeof keysSection === "object") { + for (const [k, v] of Object.entries(keysSection as Record)) { + if (typeof v === "string" && v.length > 0) { + keys[k] = v; + } + } + } + + // Also check top-level env / environment block (some OpenClaw setups): + // { "env": { "ANTHROPIC_API_KEY": "sk-ant-..." } } + const envSection = config.env ?? config.environment; + if (envSection && typeof envSection === "object") { + for (const [k, v] of Object.entries(envSection as Record)) { + if (typeof v === "string" && v.length > 0 && !(k in keys)) { + keys[k] = v; + } + } + } + } catch { + // Malformed config — silently skip. + } + + return keys; +} + +/** + * Resolve a single credential from all available sources. + * Returns the value and where it came from, or null if not found anywhere. + */ +export function resolveCredential(key: ResolvableKey): ResolvedCredential | null { + const envValue = process.env[key]; + if (envValue && envValue.length > 0) { + return { key, value: envValue, source: "env" }; + } + + const openclawKeys = readOpenClawKeys(); + const openclawValue = openclawKeys[key]; + if (openclawValue) { + return { key, value: openclawValue, source: "openclaw" }; + } + + return null; +} + +/** + * Populate `process.env` with API keys found in OpenClaw config that are + * not already set in the environment. This makes them available to all + * spawned agent sessions (tmux inherits the parent env). + * + * Returns the list of keys that were injected from OpenClaw. + */ +export function applyOpenClawCredentials(): AppliedCredential[] { + const openclawKeys = readOpenClawKeys(); + const applied: AppliedCredential[] = []; + + for (const key of RESOLVABLE_KEYS) { + if (process.env[key] && process.env[key]!.length > 0) continue; + + const value = openclawKeys[key]; + if (value) { + process.env[key] = value; + applied.push({ key, source: "openclaw" }); + } + } + + return applied; +} + +export { RESOLVABLE_KEYS, type ResolvableKey, type ResolvedCredential, type AppliedCredential }; diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index a3bf2141e..b8c15bd53 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -6,6 +6,7 @@ import type { PluginModule } from "@composio/ao-core"; import { isHumanCaller } from "./caller-context.js"; +import { promptSelect } from "./prompts.js"; export interface DetectedAgent { name: string; @@ -71,27 +72,12 @@ export async function detectAgentRuntime(preDetected?: DetectedAgent[]): Promise return available.find((a) => a.name === "claude-code")?.name ?? available[0].name; } - // Interactive: prompt human to pick using node:readline (no external deps) - const { createInterface } = await import("node:readline/promises"); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - try { - console.log("\n Multiple agent runtimes detected:\n"); - available.forEach((a, i) => { - console.log(` ${i + 1}. ${a.displayName} (${a.name})`); - }); - console.log(); - - const answer = await rl.question(` Choose default agent [1-${available.length}]: `); - - const idx = parseInt(answer.trim(), 10) - 1; - if (idx >= 0 && idx < available.length) { - return available[idx].name; - } - - // Invalid input — default to first - return available[0].name; - } finally { - rl.close(); - } + return await promptSelect( + "Choose default agent runtime:", + available.map((agent) => ({ + value: agent.name, + label: agent.displayName, + hint: agent.name, + })) + ); } diff --git a/packages/cli/src/lib/openclaw-probe.ts b/packages/cli/src/lib/openclaw-probe.ts index 06db7a282..6d877e913 100644 --- a/packages/cli/src/lib/openclaw-probe.ts +++ b/packages/cli/src/lib/openclaw-probe.ts @@ -5,6 +5,11 @@ * Probes the OpenClaw gateway for reachability and validates auth tokens. */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + export interface ProbeResult { reachable: boolean; httpStatus?: number; @@ -16,10 +21,37 @@ export interface TokenValidation { error?: string; } +export interface OpenClawInstallation { + state: "missing" | "installed-but-stopped" | "running"; + gatewayUrl: string; + binaryPath?: string; + configPath?: string; + probe: ProbeResult; +} + const DEFAULT_TIMEOUT_MS = 3_000; const DEFAULT_OPENCLAW_URL = "http://127.0.0.1:18789"; const HOOKS_PATH = "/hooks/agent"; +function normalizeGatewayBaseUrl(url: string): string { + const normalized = url.trim().replace(/\/+$/, ""); + return normalized.endsWith(HOOKS_PATH) + ? normalized.slice(0, -HOOKS_PATH.length) + : normalized; +} + +function resolveOpenClawBinaryPath(): string | undefined { + const shell = process.env["SHELL"] ?? "/bin/sh"; + if (!shell.startsWith("/")) return undefined; + const result = spawnSync(shell, ["-lc", "command -v openclaw"], { + encoding: "utf-8", + }); + + if (result.status !== 0) return undefined; + const path = result.stdout.trim(); + return path || undefined; +} + /** * Probe the OpenClaw gateway for reachability. * Sends a GET to the base URL (not /hooks/agent) with a short timeout. @@ -28,7 +60,7 @@ export async function probeGateway( baseUrl: string = DEFAULT_OPENCLAW_URL, timeoutMs: number = DEFAULT_TIMEOUT_MS, ): Promise { - const url = baseUrl.replace(/\/+$/, ""); + const url = normalizeGatewayBaseUrl(baseUrl); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -54,6 +86,29 @@ export async function probeGateway( } } +export async function detectOpenClawInstallation( + baseUrl: string = DEFAULT_OPENCLAW_URL, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + const gatewayUrl = normalizeGatewayBaseUrl(baseUrl); + const binaryPath = resolveOpenClawBinaryPath(); + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + const hasConfig = existsSync(configPath); + const probe = await probeGateway(gatewayUrl, timeoutMs); + + return { + state: probe.reachable + ? "running" + : binaryPath || hasConfig + ? "installed-but-stopped" + : "missing", + gatewayUrl, + binaryPath, + configPath: hasConfig ? configPath : undefined, + probe, + }; +} + /** * Validate an auth token against the OpenClaw hooks endpoint. * Sends a lightweight test payload and checks the response. @@ -125,4 +180,4 @@ export async function validateToken( } } -export { DEFAULT_OPENCLAW_URL, HOOKS_PATH }; +export { DEFAULT_OPENCLAW_URL, HOOKS_PATH, normalizeGatewayBaseUrl }; diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts new file mode 100644 index 000000000..82a0095f5 --- /dev/null +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -0,0 +1,230 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { resolveLocalPluginEntrypoint, type InstalledPluginConfig, type PluginSlot } from "@composio/ao-core"; + +const registryData = createRequire(import.meta.url)("../assets/plugin-registry.json") as unknown[]; + +export interface MarketplacePluginEntry { + id: string; + package: string; + slot: PluginSlot; + description: string; + source: "registry"; + setupAction?: "openclaw-setup"; + latestVersion?: string; +} + +export const BUNDLED_MARKETPLACE_PLUGIN_CATALOG = registryData as MarketplacePluginEntry[]; +export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL = + "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json"; + +const MARKETPLACE_CACHE_FILE = "plugin-registry.json"; +const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000; + +function isPluginSlot(value: unknown): value is PluginSlot { + return ( + value === "runtime" || + value === "agent" || + value === "workspace" || + value === "tracker" || + value === "scm" || + value === "notifier" || + value === "terminal" + ); +} + +function isMarketplacePluginEntry(value: unknown): value is MarketplacePluginEntry { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.package === "string" && + isPluginSlot(candidate.slot) && + typeof candidate.description === "string" && + candidate.source === "registry" + ); +} + +function mergeMarketplaceCatalogs( + primary: MarketplacePluginEntry[], + fallback: MarketplacePluginEntry[], +): MarketplacePluginEntry[] { + const merged = new Map(); + for (const entry of fallback) { + merged.set(entry.id, entry); + } + for (const entry of primary) { + merged.set(entry.id, entry); + } + return [...merged.values()]; +} + +export function getMarketplaceRegistryCachePath(): string { + const override = process.env["AO_PLUGIN_REGISTRY_CACHE_PATH"]; + if (override && override.trim().length > 0) { + return override; + } + return join(homedir(), ".agent-orchestrator", MARKETPLACE_CACHE_FILE); +} + +function validateMarketplaceCatalog(payload: unknown, sourceLabel: string): MarketplacePluginEntry[] { + if (!Array.isArray(payload)) { + throw new Error(`${sourceLabel} did not return a registry array.`); + } + + const entries = payload.filter(isMarketplacePluginEntry); + if (entries.length !== payload.length) { + throw new Error(`${sourceLabel} returned invalid marketplace registry entries.`); + } + + return entries; +} + +function readMarketplaceCatalogFile(filePath: string): MarketplacePluginEntry[] | null { + if (!existsSync(filePath)) return null; + + try { + const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown; + return validateMarketplaceCatalog(parsed, filePath); + } catch { + return null; + } +} + +function writeMarketplaceCatalogCache(entries: MarketplacePluginEntry[]): void { + const cachePath = getMarketplaceRegistryCachePath(); + mkdirSync(dirname(cachePath), { recursive: true }); + const tempPath = `${cachePath}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf-8"); + renameSync(tempPath, cachePath); +} + +export function loadMarketplaceCatalog(): MarketplacePluginEntry[] { + const cached = readMarketplaceCatalogFile(getMarketplaceRegistryCachePath()); + if (!cached) { + return [...BUNDLED_MARKETPLACE_PLUGIN_CATALOG]; + } + return mergeMarketplaceCatalogs(cached, BUNDLED_MARKETPLACE_PLUGIN_CATALOG); +} + +export async function refreshMarketplaceCatalog( + url = process.env["AO_PLUGIN_REGISTRY_URL"] ?? DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL, +): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(MARKETPLACE_FETCH_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Failed to fetch marketplace registry from ${url} (HTTP ${response.status}).`); + } + + const parsed = (await response.json()) as unknown; + const remoteEntries = validateMarketplaceCatalog(parsed, url); + const mergedEntries = mergeMarketplaceCatalogs(remoteEntries, BUNDLED_MARKETPLACE_PLUGIN_CATALOG); + writeMarketplaceCatalogCache(mergedEntries); + return mergedEntries; +} + +export { normalizeImportedPluginModule } from "@composio/ao-core"; + +export function isLocalPluginReference(reference: string): boolean { + return ( + reference.startsWith("./") || + reference.startsWith("../") || + reference.startsWith("/") || + reference.startsWith("~/") || + isAbsolute(reference) + ); +} + +export function findMarketplacePlugin(reference: string): MarketplacePluginEntry | undefined { + return loadMarketplaceCatalog().find( + (plugin) => plugin.id === reference || plugin.package === reference, + ); +} + +function expandHomePath(value: string): string { + return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value; +} + +function parseNpmPackageReference(reference: string): { packageName: string; version?: string } { + if (reference.startsWith("@")) { + const slashIndex = reference.indexOf("/"); + const versionSeparator = slashIndex >= 0 ? reference.indexOf("@", slashIndex + 1) : -1; + if (versionSeparator > 0) { + return { + packageName: reference.slice(0, versionSeparator), + version: reference.slice(versionSeparator + 1), + }; + } + return { packageName: reference }; + } + + const versionSeparator = reference.lastIndexOf("@"); + if (versionSeparator > 0) { + return { + packageName: reference.slice(0, versionSeparator), + version: reference.slice(versionSeparator + 1), + }; + } + + return { packageName: reference }; +} + +export function buildPluginDescriptor( + reference: string, + configPath: string, +): { + descriptor: InstalledPluginConfig; + specifier: string; + setupAction?: MarketplacePluginEntry["setupAction"]; +} { + const marketplacePlugin = findMarketplacePlugin(reference); + if (marketplacePlugin) { + return { + descriptor: { + name: marketplacePlugin.id, + source: marketplacePlugin.source, + package: marketplacePlugin.package, + version: marketplacePlugin.latestVersion, + enabled: true, + }, + specifier: marketplacePlugin.package, + setupAction: marketplacePlugin.setupAction, + }; + } + + if (isLocalPluginReference(reference)) { + const expandedReference = expandHomePath(reference); + const absolutePath = isAbsolute(expandedReference) + ? expandedReference + : resolve(dirname(configPath), expandedReference); + const entrypoint = resolveLocalPluginEntrypoint(absolutePath); + if (!entrypoint) { + throw new Error(`Could not resolve a plugin entrypoint from ${reference}`); + } + + return { + descriptor: { + name: reference, + source: "local", + path: reference, + enabled: true, + }, + specifier: pathToFileURL(entrypoint).href, + }; + } + + const { packageName, version } = parseNpmPackageReference(reference); + + return { + descriptor: { + name: reference, + source: "npm", + package: packageName, + version, + enabled: true, + }, + specifier: packageName, + }; +} diff --git a/packages/cli/src/lib/plugin-scaffold.ts b/packages/cli/src/lib/plugin-scaffold.ts new file mode 100644 index 000000000..518fc4d7b --- /dev/null +++ b/packages/cli/src/lib/plugin-scaffold.ts @@ -0,0 +1,204 @@ +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { PluginSlot } from "@composio/ao-core"; + +export interface PluginScaffoldInput { + author?: string; + description: string; + directory: string; + displayName: string; + packageName: string; + slot: PluginSlot; +} + +const CORE_VERSION_RANGE = "^0.2.0"; +const TYPESCRIPT_VERSION = "^5.7.0"; +const NODE_TYPES_VERSION = "^25.2.3"; + +const SLOT_HINTS: Record = { + runtime: "Implement a Runtime-compatible object from create() and wire up create/destroy/send lifecycle methods.", + agent: "Implement an Agent-compatible object from create() so AO can launch, inspect, and restore sessions.", + workspace: "Implement a Workspace-compatible object from create() for setup, cleanup, and isolation behavior.", + tracker: "Implement a Tracker-compatible object from create() for issue list/read/update operations.", + scm: "Implement an SCM-compatible object from create() for branch, PR, CI, and review operations.", + notifier: "Implement a Notifier-compatible object from create() for notify() delivery logic.", + terminal: "Implement a Terminal-compatible object from create() for attach/open UX.", +}; + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function normalizePluginName(value: string): string { + const normalized = slugify(value); + if (!normalized) { + throw new Error("Plugin name must contain at least one letter or number."); + } + return normalized; +} + +export function buildDefaultPackageName(slot: PluginSlot, pluginName: string): string { + return `ao-plugin-${slot}-${normalizePluginName(pluginName)}`; +} + +export function resolveScaffoldDirectory(displayName: string, targetDir?: string): string { + return resolve(targetDir && targetDir.trim().length > 0 ? targetDir : normalizePluginName(displayName)); +} + +function ensureDirectoryIsWritable(targetDir: string): void { + if (!existsSync(targetDir)) return; + if (readdirSync(targetDir).length === 0) return; + throw new Error(`Target directory ${targetDir} already exists and is not empty.`); +} + +function buildPackageJson(input: PluginScaffoldInput): string { + const manifestName = normalizePluginName(input.displayName); + return `${JSON.stringify( + { + name: input.packageName, + version: "0.1.0", + description: input.description, + license: "MIT", + author: input.author, + type: "module", + main: "dist/index.js", + types: "dist/index.d.ts", + exports: { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + }, + }, + files: ["dist"], + scripts: { + build: "tsc", + typecheck: "tsc --noEmit", + clean: "rm -rf dist", + }, + dependencies: { + "@composio/ao-core": CORE_VERSION_RANGE, + }, + devDependencies: { + "@types/node": NODE_TYPES_VERSION, + typescript: TYPESCRIPT_VERSION, + }, + keywords: ["agent-orchestrator", "plugin", input.slot, manifestName], + }, + null, + 2, + )}\n`; +} + +function buildTsConfig(): string { + return `${JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "Node16", + moduleResolution: "Node16", + lib: ["ES2022"], + declaration: true, + sourceMap: true, + strict: true, + esModuleInterop: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + outDir: "dist", + rootDir: "src", + }, + include: ["src"], + }, + null, + 2, + )}\n`; +} + +function buildIndexTs(input: PluginScaffoldInput): string { + const manifestName = normalizePluginName(input.displayName); + const displayName = input.displayName.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + const description = input.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + + return `import type { PluginModule } from "@composio/ao-core"; + +export const manifest = { + name: "${manifestName}", + slot: "${input.slot}" as const, + description: "${description}", + version: "0.1.0", + displayName: "${displayName}", +}; + +const plugin: PluginModule = { + manifest, + create(config?: Record) { + return { + name: manifest.name, + config: config ?? {}, + // TODO: replace this placeholder with a real ${input.slot} implementation. + }; + }, +}; + +export default plugin; +`; +} + +function buildReadme(input: PluginScaffoldInput): string { + const manifestName = normalizePluginName(input.displayName); + const authorLine = input.author ? `Author: ${input.author}\n\n` : ""; + return `# ${input.displayName} + +${input.description} + +${authorLine}Package: \`${input.packageName}\` + +## Development + +\`\`\`bash +npm install +npm run build +\`\`\` + +## AO Config + +Local development: + +\`\`\`yaml +plugins: + - name: ${manifestName} + source: local + path: ./${manifestName} +\`\`\` + +Published package: + +\`\`\`yaml +plugins: + - name: ${manifestName} + source: npm + package: ${input.packageName} +\`\`\` + +## Next Steps + +${SLOT_HINTS[input.slot]} +`; +} + +export function scaffoldPlugin(input: PluginScaffoldInput): string { + const targetDir = resolve(input.directory); + ensureDirectoryIsWritable(targetDir); + mkdirSync(join(targetDir, "src"), { recursive: true }); + + writeFileSync(join(targetDir, "package.json"), buildPackageJson(input), "utf-8"); + writeFileSync(join(targetDir, "tsconfig.json"), buildTsConfig(), "utf-8"); + writeFileSync(join(targetDir, "README.md"), buildReadme(input), "utf-8"); + writeFileSync(join(targetDir, ".gitignore"), "dist/\nnode_modules/\n", "utf-8"); + writeFileSync(join(targetDir, "src", "index.ts"), buildIndexTs(input), "utf-8"); + + return targetDir; +} diff --git a/packages/cli/src/lib/plugin-store.ts b/packages/cli/src/lib/plugin-store.ts new file mode 100644 index 000000000..0f7db20e6 --- /dev/null +++ b/packages/cli/src/lib/plugin-store.ts @@ -0,0 +1,148 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { exec } from "./shell.js"; +import { formatCommandError } from "./cli-errors.js"; + +interface PluginStoreManifest { + name: string; + private: boolean; + type: "module"; +} + +const STORE_MANIFEST: PluginStoreManifest = { + name: "ao-plugin-store", + private: true, + type: "module", +}; + +function getPluginStorePackageJsonPath(): string { + return join(getPluginStoreRoot(), "package.json"); +} + +function getInstalledPackageJsonPath(packageName: string): string { + return join(getPluginStoreRoot(), "node_modules", ...packageName.split("/"), "package.json"); +} + +function getStoreRequire(): NodeRequire { + const packageJsonPath = getPluginStorePackageJsonPath(); + ensurePluginStore(); + return createRequire(packageJsonPath); +} + +function isPackageSpecifier(specifier: string): boolean { + return !( + specifier.startsWith("file:") || + specifier.startsWith("node:") || + specifier.startsWith("/") || + specifier.startsWith("./") || + specifier.startsWith("../") + ); +} + +async function runNpmInStore(args: string[]): Promise { + const storeRoot = ensurePluginStore(); + try { + await exec("npm", args, { cwd: storeRoot }); + } catch (err) { + throw formatCommandError(err, { + cmd: "npm", + args, + action: "manage AO marketplace plugins", + installHints: ["Install Node.js/npm from https://nodejs.org/ and re-run the command."], + }); + } +} + +export function getPluginStoreRoot(): string { + return join(homedir(), ".agent-orchestrator", "plugins"); +} + +export function ensurePluginStore(): string { + const rootDir = getPluginStoreRoot(); + mkdirSync(rootDir, { recursive: true }); + + const packageJsonPath = getPluginStorePackageJsonPath(); + if (!existsSync(packageJsonPath)) { + writeFileSync(packageJsonPath, `${JSON.stringify(STORE_MANIFEST, null, 2)}\n`, "utf-8"); + } + + return rootDir; +} + +export function readInstalledPackageVersion(packageName: string): string | null { + const packageJsonPath = getInstalledPackageJsonPath(packageName); + if (!existsSync(packageJsonPath)) return null; + + try { + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { version?: unknown }; + return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : null; + } catch { + return null; + } +} + +export async function installPackageIntoStore( + packageName: string, + version?: string, +): Promise { + const requested = version ? `${packageName}@${version}` : packageName; + await runNpmInStore(["install", "--save-exact", requested]); + + const installedVersion = readInstalledPackageVersion(packageName); + if (!installedVersion) { + throw new Error(`Package ${packageName} was installed into the AO plugin store but no version was resolved afterwards.`); + } + + return installedVersion; +} + +export async function uninstallPackageFromStore(packageName: string): Promise { + if (!readInstalledPackageVersion(packageName)) { + return false; + } + + await runNpmInStore(["uninstall", packageName]); + return readInstalledPackageVersion(packageName) === null; +} + +export function tryResolveInstalledPluginSpecifier(packageName: string): string | null { + try { + const resolvedPath = getStoreRequire().resolve(packageName); + return pathToFileURL(resolvedPath).href; + } catch { + return null; + } +} + +export async function importPluginModuleFromSource(specifier: string): Promise { + if (isPackageSpecifier(specifier)) { + const storeSpecifier = tryResolveInstalledPluginSpecifier(specifier); + if (storeSpecifier) { + return import(storeSpecifier); + } + } + + return import(specifier); +} + +export async function getLatestPublishedPackageVersion(packageName: string): Promise { + try { + const { stdout } = await exec("npm", ["view", packageName, "version", "--json"]); + const parsed = JSON.parse(stdout) as unknown; + if (typeof parsed === "string" && parsed.length > 0) { + return parsed; + } + } catch (err) { + throw formatCommandError(err, { + cmd: "npm", + args: ["view", packageName, "version", "--json"], + action: `resolve the latest published version for ${packageName}`, + installHints: ["Install Node.js/npm from https://nodejs.org/ and re-run the command."], + }); + } + + throw new Error(`npm did not return a usable version for ${packageName}.`); +} diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts index 7db71da54..215cd006c 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -1,4 +1,4 @@ -import type { Agent, OrchestratorConfig, SCM } from "@composio/ao-core"; +import type { Agent, OrchestratorConfig, PluginRegistry, SCM } from "@composio/ao-core"; import claudeCodePlugin from "@composio/ao-plugin-agent-claude-code"; import codexPlugin from "@composio/ao-plugin-agent-codex"; import aiderPlugin from "@composio/ao-plugin-agent-aider"; @@ -23,11 +23,7 @@ const scmPlugins: Record = { export function getAgent(config: OrchestratorConfig, projectId?: string): Agent { const agentName = (projectId ? config.projects[projectId]?.agent : undefined) || config.defaults.agent; - const plugin = agentPlugins[agentName]; - if (!plugin) { - throw new Error(`Unknown agent plugin: ${agentName}`); - } - return plugin.create(); + return getAgentByName(agentName); } /** Get an agent by name directly (for fallback/no-config scenarios). */ @@ -39,6 +35,15 @@ export function getAgentByName(name: string): Agent { return plugin.create(); } +/** Get an agent by name from the shared registry. */ +export function getAgentByNameFromRegistry(registry: PluginRegistry, name: string): Agent { + const plugin = registry.get("agent", name); + if (!plugin) { + throw new Error(`Unknown agent plugin: ${name}`); + } + return plugin; +} + /** * Resolve the SCM plugin for a project (or fall back to "github"). */ @@ -50,3 +55,17 @@ export function getSCM(config: OrchestratorConfig, projectId: string): SCM { } return plugin.create(); } + +/** Resolve the SCM plugin for a project from the shared registry. */ +export function getSCMFromRegistry( + registry: PluginRegistry, + config: OrchestratorConfig, + projectId: string, +): SCM { + const scmName = config.projects[projectId]?.scm?.plugin || "github"; + const plugin = registry.get("scm", scmName); + if (!plugin) { + throw new Error(`Unknown SCM plugin: ${scmName}`); + } + return plugin; +} diff --git a/packages/cli/src/lib/project-resolution.ts b/packages/cli/src/lib/project-resolution.ts new file mode 100644 index 000000000..d7857240b --- /dev/null +++ b/packages/cli/src/lib/project-resolution.ts @@ -0,0 +1,27 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +interface ProjectWithPath { + path: string; +} + +function isWithinProject(projectPath: string, currentDir: string): boolean { + const relativePath = relative(projectPath, currentDir); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +/** + * Find the best matching project for the current directory. + * When multiple project paths contain the cwd, prefer the deepest match. + */ +export function findProjectForDirectory( + projects: Record, + currentDir: string, +): string | null { + const resolvedCurrentDir = resolve(currentDir); + + const matches = Object.entries(projects) + .filter(([, project]) => isWithinProject(resolve(project.path), resolvedCurrentDir)) + .sort(([, a], [, b]) => resolve(b.path).length - resolve(a.path).length); + + return matches[0]?.[0] ?? null; +} diff --git a/packages/cli/src/lib/prompts.ts b/packages/cli/src/lib/prompts.ts new file mode 100644 index 000000000..e7f44a29a --- /dev/null +++ b/packages/cli/src/lib/prompts.ts @@ -0,0 +1,30 @@ +import chalk from "chalk"; +import { confirm, isCancel, select, type Option } from "@clack/prompts"; + +type SelectOption = Option; + +export async function promptConfirm(message: string, initialValue = true): Promise { + const result = await confirm({ message, initialValue }); + if (isCancel(result)) { + console.log(chalk.yellow("\nCancelled.")); + process.exit(0); + } + return result; +} + +export async function promptSelect( + message: string, + options: SelectOption[], + initialValue?: T, +): Promise { + const result = await select({ + message, + options, + ...(initialValue !== undefined ? { initialValue } : {}), + }); + if (isCancel(result)) { + console.log(chalk.yellow("\nRequest Cancelled.")); + process.exit(0); + } + return result; +} diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index 63e9c613c..282caf905 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -9,6 +9,7 @@ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; import { existsSync } from "node:fs"; +import { formatCommandError } from "./cli-errors.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -52,6 +53,32 @@ export async function findFreePort(start: number, maxScan = MAX_PORT_SCAN): Prom return null; } +/** + * Open a URL in the user's browser without throwing back into the caller. + */ +export function openUrl(url: string): void { + const [cmd, args]: [string, string[]] = + process.platform === "win32" + ? ["cmd.exe", ["/c", "start", "", url]] + : [process.platform === "linux" ? "xdg-open" : "open", [url]]; + const browser = spawn(cmd, args, { stdio: "ignore" }); + browser.on("error", (err) => { + console.warn( + formatCommandError(err, { + cmd, + args, + action: `open ${url} in a browser`, + installHints: + process.platform === "linux" + ? ["Install xdg-utils so `xdg-open` is available on PATH."] + : process.platform === "win32" + ? [] + : [], + }).message, + ); + }); +} + /** * Poll until a port is accepting connections, then open a URL in the browser. * Respects an AbortSignal so the caller can cancel if the dashboard process @@ -67,14 +94,7 @@ export async function waitForPortAndOpen( while (!signal.aborted && Date.now() - start < timeoutMs) { const free = await isPortAvailable(port); if (!free) { - // Windows: `start` is a cmd.exe builtin (no start.exe), so must run via shell. - // The empty "" arg is the window title required by `start` before the URL. - const [cmd, args]: [string, string[]] = - process.platform === "win32" - ? ["cmd.exe", ["/c", "start", "", url]] - : [process.platform === "linux" ? "xdg-open" : "open", [url]]; - const browser = spawn(cmd, args, { stdio: "ignore" }); - browser.on("error", () => {}); + openUrl(url); return; } await new Promise((r) => setTimeout(r, 300)); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index e717490f6..8379aa72b 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -11,5 +11,9 @@ export default defineConfig({ maxThreads: 8, }, }, + coverage: { + provider: "v8", + reporter: ["lcov"], + }, }, }); diff --git a/packages/core/README.md b/packages/core/README.md index 82f4167b8..0b85b1a09 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,4 +1,4 @@ -# @agent-orchestrator/core +# @composio/ao-core Core services, types, and configuration for the Agent Orchestrator system. @@ -88,16 +88,16 @@ Loads plugins and provides access to them: - `get(slot, name)` — get plugin by slot + name - `list(slot)` — list all plugins for a slot - `loadBuiltins(config?)` — load built-in plugins (runtime-tmux, agent-claude-code, etc.) -- `loadFromConfig(config)` — load plugins from config (npm packages, local paths) +- `loadFromConfig(config)` — load built-ins today; external plugin descriptors are the marketplace extension point **Built-in plugins** (loaded by default): - runtime-tmux, runtime-process - agent-claude-code, agent-codex, agent-aider, agent-opencode - workspace-worktree, workspace-clone -- tracker-github, tracker-linear -- scm-github -- notifier-desktop, notifier-slack, notifier-composio, notifier-webhook +- tracker-github, tracker-linear, tracker-gitlab +- scm-github, scm-gitlab +- notifier-desktop, notifier-discord, notifier-slack, notifier-composio, notifier-openclaw, notifier-webhook - terminal-iterm2, terminal-web ### `src/config.ts` — Configuration Loading @@ -106,12 +106,12 @@ Loads and validates `agent-orchestrator.yaml`: **Main config sections:** -- `dataDir` — where session metadata lives (~/.agent-orchestrator) -- `worktreeDir` — where workspaces are created (~/.worktrees) +- Runtime data paths are auto-derived from the config location under `~/.agent-orchestrator/{hash}-{projectId}/` - `port` — web dashboard port (default 3000, set different values for multiple projects) - `terminalPort` — terminal WebSocket port (auto-detected if not set) - `directTerminalPort` — direct terminal WebSocket port (auto-detected if not set) - `defaults` — default plugins (runtime, agent, workspace, notifiers) +- `plugins` — installer-managed external plugin descriptors (registry, npm, or local) - `projects` — per-project config (repo, path, branch, symlinks, reactions, agentRules) - `notifiers` — notification channel config (Slack webhooks, etc.) - `notificationRouting` — which notifiers get which priority events @@ -125,7 +125,7 @@ Loads and validates `agent-orchestrator.yaml`: 1. Edit `src/types.ts` → `Session` interface 2. Edit `src/services/session-manager.ts` → initialize field in `spawn()` -3. Rebuild: `pnpm --filter @agent-orchestrator/core build` +3. Rebuild: `pnpm --filter @composio/ao-core build` ### Adding an Event Type @@ -188,13 +188,13 @@ Migration notes: ```bash # Run all core tests -pnpm --filter @agent-orchestrator/core test +pnpm --filter @composio/ao-core test # Run in watch mode -pnpm --filter @agent-orchestrator/core test -- --watch +pnpm --filter @composio/ao-core test -- --watch # Run specific test -pnpm --filter @agent-orchestrator/core test -- session-manager.test.ts +pnpm --filter @composio/ao-core test -- session-manager.test.ts ``` Tests are in `src/__tests__/`: @@ -209,10 +209,10 @@ Tests are in `src/__tests__/`: ```bash # Build core -pnpm --filter @agent-orchestrator/core build +pnpm --filter @composio/ao-core build # Typecheck -pnpm --filter @agent-orchestrator/core typecheck +pnpm --filter @composio/ao-core typecheck ``` This package is a dependency of all other packages. Build it first if working on the codebase. @@ -221,7 +221,7 @@ This package is a dependency of all other packages. Build it first if working on **Why flat metadata files?** -- Debuggability: `cat ~/.agent-orchestrator/my-app-3` shows full state +- Debuggability: `cat ~/.agent-orchestrator/-my-app/sessions/app-3` shows full state - No database dependency (survives crashes, easy to inspect) - Backwards-compatible with bash script orchestrator diff --git a/packages/core/__tests__/config.test.ts b/packages/core/__tests__/config.test.ts index 208557b6b..d16bad9cc 100644 --- a/packages/core/__tests__/config.test.ts +++ b/packages/core/__tests__/config.test.ts @@ -19,6 +19,9 @@ describe("Config Loading", () => { originalCwd = process.cwd(); originalEnv = { ...process.env }; + // Clear AO_CONFIG_PATH to ensure test isolation + delete process.env.AO_CONFIG_PATH; + // Change to test directory process.chdir(testDir); }); diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index b08e1d475..799c947fa 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { createPluginRegistry } from "../plugin-registry.js"; import type { PluginModule, PluginManifest, OrchestratorConfig } from "../types.js"; @@ -366,4 +369,89 @@ describe("loadFromConfig", () => { expect(importedPackages.length).toBeGreaterThan(0); expect(importedPackages).toContain("@composio/ao-plugin-runtime-tmux"); }); + + it("loads external package plugins from config.plugins", async () => { + const registry = createPluginRegistry(); + const agentPlugin = makePlugin("agent", "goose"); + const config = makeOrchestratorConfig({ + configPath: "/tmp/agent-orchestrator.yaml", + plugins: [ + { + name: "goose", + source: "npm", + package: "@example/ao-plugin-agent-goose", + }, + ], + }); + + await registry.loadFromConfig(config, async (specifier: string) => { + if (specifier === "@example/ao-plugin-agent-goose") { + return { default: agentPlugin }; + } + throw new Error(`Not found: ${specifier}`); + }); + + expect(registry.list("agent")).toContainEqual( + expect.objectContaining({ name: "goose", slot: "agent" }), + ); + expect(registry.get("agent", "goose")).not.toBeNull(); + }); + + it("loads local plugins relative to the config file", async () => { + const registry = createPluginRegistry(); + const tmpConfigDir = mkdtempSync(join(tmpdir(), "ao-plugin-registry-")); + const localPluginDir = join(tmpConfigDir, "plugins", "role-qa"); + mkdirSync(join(localPluginDir, "dist"), { recursive: true }); + writeFileSync(join(localPluginDir, "dist", "index.js"), "export default {};\n"); + writeFileSync( + join(localPluginDir, "package.json"), + JSON.stringify({ name: "role-qa", main: "dist/index.js" }), + ); + + const config = makeOrchestratorConfig({ + configPath: join(tmpConfigDir, "agent-orchestrator.yaml"), + plugins: [ + { + name: "gitlab-plus", + source: "local", + path: "./plugins/role-qa", + }, + ], + }); + + let importedSpecifier = ""; + await registry.loadFromConfig(config, async (specifier: string) => { + if (specifier.startsWith("file:")) { + importedSpecifier = specifier; + return makePlugin("tracker", "gitlab-plus"); + } + throw new Error(`Not found: ${specifier}`); + }); + + expect(importedSpecifier).toContain("/plugins/role-qa/dist/index.js"); + expect(registry.get("tracker", "gitlab-plus")).not.toBeNull(); + }); + + it("skips disabled external plugins", async () => { + const registry = createPluginRegistry(); + const config = makeOrchestratorConfig({ + configPath: "/tmp/agent-orchestrator.yaml", + plugins: [ + { + name: "goose", + source: "npm", + package: "@example/ao-plugin-agent-goose", + enabled: false, + }, + ], + }); + const importFn = vi.fn(async (_specifier: string) => { + throw new Error("should not import disabled plugin"); + }); + + await registry.loadFromConfig(config, importFn); + + expect(importFn).not.toHaveBeenCalledWith("@example/ao-plugin-agent-goose"); + expect(registry.get("agent", "goose")).toBeNull(); + }); }); diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index 546262dd6..25a66e2a8 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { readMetadataRaw } from "../metadata.js"; -import { getSessionsDir } from "../paths.js"; -import { escalateSession, recoverSession } from "../recovery/actions.js"; +import { getSessionsDir, getProjectBaseDir } from "../paths.js"; +import { cleanupSession, escalateSession, recoverSession } from "../recovery/actions.js"; import { runRecovery } from "../recovery/manager.js"; import { getRecoveryLogPath, scanAllSessions } from "../recovery/scanner.js"; import { @@ -13,7 +13,7 @@ import { type RecoveryAssessment, type RecoveryContext, } from "../recovery/types.js"; -import type { OrchestratorConfig, PluginRegistry } from "../types.js"; +import type { OrchestratorConfig, PluginRegistry, Runtime, Workspace } from "../types.js"; function makeConfig(rootDir: string): OrchestratorConfig { return { @@ -101,6 +101,12 @@ describe("recoverSession", () => { afterEach(() => { if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } rmSync(rootDir, { recursive: true, force: true }); } }); @@ -215,6 +221,12 @@ describe("escalateSession", () => { afterEach(() => { if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } rmSync(rootDir, { recursive: true, force: true }); } }); @@ -242,11 +254,135 @@ describe("escalateSession", () => { }); }); +describe("cleanupSession", () => { + let rootDir: string; + + afterEach(() => { + if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } + rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("continues cleanup and calls deleteMetadata even when workspace.destroy throws", async () => { + rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + mkdirSync(join(rootDir, "project"), { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const config = makeConfig(rootDir); + const workspacePath = join(rootDir, "worktree"); + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn().mockRejectedValue(new Error("Workspace destroy failed")), + list: vi.fn(), + exists: vi.fn(), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "workspace") return mockWorkspace; + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const assessment = makeAssessment({ + action: "cleanup", + classification: "dead", + runtimeAlive: false, + workspaceExists: true, + workspacePath, + rawMetadata: { + ...makeAssessment().rawMetadata, + worktree: workspacePath, + }, + }); + const context = makeContext(rootDir); + + const result = await cleanupSession(assessment, config, registry, context); + const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path); + + expect(mockWorkspace.destroy).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(existsSync(join(sessionsDir, "app-1"))).toBe(false); + }); + + it("continues cleanup and calls workspace.destroy and deleteMetadata even when runtime.destroy throws", async () => { + rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + mkdirSync(join(rootDir, "project"), { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const config = makeConfig(rootDir); + const workspacePath = join(rootDir, "worktree"); + const mockRuntime: Runtime = { + name: "tmux", + create: vi.fn(), + destroy: vi.fn().mockRejectedValue(new Error("Runtime destroy failed")), + sendMessage: vi.fn(), + getOutput: vi.fn(), + isAlive: vi.fn(), + }; + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + list: vi.fn(), + exists: vi.fn(), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const assessment = makeAssessment({ + action: "cleanup", + classification: "partial", + runtimeAlive: true, + workspaceExists: true, + workspacePath, + rawMetadata: { + ...makeAssessment().rawMetadata, + worktree: workspacePath, + }, + }); + const context = makeContext(rootDir); + + const result = await cleanupSession(assessment, config, registry, context); + const sessionsDir = getSessionsDir(config.configPath, config.projects.app.path); + + expect(mockRuntime.destroy).toHaveBeenCalled(); + expect(mockWorkspace.destroy).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(existsSync(join(sessionsDir, "app-1"))).toBe(false); + }); +}); + describe("recovery manager and scanner", () => { let rootDir: string; afterEach(() => { if (rootDir) { + const configPath = join(rootDir, "agent-orchestrator.yaml"); + const projectPath = join(rootDir, "project"); + const projectBaseDir = getProjectBaseDir(configPath, projectPath); + if (existsSync(projectBaseDir)) { + rmSync(projectBaseDir, { recursive: true, force: true }); + } rmSync(rootDir, { recursive: true, force: true }); } }); diff --git a/packages/core/src/__tests__/recovery-validator.test.ts b/packages/core/src/__tests__/recovery-validator.test.ts index fa585db6d..cab682a4f 100644 --- a/packages/core/src/__tests__/recovery-validator.test.ts +++ b/packages/core/src/__tests__/recovery-validator.test.ts @@ -121,4 +121,94 @@ describe("recovery validator", () => { expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled(); expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled(); }); + + it("sets runtimeAlive to false when runtime.isAlive throws an error", async () => { + rootDir = join(tmpdir(), `ao-recovery-validator-${randomUUID()}`); + mkdirSync(rootDir, { recursive: true }); + const projectPath = join(rootDir, "project"); + mkdirSync(projectPath, { recursive: true }); + writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8"); + + const mockRuntime: Runtime = { + name: "tmux", + create: vi.fn(), + destroy: vi.fn(), + sendMessage: vi.fn(), + getOutput: vi.fn(), + isAlive: vi.fn().mockRejectedValue(new Error("Runtime check failed")), + }; + const mockWorkspace: Workspace = { + name: "worktree", + create: vi.fn(), + destroy: vi.fn(), + list: vi.fn(), + exists: vi.fn().mockResolvedValue(true), + }; + const mockAgent: Agent = { + name: "mock-agent", + processName: "mock-agent", + getLaunchCommand: vi.fn(), + getEnvironment: vi.fn(), + detectActivity: vi.fn(), + getActivityState: vi.fn(), + isProcessRunning: vi.fn().mockResolvedValue(false), + getSessionInfo: vi.fn(), + }; + const registry: PluginRegistry = { + register: vi.fn(), + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "workspace") return mockWorkspace; + if (slot === "agent") return mockAgent; + return null; + }), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn().mockResolvedValue(undefined), + loadFromConfig: vi.fn().mockResolvedValue(undefined), + }; + const config: OrchestratorConfig = { + configPath: join(rootDir, "agent-orchestrator.yaml"), + port: 3000, + readyThresholdMs: 300_000, + defaults: { + runtime: "tmux", + agent: "mock-agent", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + app: { + name: "app", + repo: "org/repo", + path: projectPath, + defaultBranch: "main", + sessionPrefix: "app", + }, + }, + notifiers: {}, + notificationRouting: { + urgent: ["desktop"], + action: ["desktop"], + warning: [], + info: [], + }, + reactions: {}, + }; + const scanned: ScannedSession = { + sessionId: "app-1", + projectId: "app", + project: config.projects.app, + sessionsDir: getSessionsDir(config.configPath, projectPath), + rawMetadata: { + worktree: projectPath, + status: "working", + runtimeHandle: JSON.stringify({ id: "rt-1", runtimeName: "tmux", data: {} }), + }, + }; + + const assessment = await validateSession(scanned, config, registry); + + expect(mockRuntime.isAlive).toHaveBeenCalled(); + expect(assessment.runtimeAlive).toBe(false); + }); }); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts new file mode 100644 index 000000000..ae9e1ef45 --- /dev/null +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createSessionManager } from "../session-manager.js"; +import { writeMetadata } from "../metadata.js"; +import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js"; +import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "./test-utils.js"; + +// Mock child_process module with custom promisify +vi.mock("node:child_process", () => { + const execFileMock = vi.fn() as any; + // Implement custom promisify to return { stdout, stderr } objects + execFileMock[Symbol.for("nodejs.util.promisify.custom")] = (...args: any[]) => { + return new Promise((resolve, reject) => { + execFileMock(...args, (error: any, stdout: string, stderr: string) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })); + } else { + resolve({ stdout, stderr }); + } + }); + }); + }; + return { + execFile: execFileMock, + }; +}); + +let ctx: TestContext; +let sessionsDir: string; +let mockRegistry: PluginRegistry; +let config: OrchestratorConfig; + +beforeEach(() => { + ctx = setupTestContext(); + ({ sessionsDir, mockRegistry, config } = ctx); + + // Create an opencode agent mock + const opencodeAgent: Agent = { + name: "opencode", + processName: "opencode", + getLaunchCommand: vi.fn().mockReturnValue("opencode start"), + getEnvironment: vi.fn().mockReturnValue({}), + detectActivity: vi.fn().mockReturnValue("active"), + getActivityState: vi.fn().mockResolvedValue({ state: "active" }), + isProcessRunning: vi.fn().mockResolvedValue(true), + getSessionInfo: vi.fn().mockResolvedValue(null), + }; + + // Update registry to include opencode agent + const originalGet = mockRegistry.get; + mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => { + if (slot === "agent" && name === "opencode") { + return opencodeAgent; + } + return (originalGet as any)(slot, name); + }); + + // Set project to use opencode agent + config.projects["my-app"]!.agent = "opencode"; +}); + +afterEach(() => { + teardownTestContext(ctx); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("deleteSession retry loop", () => { + it("verifies retry count - calls execFileAsync 3 times when all attempts fail", async () => { + const { execFile } = await import("node:child_process"); + + // Setup: Create a session with opencode agent + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_123", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + let deleteCallCount = 0; + const mockError = new Error("OpenCode delete failed"); + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + cb(mockError, "", ""); + } else if (argsArray[1] === "list") { + cb(null, "[]", ""); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + // Execute kill with purgeOpenCode option + await sm.kill("app-1", { purgeOpenCode: true }); + + // Verify delete was called 3 times (one for each retry) + expect(deleteCallCount).toBe(3); + }); + + it("verifies retry delays - confirms delays are 0ms, 200ms, 600ms", async () => { + const { execFile } = await import("node:child_process"); + vi.useFakeTimers(); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_456", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + + const callTimes: number[] = []; + const mockError = new Error("OpenCode delete failed"); + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + callTimes.push(Date.now()); + cb(mockError, "", ""); + } else if (argsArray[1] === "list") { + cb(null, "[]", ""); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const killPromise = sm.kill("app-2", { purgeOpenCode: true }); + + // Run all timers to completion + await vi.runAllTimersAsync(); + await killPromise; + + // Verify we have 3 calls + expect(callTimes).toHaveLength(3); + + // Calculate delays between calls + const delay1 = callTimes[1]! - callTimes[0]!; // Should be 200ms + const delay2 = callTimes[2]! - callTimes[1]!; // Should be 600ms + + expect(delay1).toBe(200); + expect(delay2).toBe(600); + + vi.useRealTimers(); + }); + + it("verifies all retries are attempted when deletion fails", async () => { + const { execFile } = await import("node:child_process"); + + writeMetadata(sessionsDir, "app-3", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_789", + runtimeHandle: JSON.stringify(makeHandle("rt-3")), + }); + + const lastError = new Error("Final error after retries"); + let deleteCallCount = 0; + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + const error = deleteCallCount === 3 ? lastError : new Error(`Error ${deleteCallCount}`); + cb(error, "", ""); + } else if (argsArray[1] === "list") { + cb(null, "[]", ""); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + // The kill function catches and ignores deleteOpenCodeSession() failures, + // so this test verifies that all retry attempts are made despite errors + await sm.kill("app-3", { purgeOpenCode: true }); + + // Verify all 3 delete attempts were made + expect(deleteCallCount).toBe(3); + }); + + it("verifies early success exit - stops after first success without unnecessary retries", async () => { + const { execFile } = await import("node:child_process"); + + writeMetadata(sessionsDir, "app-4", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_abc", + runtimeHandle: JSON.stringify(makeHandle("rt-4")), + }); + + let deleteCallCount = 0; + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + if (deleteCallCount === 1) { + // First attempt fails + cb(new Error("First attempt failed"), "", ""); + } else { + // Second attempt succeeds + cb(null, "", ""); + } + } else if (argsArray[1] === "list") { + cb(null, "[]", ""); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-4", { purgeOpenCode: true }); + + // Verify delete was called exactly 2 times (failed once, succeeded on second) + expect(deleteCallCount).toBe(2); + }); + + it("verifies session-not-found handling - exits gracefully without retrying", async () => { + const { execFile } = await import("node:child_process"); + + writeMetadata(sessionsDir, "app-5", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_test_def", + runtimeHandle: JSON.stringify(makeHandle("rt-5")), + }); + + const notFoundError = new Error("Session not found: ses_test_def") as Error & { + stderr?: string; + stdout?: string; + }; + notFoundError.stderr = "Error: session not found: ses_test_def"; + + let deleteCallCount = 0; + + vi.mocked(execFile).mockImplementation(((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === "function" ? options : callback; + if (!cb) return null as any; + + const argsArray = Array.isArray(args) ? args : []; + if (argsArray[1] === "delete") { + deleteCallCount++; + cb(notFoundError, "", ""); + } else if (argsArray[1] === "list") { + cb(null, "[]", ""); + } + return null as any; + }) as any); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-5", { purgeOpenCode: true }); + + // Verify delete was called only once - no retries for "not found" errors + expect(deleteCallCount).toBe(1); + }); +}); diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index a9cba56fd..cd1a3f391 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -215,6 +215,9 @@ describe("send", () => { runtimeHandle: JSON.stringify(makeHandle("rt-1")), }); + vi.mocked(mockRuntime.isAlive).mockResolvedValue(true); + vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after"); + const sm = createSessionManager({ config, registry: mockRegistry }); await sm.send("app-1", "hello"); @@ -247,6 +250,9 @@ describe("send", () => { runtimeHandle: JSON.stringify(makeHandle("rt-1")), }); + vi.mocked(mockRuntime.isAlive).mockResolvedValue(true); + vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValue("after"); + const sm = createSessionManager({ config, registry: mockRegistry }); await sm.send("app-1", "hello"); @@ -351,7 +357,7 @@ describe("send", () => { makeHandle("rt-1"), "do not confirm on visibility", ); - }); + }, 10000); }); describe("remap", () => { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5ddc731bf..13a805195 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -181,12 +181,40 @@ const DefaultPluginsSchema = z.object({ worker: RoleAgentDefaultsSchema, }); +const InstalledPluginConfigSchema = z + .object({ + name: z.string(), + source: z.enum(["registry", "npm", "local"]), + package: z.string().optional(), + version: z.string().optional(), + path: z.string().optional(), + enabled: z.boolean().default(true), + }) + .superRefine((value, ctx) => { + if (value.source === "local" && !value.path) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["path"], + message: "Local plugins require a path", + }); + } + + if ((value.source === "registry" || value.source === "npm") && !value.package) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["package"], + message: "Registry and npm plugins require a package name", + }); + } + }); + const OrchestratorConfigSchema = z.object({ port: z.number().default(3000), terminalPort: z.number().optional(), directTerminalPort: z.number().optional(), readyThresholdMs: z.number().nonnegative().default(300_000), defaults: DefaultPluginsSchema.default({}), + plugins: z.array(InstalledPluginConfigSchema).default([]), projects: z.record(ProjectConfigSchema), notifiers: z.record(NotifierConfigSchema).default({}), notificationRouting: z.record(z.array(z.string())).default({ @@ -216,6 +244,12 @@ function expandPaths(config: OrchestratorConfig): OrchestratorConfig { project.path = expandHome(project.path); } + for (const plugin of config.plugins ?? []) { + if (plugin.path) { + plugin.path = expandHome(plugin.path); + } + } + return config; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af7b5519e..b8f4f58b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,7 +19,13 @@ export { } from "./config.js"; // Plugin registry -export { createPluginRegistry } from "./plugin-registry.js"; +export { + createPluginRegistry, + isPluginModule, + normalizeImportedPluginModule, + resolveLocalPluginEntrypoint, + resolvePackageExportsEntry, +} from "./plugin-registry.js"; // Metadata — flat-file session metadata read/write export { @@ -115,6 +121,7 @@ export { export type { ObservabilityMetricName, ObservabilityHealthStatus, + ObservabilityLevel, ObservabilitySummary, ProjectObserver, } from "./observability.js"; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 19a262b3c..6db22c905 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -32,6 +32,7 @@ import { type Session, type EventPriority, type ProjectConfig as _ProjectConfig, + type PREnrichmentData, } from "./types.js"; import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; @@ -198,6 +199,133 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan let polling = false; // re-entrancy guard let allCompleteEmitted = false; // guard against repeated all_complete + /** + * Cache for PR enrichment data within a single poll cycle. + * Cleared at the start of each pollAll() call. + * Key format: "${owner}/${repo}#${number}" + */ + const prEnrichmentCache = new Map(); + + /** + * Populate the PR enrichment cache using batch GraphQL queries. + * This is called once per poll cycle to fetch data for all PRs efficiently. + */ + async function populatePREnrichmentCache(sessions: Session[]): Promise { + // Clear previous cache + prEnrichmentCache.clear(); + + // Collect all unique PRs + const prs = sessions + .map((s) => s.pr) + .filter((pr): pr is NonNullable => pr !== null); + + // Deduplicate by key + const uniquePRs = Array.from( + new Map(prs.map((pr) => [`${pr.owner}/${pr.repo}#${pr.number}`, pr])).values(), + ); + + if (uniquePRs.length === 0) return; + + // Group by SCM plugin and batch fetch for each group + const prsByPlugin = new Map(); + for (const pr of uniquePRs) { + // Find the project for this PR + const project = Object.values(config.projects).find((p) => { + const [owner, repo] = p.repo.split("/"); + return owner === pr.owner && repo === pr.repo; + }); + if (!project?.scm) continue; + + const pluginKey = project.scm.plugin; + if (!prsByPlugin.has(pluginKey)) { + prsByPlugin.set(pluginKey, []); + } + const pluginPRs = prsByPlugin.get(pluginKey); + if (pluginPRs) { + pluginPRs.push(pr); + } + } + + // Fetch enrichment data for each plugin's PRs + for (const [pluginKey, pluginPRs] of prsByPlugin) { + const scm = registry.get("scm", pluginKey); + if (!scm?.enrichSessionsPRBatch) continue; + + const batchStartTime = Date.now(); + try { + const enrichmentData = await scm.enrichSessionsPRBatch( + pluginPRs, + { + recordSuccess(_data) { + const batchDuration = Date.now() - batchStartTime; + observer?.recordOperation({ + metric: "graphql_batch", + operation: "batch_enrichment", + correlationId: createCorrelationId("graphql-batch"), + outcome: "success", + projectId: scopedProjectId, + durationMs: batchDuration, + data: { + plugin: pluginKey, + prCount: pluginPRs.length, + prKeys: pluginPRs.map((pr) => `${pr.owner}/${pr.repo}#${pr.number}`), + }, + level: "info", + }); + }, + recordFailure(data) { + const batchDuration = Date.now() - batchStartTime; + observer?.recordOperation({ + metric: "graphql_batch", + operation: "batch_enrichment", + correlationId: createCorrelationId("graphql-batch"), + outcome: "failure", + reason: data.error, + level: "warn", + data: { + plugin: pluginKey, + prCount: pluginPRs.length, + error: data.error, + durationMs: batchDuration, + }, + }); + }, + log(level, message) { + // Log to stderr for observability + process.stderr.write( + JSON.stringify({ + source: "ao-graphql-batch", + level, + message, + plugin: pluginKey, + timestamp: new Date().toISOString(), + }) + "\n" + ); + }, + }, + ); + + // Merge into cache + for (const [key, data] of enrichmentData) { + prEnrichmentCache.set(key, data); + } + } catch (err) { + // Batch fetch failed - individual calls will still work + const errorMsg = err instanceof Error ? err.message : String(err); + const batchCorrelationId = createCorrelationId("batch-enrichment"); + observer?.recordOperation?.({ + metric: "lifecycle_poll", + operation: "batch_enrichment", + correlationId: batchCorrelationId, + outcome: "failure", + reason: errorMsg, + level: "warn", + data: { plugin: pluginKey, prCount: pluginPRs.length }, + }); + } + } + } + /** Check if idle time exceeds the agent-stuck threshold. */ function isIdleBeyondThreshold(session: Session, idleTimestamp: Date): boolean { const stuckReaction = getReactionConfigForSession(session, "agent-stuck"); @@ -311,6 +439,43 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // 4. Check PR state if PR exists if (session.pr && scm) { try { + // Try to use cached enrichment data from batch GraphQL query + const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; + const cachedData = prEnrichmentCache.get(prKey); + + if (cachedData) { + // Use cached enrichment data - avoids individual API calls + if (cachedData.state === PR_STATE.MERGED) return "merged"; + if (cachedData.state === PR_STATE.CLOSED) return "killed"; + + // Check CI + if (cachedData.ciStatus === CI_STATUS.FAILING) return "ci_failed"; + + // Check reviews + if (cachedData.reviewDecision === "changes_requested") + return "changes_requested"; + if (cachedData.reviewDecision === "approved" || cachedData.reviewDecision === "none") { + // Check merge readiness — treat "none" (no reviewers required) + // as "approved" so CI-green PRs reach "mergeable" status + // and fire the merge.ready event / approved-and-green reaction. + if (cachedData.mergeable) return "mergeable"; + if (cachedData.reviewDecision === "approved") return "approved"; + } + if (cachedData.reviewDecision === "pending") return "review_pending"; + + // 4b. Post-PR stuck detection: agent has a PR open but is idle beyond + // threshold. This catches the case where step 2's stuck check was + // bypassed (getActivityState returned null) or the idle timestamp + // wasn't available during step 2 but the session has been at pr_open + // for a long time. Without this, sessions get stuck at "pr_open" forever. + if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) { + return "stuck"; + } + + return "pr_open"; + } + + // Fall back to individual API calls if no cached data const prState = await scm.getPRState(session.pr); if (prState === PR_STATE.MERGED) return "merged"; if (prState === PR_STATE.CLOSED) return "killed"; @@ -324,7 +489,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (reviewDecision === "changes_requested") return "changes_requested"; if (reviewDecision === "approved" || reviewDecision === "none") { // Check merge readiness — treat "none" (no reviewers required) - // the same as "approved" so CI-green PRs reach "mergeable" status + // as "approved" so CI-green PRs reach "mergeable" status // and fire the merge.ready event / approved-and-green reaction. const mergeReady = await scm.getMergeability(session.pr); if (mergeReady.mergeable) return "mergeable"; @@ -811,6 +976,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return tracked !== undefined && tracked !== s.status; }); + // Populate PR enrichment cache using batch GraphQL queries + // This reduces API calls from N×3 to 1 per poll cycle + await populatePREnrichmentCache(sessionsToCheck); + // Poll all sessions concurrently await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s))); diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 1c6f4abe1..49ad537bf 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -18,6 +18,7 @@ export type ObservabilityMetricName = | "api_request" | "claim_pr" | "cleanup" + | "graphql_batch" | "kill" | "lifecycle_poll" | "restore" diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 5d81a5f35..d807d956d 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -7,7 +7,11 @@ * 3. Local file paths specified in config */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import type { + InstalledPluginConfig, PluginSlot, PluginManifest, PluginModule, @@ -18,6 +22,8 @@ import type { /** Map from "slot:name" → plugin instance */ type PluginMap = Map; +const LOCAL_PLUGIN_ENTRY_CANDIDATES = ["dist/index.js", "index.js"] as const; + function makeKey(slot: PluginSlot, name: string): string { return `${slot}:${name}`; } @@ -70,7 +76,7 @@ function extractPluginConfig( const matches = hasExplicitPlugin ? configuredPlugin === name : notifierName === name; if (matches) { const { plugin: _plugin, ...rest } = notifierConfig as Record; - return rest; + return config.configPath ? { ...rest, configPath: config.configPath } : rest; } } } @@ -78,6 +84,129 @@ function extractPluginConfig( return undefined; } +export function isPluginModule(value: unknown): value is PluginModule { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return Boolean(candidate.manifest && typeof candidate.create === "function"); +} + +export function normalizeImportedPluginModule(value: unknown): PluginModule | null { + if (isPluginModule(value)) return value; + + if (value && typeof value === "object" && "default" in value) { + const defaultExport = (value as { default?: unknown }).default; + if (isPluginModule(defaultExport)) return defaultExport; + } + + return null; +} + +function resolveConfigRelativePath(targetPath: string, configPath?: string): string { + if (isAbsolute(targetPath)) return targetPath; + const baseDir = configPath ? dirname(configPath) : process.cwd(); + return resolve(baseDir, targetPath); +} + +export function resolvePackageExportsEntry(exportsField: unknown): string | null { + if (typeof exportsField === "string") return exportsField; + if (!exportsField || typeof exportsField !== "object") return null; + + const exportsRecord = exportsField as Record; + const dotEntry = exportsRecord["."]; + + if (typeof dotEntry === "string") return dotEntry; + if (dotEntry && typeof dotEntry === "object") { + const importEntry = (dotEntry as Record)["import"]; + if (typeof importEntry === "string") return importEntry; + const defaultEntry = (dotEntry as Record)["default"]; + if (typeof defaultEntry === "string") return defaultEntry; + } + + const importEntry = exportsRecord["import"]; + if (typeof importEntry === "string") return importEntry; + + const defaultEntry = exportsRecord["default"]; + if (typeof defaultEntry === "string") return defaultEntry; + + return null; +} + +export function resolveLocalPluginEntrypoint(pluginPath: string): string | null { + if (!existsSync(pluginPath)) return null; + + let stat; + try { + stat = statSync(pluginPath); + } catch { + return null; + } + + if (stat.isFile()) return pluginPath; + if (!stat.isDirectory()) return null; + + const packageJsonPath = join(pluginPath, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const raw = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(raw) as { + exports?: unknown; + module?: unknown; + main?: unknown; + }; + + const exportsEntry = resolvePackageExportsEntry(packageJson.exports); + if (exportsEntry) { + const resolvedEntry = resolve(pluginPath, exportsEntry); + if (existsSync(resolvedEntry)) return resolvedEntry; + } + + if (typeof packageJson.module === "string") { + const moduleEntry = resolve(pluginPath, packageJson.module); + if (existsSync(moduleEntry)) return moduleEntry; + } + + if (typeof packageJson.main === "string") { + const mainEntry = resolve(pluginPath, packageJson.main); + if (existsSync(mainEntry)) return mainEntry; + } + } catch { + // Fall through to common entrypoint guesses below. + } + } + + for (const candidate of LOCAL_PLUGIN_ENTRY_CANDIDATES) { + const entry = join(pluginPath, candidate); + if (existsSync(entry)) return entry; + } + + return null; +} + +function inferPackageSpecifier(value: string | undefined): string | null { + if (!value) return null; + if (value.startsWith(".") || value.startsWith("/")) return null; + return value.startsWith("@") || value.includes("/") ? value : null; +} + +function resolvePluginSpecifier( + plugin: InstalledPluginConfig, + config: OrchestratorConfig, +): string | null { + switch (plugin.source) { + case "local": { + if (!plugin.path) return null; + const absolutePath = resolveConfigRelativePath(plugin.path, config.configPath); + const entrypoint = resolveLocalPluginEntrypoint(absolutePath); + return entrypoint ? pathToFileURL(entrypoint).href : null; + } + case "registry": + case "npm": + return plugin.package ?? inferPackageSpecifier(plugin.name); + default: + return null; + } +} + export function createPluginRegistry(): PluginRegistry { const plugins: PluginMap = new Map(); @@ -111,8 +240,8 @@ export function createPluginRegistry(): PluginRegistry { const doImport = importFn ?? ((pkg: string) => import(pkg)); for (const builtin of BUILTIN_PLUGINS) { try { - const mod = (await doImport(builtin.pkg)) as PluginModule; - if (mod.manifest && typeof mod.create === "function") { + const mod = normalizeImportedPluginModule(await doImport(builtin.pkg)); + if (mod) { const pluginConfig = orchestratorConfig ? extractPluginConfig(builtin.slot, builtin.name, orchestratorConfig) : undefined; @@ -131,8 +260,27 @@ export function createPluginRegistry(): PluginRegistry { // Load built-ins with orchestrator config so plugins receive their settings await this.loadBuiltins(config, importFn); - // Then, load any additional plugins specified in project configs - // (future: support npm package names and local file paths) + const doImport = importFn ?? ((pkg: string) => import(pkg)); + + for (const plugin of config.plugins ?? []) { + if (plugin.enabled === false) continue; + + const specifier = resolvePluginSpecifier(plugin, config); + if (!specifier) { + console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`); + continue; + } + + try { + const mod = normalizeImportedPluginModule(await doImport(specifier)); + if (!mod) continue; + + const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config); + this.register(mod, pluginConfig); + } catch (error) { + console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error); + } + } }, }; } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 6baa4fa28..dcf127d2a 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -76,7 +76,7 @@ import { safeJsonParse } from "./utils/validation.js"; import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; const execFileAsync = promisify(execFile); -const OPENCODE_DISCOVERY_TIMEOUT_MS = 2_000; +const OPENCODE_DISCOVERY_TIMEOUT_MS = 10_000; const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000; function errorIncludesSessionNotFound(err: unknown): boolean { @@ -1372,7 +1372,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM AO_CALLER_TYPE: "orchestrator", AO_PROJECT_ID: orchestratorConfig.projectId, AO_CONFIG_PATH: config.configPath, - ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }), + ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }) }, }); @@ -1487,7 +1487,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM let enrichTimeoutId: ReturnType | null = null; const enrichTimeout = new Promise((resolve) => { - enrichTimeoutId = setTimeout(resolve, 2_000); + enrichTimeoutId = setTimeout(resolve, OPENCODE_DISCOVERY_TIMEOUT_MS + 2_000); }); const enrichPromise = ensureHandleAndEnrich( session, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3e1880196..2431511a0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,3 +1,5 @@ +import type { ObservabilityLevel } from "./observability.js"; + /** * Agent Orchestrator — Core Type Definitions * @@ -592,6 +594,21 @@ export interface SCM { /** Check if PR is ready to merge */ getMergeability(pr: PRInfo): Promise; + + /** + * Batch fetch PR data for multiple PRs in a single GraphQL query. + * Used by the orchestrator to poll all active sessions efficiently. + * + * This is an optimization method that, when implemented, can dramatically + * reduce API calls by fetching data for multiple PRs in one request + * instead of calling getPRState/getCISummary/getReviewDecision separately + * for each PR. + * + * @param prs - Array of PR information to fetch data for + * @param observer - Optional observer for batch operation metrics + * @returns Map keyed by "${owner}/${repo}#${number}" containing enrichment data + */ + enrichSessionsPRBatch?(prs: PRInfo[], observer?: BatchObserver): Promise>; } // --- PR Types --- @@ -718,6 +735,59 @@ export interface MergeReadiness { blockers: string[]; } +/** + * Batch enrichment data returned by SCM plugins. + * Contains all the information the orchestrator needs for status detection. + */ +export interface PREnrichmentData { + /** Current PR state */ + state: PRState; + /** Overall CI status */ + ciStatus: CIStatus; + /** Review decision */ + reviewDecision: ReviewDecision; + /** Whether the PR is mergeable based on CI, reviews, and merge state */ + mergeable: boolean; + /** PR title */ + title?: string; + /** Number of additions */ + additions?: number; + /** Number of deletions */ + deletions?: number; + /** Whether PR is a draft */ + isDraft?: boolean; + /** Whether PR has merge conflicts */ + hasConflicts?: boolean; + /** Whether PR is behind base branch */ + isBehind?: boolean; + /** List of blockers preventing merge */ + blockers?: string[]; +} + +/** + * Observer for GraphQL batch PR enrichment operations. + * Used by SCM plugins to report batch success/failure to the observability system. + */ +export interface BatchObserver { + /** Record a successful batch enrichment */ + recordSuccess(data: { + batchIndex: number; + totalBatches: number; + prCount: number; + durationMs: number; + }): void; + /** Record a failed batch enrichment */ + recordFailure(data: { + batchIndex: number; + totalBatches: number; + prCount: number; + error: string; + durationMs: number; + }): void; + /** Log a message at a specific level */ + log(level: ObservabilityLevel, message: string): void; +} + // ============================================================================= // NOTIFIER — Plugin Slot 6 (PRIMARY INTERFACE) // ============================================================================= @@ -901,6 +971,9 @@ export interface OrchestratorConfig { /** Default plugin selections */ defaults: DefaultPlugins; + /** Installer-managed external plugin descriptors */ + plugins?: InstalledPluginConfig[]; + /** Project configurations */ projects: Record; @@ -927,6 +1000,28 @@ export interface DefaultPlugins { }; } +export type InstalledPluginSource = "registry" | "npm" | "local"; + +export interface InstalledPluginConfig { + /** Stable logical plugin name used in config and CLI UX */ + name: string; + + /** Where the plugin should be resolved from */ + source: InstalledPluginSource; + + /** Package name for registry/npm-managed plugins */ + package?: string; + + /** Requested version/range for installer-managed plugins */ + version?: string; + + /** Filesystem path for local plugins */ + path?: string; + + /** Installer-managed enable flag (defaults to true) */ + enabled?: boolean; +} + export interface RoleAgentConfig { agent?: string; agentConfig?: AgentSpecificConfig; diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 32cbe7b9b..4d31bb5dd 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -16,5 +16,11 @@ export default defineConfig({ ), "@composio/ao-plugin-scm-github": resolve(__dirname, "../plugins/scm-github/src/index.ts"), }, + coverage: { + provider: "v8", + reporter: ["lcov"], + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**", "src/index.ts", "src/recovery/index.ts"], + }, }, }); diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 271d5675e..19e793a1f 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -5,7 +5,7 @@ "description": "Integration tests — requires real binaries and tmux", "type": "module", "scripts": { - "test": "echo 'Skipped — run test:integration instead'", + "test": "vitest run --config vitest.config.ts", "test:integration": "vitest run --config vitest.config.ts", "typecheck": "tsc --noEmit" }, diff --git a/packages/mobile/.gitignore b/packages/mobile/.gitignore deleted file mode 100644 index bf107f1b0..000000000 --- a/packages/mobile/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# Expo -.expo/ -dist/ -web-build/ - -# Native build folders (generated by `expo prebuild`) -android/ -ios/ - -# Native -*.orig.* -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision - -# Metro -.metro-health-check* - -# debug -npm-debug.* -yarn-debug.* -yarn-error.* - -# macOS -.DS_Store -*.pem - -# local env files -.env*.local - -# npm (project uses pnpm) -package-lock.json diff --git a/packages/mobile/app.json b/packages/mobile/app.json deleted file mode 100644 index ad1e56dab..000000000 --- a/packages/mobile/app.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "expo": { - "name": "Agent Orchestrator", - "slug": "ao-mobile", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/icon.png", - "userInterfaceStyle": "dark", - "splash": { - "image": "./assets/splash.png", - "resizeMode": "contain", - "backgroundColor": "#0d1117" - }, - "assetBundlePatterns": [ - "**/*" - ], - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.composio.ao-mobile", - "infoPlist": { - "UIBackgroundModes": [ - "fetch", - "processing", - "remote-notification" - ] - } - }, - "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#0d1117" - }, - "package": "com.composio.aomobile", - "usesCleartextTraffic": true - }, - "web": { - "favicon": "./assets/favicon.png" - }, - "plugins": [ - "expo-background-task", - [ - "expo-notifications", - { - "color": "#f85149", - "defaultChannel": "ao-respond" - } - ] - ], - "extra": { - "eas": { - "projectId": "de59ca61-8fd4-47bb-bdea-6e1a50ade3df" - } - } - } -} diff --git a/packages/mobile/assets/adaptive-icon.png b/packages/mobile/assets/adaptive-icon.png deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/adaptive-icon.png and /dev/null differ diff --git a/packages/mobile/assets/favicon.png b/packages/mobile/assets/favicon.png deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/favicon.png and /dev/null differ diff --git a/packages/mobile/assets/icon.png b/packages/mobile/assets/icon.png deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/icon.png and /dev/null differ diff --git a/packages/mobile/assets/splash.png b/packages/mobile/assets/splash.png deleted file mode 100644 index 9e2529313..000000000 Binary files a/packages/mobile/assets/splash.png and /dev/null differ diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js deleted file mode 100644 index 73ebf58e3..000000000 --- a/packages/mobile/babel.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ["babel-preset-expo"], - }; -}; diff --git a/packages/mobile/eas.json b/packages/mobile/eas.json deleted file mode 100644 index 59b609d6b..000000000 --- a/packages/mobile/eas.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "cli": { - "version": ">= 12.0.0" - }, - "build": { - "development": { - "developmentClient": true, - "distribution": "internal" - }, - "preview": { - "distribution": "internal", - "ios": { - "simulator": false - } - }, - "production": {} - }, - "submit": { - "production": {} - } -} diff --git a/packages/mobile/index.js b/packages/mobile/index.js deleted file mode 100644 index 000be560b..000000000 --- a/packages/mobile/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import { registerRootComponent } from "expo"; -import App from "./src/App"; - -registerRootComponent(App); diff --git a/packages/mobile/metro.config.js b/packages/mobile/metro.config.js deleted file mode 100644 index c75ab9503..000000000 --- a/packages/mobile/metro.config.js +++ /dev/null @@ -1,10 +0,0 @@ -const { getDefaultConfig } = require("expo/metro-config"); - -const config = getDefaultConfig(__dirname); - -// Disable package exports resolution — some packages (math-intrinsics, expo-application) -// have invalid or incomplete `exports` fields that cause Metro bundling failures. -// Falling back to classic file-based resolution fixes these. -config.resolver.unstable_enablePackageExports = false; - -module.exports = config; diff --git a/packages/mobile/package.json b/packages/mobile/package.json deleted file mode 100644 index 37911a4d6..000000000 --- a/packages/mobile/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@composio/ao-mobile", - "version": "0.1.0", - "private": true, - "main": "index.js", - "scripts": { - "start": "expo start", - "android": "expo run:android", - "ios": "expo run:ios", - "web": "expo start --web" - }, - "dependencies": { - "@react-native-async-storage/async-storage": "2.1.2", - "@react-navigation/native": "^6.1.18", - "@react-navigation/native-stack": "^6.11.0", - "expo": "~53.0.0", - "expo-application": "~6.1.5", - "expo-background-task": "~0.2.8", - "expo-notifications": "~0.31.5", - "expo-status-bar": "~2.2.3", - "expo-task-manager": "~13.1.6", - "react": "19.0.0", - "react-native": "0.79.6", - "react-native-safe-area-context": "5.4.0", - "react-native-screens": "~4.11.1", - "react-native-webview": "13.13.5" - }, - "devDependencies": { - "@babel/core": "^7.24.0", - "@types/react": "~19.0.10", - "typescript": "~5.8.3" - } -} diff --git a/packages/mobile/src/App.tsx b/packages/mobile/src/App.tsx deleted file mode 100644 index c44f3c870..000000000 --- a/packages/mobile/src/App.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useEffect } from "react"; -import { SafeAreaProvider } from "react-native-safe-area-context"; -import * as Notifications from "expo-notifications"; -import { BackendProvider } from "./context/BackendContext"; -import RootNavigator from "./navigation/RootNavigator"; -import { navigationRef } from "./navigation/RootNavigator"; -import { setupNotifications } from "./notifications"; -import { registerBackgroundTask } from "./notifications/backgroundTask"; - -/** Navigate to a session when a notification is tapped. */ -function handleNotificationResponse(response: Notifications.NotificationResponse) { - const data = response.notification.request.content.data as - | { sessionId?: string } - | undefined; - const sessionId = data?.sessionId; - if (!sessionId) return; - - // Wait for navigation to be ready (cold-start case) - const tryNavigate = () => { - if (navigationRef.isReady()) { - navigationRef.navigate("SessionDetail", { sessionId }); - } else { - setTimeout(tryNavigate, 100); - } - }; - tryNavigate(); -} - -export default function App() { - useEffect(() => { - void setupNotifications(); - void registerBackgroundTask(); - - // Handle notification tapped while app was killed or in background - void Notifications.getLastNotificationResponseAsync().then((response) => { - if (response) handleNotificationResponse(response); - }); - - // Handle notification tapped while app is running - const sub = Notifications.addNotificationResponseReceivedListener(handleNotificationResponse); - return () => sub.remove(); - }, []); - - return ( - - - - - - ); -} diff --git a/packages/mobile/src/components/AttentionBadge.tsx b/packages/mobile/src/components/AttentionBadge.tsx deleted file mode 100644 index 34e9b3e26..000000000 --- a/packages/mobile/src/components/AttentionBadge.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import { View, Text, StyleSheet } from "react-native"; -import { ATTENTION_COLORS, type AttentionLevel } from "../types"; - -interface Props { - level: AttentionLevel; -} - -const LABELS: Record = { - merge: "MERGE", - respond: "RESPOND", - review: "REVIEW", - pending: "PENDING", - working: "WORKING", - done: "DONE", -}; - -export default function AttentionBadge({ level }: Props) { - const color = ATTENTION_COLORS[level]; - return ( - - {LABELS[level]} - - ); -} - -const styles = StyleSheet.create({ - badge: { - borderWidth: 1, - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - }, - label: { - fontSize: 10, - fontWeight: "700", - letterSpacing: 0.5, - }, -}); diff --git a/packages/mobile/src/components/SessionCard.tsx b/packages/mobile/src/components/SessionCard.tsx deleted file mode 100644 index dc1d84338..000000000 --- a/packages/mobile/src/components/SessionCard.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from "react"; -import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; -import { - getAttentionLevel, - relativeTime, - ATTENTION_COLORS, - type DashboardSession, -} from "../types"; -import AttentionBadge from "./AttentionBadge"; - -interface Props { - session: DashboardSession; - onPress: () => void; -} - -export default function SessionCard({ session, onPress }: Props) { - const level = getAttentionLevel(session); - const color = ATTENTION_COLORS[level]; - const time = relativeTime(session.lastActivityAt); - - return ( - - - - {session.id} - - - - - {session.issueLabel || session.issueTitle ? ( - - {session.issueLabel ? `${session.issueLabel}: ` : ""} - {session.issueTitle ?? ""} - - ) : null} - - {session.summary && !session.summaryIsFallback ? ( - - {session.summary} - - ) : null} - - - {session.branch ? ( - - {session.branch} - - ) : null} - {time} - - - {session.pr ? ( - - PR #{session.pr.number} - {session.pr.ciStatus !== "none" && ( - - {session.pr.ciStatus.toUpperCase()} - - )} - - ) : null} - - ); -} - -const styles = StyleSheet.create({ - card: { - backgroundColor: "#161b22", - borderLeftWidth: 3, - borderRadius: 8, - padding: 14, - marginHorizontal: 12, - marginVertical: 5, - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 6, - }, - id: { - color: "#8b949e", - fontSize: 12, - fontFamily: "monospace", - flex: 1, - marginRight: 8, - }, - issue: { - color: "#e6edf3", - fontSize: 14, - fontWeight: "600", - marginBottom: 4, - }, - summary: { - color: "#8b949e", - fontSize: 13, - lineHeight: 18, - marginBottom: 6, - }, - footer: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - branch: { - color: "#58a6ff", - fontSize: 12, - fontFamily: "monospace", - flex: 1, - }, - time: { - color: "#8b949e", - fontSize: 12, - }, - prRow: { - flexDirection: "row", - alignItems: "center", - marginTop: 6, - gap: 8, - }, - prLabel: { - color: "#3fb950", - fontSize: 12, - fontWeight: "600", - }, - ciStatus: { - fontSize: 11, - fontWeight: "700", - letterSpacing: 0.5, - }, -}); diff --git a/packages/mobile/src/components/StatBar.tsx b/packages/mobile/src/components/StatBar.tsx deleted file mode 100644 index d237598fb..000000000 --- a/packages/mobile/src/components/StatBar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from "react"; -import { View, Text, StyleSheet } from "react-native"; -import type { DashboardStats } from "../types"; - -interface Props { - stats: DashboardStats; -} - -export default function StatBar({ stats }: Props) { - return ( - - - - - - - ); -} - -function StatItem({ label, value, color }: { label: string; value: number; color: string }) { - return ( - - {value} - {label} - - ); -} - -const styles = StyleSheet.create({ - container: { - flexDirection: "row", - justifyContent: "space-around", - backgroundColor: "#161b22", - paddingVertical: 12, - paddingHorizontal: 16, - borderBottomWidth: 1, - borderBottomColor: "#30363d", - }, - item: { - alignItems: "center", - }, - value: { - fontSize: 20, - fontWeight: "700", - }, - label: { - fontSize: 11, - color: "#8b949e", - marginTop: 2, - }, -}); diff --git a/packages/mobile/src/context/BackendContext.tsx b/packages/mobile/src/context/BackendContext.tsx deleted file mode 100644 index 2ec784949..000000000 --- a/packages/mobile/src/context/BackendContext.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { DashboardSession, SessionsResponse } from "../types"; - -const STORAGE_KEY = "@ao_backend_url"; -const TERMINAL_WS_OVERRIDE_KEY = "@ao_terminal_ws_url"; -const DEFAULT_URL = "http://192.168.1.1:3000"; - -interface BackendContextValue { - backendUrl: string; - setBackendUrl: (url: string) => Promise; - /** WebSocket URL for the terminal server. Auto-derived unless manually overridden. */ - terminalWsUrl: string; - /** Manual override for terminal WS URL (for ngrok / different host). Empty = auto-derive. */ - terminalWsOverride: string; - setTerminalWsOverride: (url: string) => Promise; - fetchSessions: () => Promise; - fetchSession: (id: string) => Promise; - sendMessage: (id: string, message: string) => Promise; - killSession: (id: string) => Promise; - restoreSession: (id: string) => Promise; - mergePR: (prNumber: number) => Promise; - spawnSession: (projectId: string, issueId?: string) => Promise; -} - -const BackendContext = createContext(null); - -function deriveTerminalWsUrl(backendUrl: string): string { - try { - const url = new URL(backendUrl); - return `ws://${url.hostname}:14801`; - } catch { - return "ws://192.168.1.1:14801"; - } -} - -/** Convert an ngrok https URL to a wss URL for WebSocket connections */ -function normalizeWsUrl(url: string): string { - return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://"); -} - -export function BackendProvider({ children }: { children: React.ReactNode }) { - const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL); - const [terminalWsOverride, setTerminalWsOverrideState] = useState(""); - - useEffect(() => { - Promise.all([ - AsyncStorage.getItem(STORAGE_KEY), - AsyncStorage.getItem(TERMINAL_WS_OVERRIDE_KEY), - ]).then(([storedBackend, storedWs]) => { - if (storedBackend) setBackendUrlState(storedBackend); - if (storedWs) setTerminalWsOverrideState(storedWs); - }); - }, []); - - const setBackendUrl = useCallback(async (url: string) => { - const trimmed = url.trim().replace(/\/$/, ""); - setBackendUrlState(trimmed); - await AsyncStorage.setItem(STORAGE_KEY, trimmed); - }, []); - - const setTerminalWsOverride = useCallback(async (url: string) => { - const trimmed = url.trim().replace(/\/$/, ""); - setTerminalWsOverrideState(trimmed); - await AsyncStorage.setItem(TERMINAL_WS_OVERRIDE_KEY, trimmed); - }, []); - - // Use override if set, otherwise derive from backendUrl - const terminalWsUrl = terminalWsOverride - ? normalizeWsUrl(terminalWsOverride) - : deriveTerminalWsUrl(backendUrl); - - const apiFetch = useCallback( - async (path: string, options?: RequestInit) => { - const url = `${backendUrl}${path}`; - const res = await fetch(url, { - ...options, - headers: { "Content-Type": "application/json", ...(options?.headers ?? {}) }, - }); - if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(`${res.status}: ${text}`); - } - return res; - }, - [backendUrl], - ); - - const fetchSessions = useCallback(async (): Promise => { - const res = await apiFetch("/api/sessions"); - return res.json() as Promise; - }, [apiFetch]); - - const fetchSession = useCallback(async (id: string): Promise => { - const res = await apiFetch(`/api/sessions/${encodeURIComponent(id)}`); - return res.json() as Promise; - }, [apiFetch]); - - const sendMessage = useCallback(async (id: string, message: string): Promise => { - await apiFetch(`/api/sessions/${encodeURIComponent(id)}/message`, { - method: "POST", - body: JSON.stringify({ message }), - }); - }, [apiFetch]); - - const killSession = useCallback(async (id: string): Promise => { - await apiFetch(`/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" }); - }, [apiFetch]); - - const restoreSession = useCallback(async (id: string): Promise => { - await apiFetch(`/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" }); - }, [apiFetch]); - - const mergePR = useCallback(async (prNumber: number): Promise => { - await apiFetch(`/api/prs/${prNumber}/merge`, { method: "POST" }); - }, [apiFetch]); - - const spawnSession = useCallback(async (projectId: string, issueId?: string): Promise => { - const res = await apiFetch("/api/spawn", { - method: "POST", - body: JSON.stringify({ projectId, ...(issueId ? { issueId } : {}) }), - }); - const data = (await res.json()) as { session: DashboardSession }; - return data.session; - }, [apiFetch]); - - return ( - - {children} - - ); -} - -export function useBackend(): BackendContextValue { - const ctx = useContext(BackendContext); - if (!ctx) throw new Error("useBackend must be used inside BackendProvider"); - return ctx; -} diff --git a/packages/mobile/src/hooks/useSession.ts b/packages/mobile/src/hooks/useSession.ts deleted file mode 100644 index 7bdf901ca..000000000 --- a/packages/mobile/src/hooks/useSession.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { AppState, type AppStateStatus } from "react-native"; -import { useBackend } from "../context/BackendContext"; -import type { DashboardSession } from "../types"; - -const POLL_INTERVAL = 5_000; - -interface UseSessionOptions { - /** Set to false to disable polling. Defaults to true. */ - enabled?: boolean; -} - -interface UseSessionResult { - session: DashboardSession | null; - loading: boolean; - error: string | null; - refresh: () => void; -} - -export function useSession(id: string, options?: UseSessionOptions): UseSessionResult { - const enabled = options?.enabled ?? true; - const { fetchSession } = useBackend(); - const [session, setSession] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const intervalRef = useRef | null>(null); - // Generation counter — incremented on cleanup to invalidate in-flight fetches - // from a previous effect run (e.g. when backend URL changes). - const fetchGenRef = useRef(0); - - const doFetch = useCallback(async () => { - const gen = fetchGenRef.current; - try { - const data = await fetchSession(id); - if (gen !== fetchGenRef.current) return; - setSession(data); - setError(null); - } catch (err) { - if (gen !== fetchGenRef.current) return; - setError(err instanceof Error ? err.message : "Failed to load session"); - } finally { - if (gen === fetchGenRef.current) setLoading(false); - } - }, [fetchSession, id]); - - const startPolling = useCallback(() => { - doFetch(); - intervalRef.current = setInterval(doFetch, POLL_INTERVAL); - }, [doFetch]); - - const stopPolling = useCallback(() => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }, []); - - useEffect(() => { - if (!enabled) { - setSession(null); - setLoading(false); - return; - } - - startPolling(); - - const handleAppState = (nextState: AppStateStatus) => { - if (nextState === "active") { - stopPolling(); - startPolling(); - } else { - stopPolling(); - } - }; - - const sub = AppState.addEventListener("change", handleAppState); - - return () => { - fetchGenRef.current++; // Invalidate in-flight fetches from this effect run - stopPolling(); - sub.remove(); - }; - }, [enabled, startPolling, stopPolling]); - - const refresh = useCallback(() => { - if (!enabled) return; - setLoading(true); - doFetch(); - }, [enabled, doFetch]); - - return { session, loading, error, refresh }; -} diff --git a/packages/mobile/src/hooks/useSessionNotifications.ts b/packages/mobile/src/hooks/useSessionNotifications.ts deleted file mode 100644 index 29aea2956..000000000 --- a/packages/mobile/src/hooks/useSessionNotifications.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect, useRef } from "react"; -import { AppState } from "react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { getAttentionLevel, type AttentionLevel, type DashboardSession } from "../types"; -import { scheduleNotification } from "../notifications"; -import { NOTIFY_STATE_KEY } from "../notifications/backgroundTask"; - -/** - * Minimum time (ms) before re-notifying for the same session. - * Prevents spam when an agent oscillates between working <-> waiting_input. - */ -const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes - -export function useSessionNotifications(sessions: DashboardSession[]): void { - const prevLevels = useRef>({}); - // Tracks when we last sent a notification per session - const lastNotifiedAt = useRef>({}); - // Skip the very first render — don't notify for state that existed before app opened - const isFirstRender = useRef(true); - - useEffect(() => { - if (sessions.length === 0) return; - - const nextLevels: Record = {}; - const now = Date.now(); - // Only notify when app is in background — no point interrupting the user - // if they're actively looking at the session list - const isBackground = AppState.currentState !== "active"; - - for (const session of sessions) { - const level = getAttentionLevel(session); - nextLevels[session.id] = level; - - if (!isFirstRender.current) { - const prev = prevLevels.current[session.id]; - const lastNotified = lastNotifiedAt.current[session.id] ?? 0; - const cooldownExpired = now - lastNotified > COOLDOWN_MS; - - if (level === "respond" && (prev !== "respond" || cooldownExpired)) { - if (isBackground || prev !== "respond") { - void scheduleNotification(session, "respond").catch(() => {}); - lastNotifiedAt.current[session.id] = now; - } - } else if (level === "merge" && (prev !== "merge" || cooldownExpired)) { - if (isBackground || prev !== "merge") { - void scheduleNotification(session, "merge").catch(() => {}); - lastNotifiedAt.current[session.id] = now; - } - } else if (level === "review" && (prev !== "review" || cooldownExpired)) { - if (isBackground || prev !== "review") { - void scheduleNotification(session, "review").catch(() => {}); - lastNotifiedAt.current[session.id] = now; - } - } - } - } - - prevLevels.current = nextLevels; - isFirstRender.current = false; - - void AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextLevels)); - }, [sessions]); -} diff --git a/packages/mobile/src/hooks/useSessions.ts b/packages/mobile/src/hooks/useSessions.ts deleted file mode 100644 index e7891ecdc..000000000 --- a/packages/mobile/src/hooks/useSessions.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { AppState, type AppStateStatus } from "react-native"; -import { useBackend } from "../context/BackendContext"; -import type { DashboardSession, DashboardStats } from "../types"; - -const POLL_INTERVAL = 5_000; - -interface UseSessionsResult { - sessions: DashboardSession[]; - stats: DashboardStats | null; - orchestratorId: string | null; - loading: boolean; - error: string | null; - refresh: () => void; -} - -export function useSessions(): UseSessionsResult { - const { fetchSessions } = useBackend(); - const [sessions, setSessions] = useState([]); - const [stats, setStats] = useState(null); - const [orchestratorId, setOrchestratorId] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const intervalRef = useRef | null>(null); - // Generation counter — incremented on cleanup to invalidate in-flight fetches - // from a previous effect run (e.g. when backend URL changes). - const fetchGenRef = useRef(0); - - const doFetch = useCallback(async () => { - const gen = fetchGenRef.current; - try { - const data = await fetchSessions(); - if (gen !== fetchGenRef.current) return; - setSessions(data.sessions ?? []); - setStats(data.stats ?? null); - setOrchestratorId(data.orchestratorId ?? null); - setError(null); - } catch (err) { - if (gen !== fetchGenRef.current) return; - setError(err instanceof Error ? err.message : "Failed to load sessions"); - } finally { - if (gen === fetchGenRef.current) setLoading(false); - } - }, [fetchSessions]); - - const startPolling = useCallback(() => { - doFetch(); - intervalRef.current = setInterval(doFetch, POLL_INTERVAL); - }, [doFetch]); - - const stopPolling = useCallback(() => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }, []); - - useEffect(() => { - startPolling(); - - const handleAppState = (nextState: AppStateStatus) => { - if (nextState === "active") { - // Resumed — refresh immediately and restart polling - stopPolling(); - startPolling(); - } else { - // Backgrounded — stop polling to save battery - stopPolling(); - } - }; - - const sub = AppState.addEventListener("change", handleAppState); - - return () => { - fetchGenRef.current++; // Invalidate in-flight fetches from this effect run - stopPolling(); - sub.remove(); - }; - }, [startPolling, stopPolling]); - - const refresh = useCallback(() => { - setLoading(true); - doFetch(); - }, [doFetch]); - - return { sessions, stats, orchestratorId, loading, error, refresh }; -} diff --git a/packages/mobile/src/navigation/RootNavigator.tsx b/packages/mobile/src/navigation/RootNavigator.tsx deleted file mode 100644 index 9939907e4..000000000 --- a/packages/mobile/src/navigation/RootNavigator.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from "react"; -import { NavigationContainer, DarkTheme, createNavigationContainerRef } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import HomeScreen from "../screens/HomeScreen"; -import SessionDetailScreen from "../screens/SessionDetailScreen"; -import TerminalScreen from "../screens/TerminalScreen"; -import SettingsScreen from "../screens/SettingsScreen"; -import SpawnSessionScreen from "../screens/SpawnSessionScreen"; -import OrchestratorScreen from "../screens/OrchestratorScreen"; -import CommandsScreen from "../screens/CommandsScreen"; - -export type RootStackParamList = { - Home: undefined; - SessionDetail: { sessionId: string }; - Terminal: { sessionId: string; terminalWsUrl: string }; - Settings: undefined; - SpawnSession: undefined; - Orchestrator: undefined; - Commands: undefined; -}; - -export const navigationRef = createNavigationContainerRef(); - -const Stack = createNativeStackNavigator(); - -const AoDarkTheme = { - ...DarkTheme, - colors: { - ...DarkTheme.colors, - background: "#0d1117", - card: "#161b22", - text: "#e6edf3", - border: "#30363d", - primary: "#58a6ff", - }, -}; - -export default function RootNavigator() { - return ( - - - - - - - - - - - - ); -} diff --git a/packages/mobile/src/notifications/backgroundTask.ts b/packages/mobile/src/notifications/backgroundTask.ts deleted file mode 100644 index 2e2132e60..000000000 --- a/packages/mobile/src/notifications/backgroundTask.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as TaskManager from "expo-task-manager"; -import * as BackgroundTask from "expo-background-task"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { getAttentionLevel, type AttentionLevel, type DashboardSession, type SessionsResponse } from "../types"; -import { scheduleNotification } from "./index"; - -export const TASK_ID = "ao-session-check"; -const BACKEND_URL_KEY = "@ao_backend_url"; -export const NOTIFY_STATE_KEY = "@ao_notify_state"; -const NOTIFY_TIMESTAMPS_KEY = "@ao_notify_timestamps"; -const COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes - -/** Define the background task globally — must be at module top level. */ -TaskManager.defineTask(TASK_ID, async () => { - try { - const backendUrl = await AsyncStorage.getItem(BACKEND_URL_KEY); - if (!backendUrl) return BackgroundTask.BackgroundTaskResult.Success; - - const res = await fetch(`${backendUrl}/api/sessions`, { - headers: { "Content-Type": "application/json" }, - }); - if (!res.ok) return BackgroundTask.BackgroundTaskResult.Failed; - - const data = (await res.json()) as SessionsResponse; - const sessions: DashboardSession[] = data.sessions ?? []; - - const [prevRaw, tsRaw] = await Promise.all([ - AsyncStorage.getItem(NOTIFY_STATE_KEY), - AsyncStorage.getItem(NOTIFY_TIMESTAMPS_KEY), - ]); - const prevState: Record = prevRaw - ? (JSON.parse(prevRaw) as Record) - : {}; - const timestamps: Record = tsRaw - ? (JSON.parse(tsRaw) as Record) - : {}; - - const nextState: Record = {}; - const now = Date.now(); - - for (const session of sessions) { - const level = getAttentionLevel(session); - nextState[session.id] = level; - - const prev = prevState[session.id]; - const lastNotified = timestamps[session.id] ?? 0; - const cooldownExpired = now - lastNotified > COOLDOWN_MS; - - if (level === "respond" && (prev !== "respond" || cooldownExpired)) { - await scheduleNotification(session, "respond"); - timestamps[session.id] = now; - } else if (level === "merge" && (prev !== "merge" || cooldownExpired)) { - await scheduleNotification(session, "merge"); - timestamps[session.id] = now; - } else if (level === "review" && (prev !== "review" || cooldownExpired)) { - await scheduleNotification(session, "review"); - timestamps[session.id] = now; - } - } - - await Promise.all([ - AsyncStorage.setItem(NOTIFY_STATE_KEY, JSON.stringify(nextState)), - AsyncStorage.setItem(NOTIFY_TIMESTAMPS_KEY, JSON.stringify(timestamps)), - ]); - return BackgroundTask.BackgroundTaskResult.Success; - } catch { - return BackgroundTask.BackgroundTaskResult.Failed; - } -}); - -/** Register the background task. Safe to call multiple times. */ -export async function registerBackgroundTask(): Promise { - try { - const isRegistered = await TaskManager.isTaskRegisteredAsync(TASK_ID); - if (isRegistered) return; - - await BackgroundTask.registerTaskAsync(TASK_ID, { - minimumInterval: 15 * 60, // 15 minutes (OS minimum) - }); - } catch { - // Background tasks are not supported in Expo Go — ignore silently - } -} diff --git a/packages/mobile/src/notifications/index.ts b/packages/mobile/src/notifications/index.ts deleted file mode 100644 index 9808399ad..000000000 --- a/packages/mobile/src/notifications/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as Notifications from "expo-notifications"; -import { Platform } from "react-native"; -import type { DashboardSession } from "../types"; - -const ANDROID_CHANNEL_ID = "ao-respond"; - -/** Configure how notifications are presented when the app is in foreground */ -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: false, - shouldShowBanner: true, - shouldShowList: true, - }), -}); - -/** Create Android channel + request permission. Call once on app startup. */ -export async function setupNotifications(): Promise { - // Create Android notification channel - if (Platform.OS === "android") { - await Notifications.setNotificationChannelAsync(ANDROID_CHANNEL_ID, { - name: "Agent Input Required", - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 250, 250, 250], - lightColor: "#f85149", - }); - } - - const { status } = await Notifications.requestPermissionsAsync({ - ios: { - allowAlert: true, - allowBadge: false, - allowSound: true, - }, - }); - - return status === "granted"; -} - -/** Fire an immediate local notification for a session attention transition. */ -export async function scheduleNotification( - session: DashboardSession, - level: "respond" | "merge" | "review", -): Promise { - const sessionLabel = - session.issueLabel ?? - session.id; - - const body = - session.issueTitle ?? - (session.summary && !session.summaryIsFallback ? session.summary : null) ?? - session.activity ?? - session.status; - - const titles: Record = { - respond: "Agent needs your input", - merge: "PR ready to merge", - review: "Session needs review", - }; - - const bodies: Record = { - respond: `${sessionLabel}: ${body}`, - merge: `${sessionLabel}${session.pr ? `: PR #${session.pr.number}` : ""}`, - review: `${sessionLabel}: ${session.pr?.ciStatus === "failing" ? "CI failing" : session.pr?.reviewDecision === "changes_requested" ? "Changes requested" : body}`, - }; - - const content: Notifications.NotificationContentInput = { - title: titles[level], - body: bodies[level], - data: { sessionId: session.id }, - sound: true, - ...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }), - }; - - // Use timeInterval trigger instead of null — trigger: null fails silently on Android in background tasks. - // SDK 53 requires explicit `type` field on trigger objects. - await Notifications.scheduleNotificationAsync({ - content, - trigger: { - type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, - seconds: 1, - channelId: Platform.OS === "android" ? ANDROID_CHANNEL_ID : undefined, - }, - }); -} diff --git a/packages/mobile/src/screens/CommandsScreen.tsx b/packages/mobile/src/screens/CommandsScreen.tsx deleted file mode 100644 index b584c78d6..000000000 --- a/packages/mobile/src/screens/CommandsScreen.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from "react"; -import { View, Text, StyleSheet, ScrollView } from "react-native"; - -const CLI_COMMANDS = [ - { cmd: "ao start [project]", desc: "Start orchestrator + dashboard" }, - { cmd: "ao stop [project]", desc: "Stop orchestrator + dashboard" }, - { cmd: "ao spawn [issue]", desc: "Spawn a session for an issue" }, - { cmd: "ao batch-spawn ", desc: "Spawn multiple sessions" }, - { cmd: "ao session ls [-p ]", desc: "List all active sessions" }, - { cmd: "ao session kill ", desc: "Kill a session" }, - { cmd: "ao session restore ", desc: "Restore a crashed session" }, - { cmd: "ao session cleanup [-p ]", desc: "Clean up merged/closed sessions" }, - { cmd: "ao send [message]", desc: "Send message to a session" }, - { cmd: "ao status [-p ]", desc: "Show sessions with PR/CI status" }, - { cmd: "ao review-check [project]", desc: "Check PRs and trigger agents" }, - { cmd: "ao dashboard [-p ]", desc: "Start the web dashboard" }, - { cmd: "ao open [target]", desc: "Open session(s) in terminal" }, - { cmd: "ao init [project]", desc: "Initialize config file" }, -]; - -export default function CommandsScreen() { - return ( - - - Quick reference for managing sessions from terminal. - {CLI_COMMANDS.map((c, i) => ( - - {c.cmd} - {c.desc} - - ))} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#0d1117", - }, - content: { - padding: 14, - paddingBottom: 32, - }, - section: { - backgroundColor: "#161b22", - borderRadius: 10, - padding: 16, - }, - hint: { - color: "#8b949e", - fontSize: 13, - lineHeight: 18, - marginBottom: 12, - }, - cmdRow: { - backgroundColor: "#0d1117", - borderRadius: 6, - padding: 10, - marginBottom: 6, - borderWidth: 1, - borderColor: "#30363d", - }, - cmdText: { - color: "#58a6ff", - fontSize: 12, - fontFamily: "monospace", - marginBottom: 4, - }, - cmdDesc: { - color: "#8b949e", - fontSize: 12, - }, -}); diff --git a/packages/mobile/src/screens/HomeScreen.tsx b/packages/mobile/src/screens/HomeScreen.tsx deleted file mode 100644 index d1015d14f..000000000 --- a/packages/mobile/src/screens/HomeScreen.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React from "react"; -import { - View, - Text, - FlatList, - StyleSheet, - RefreshControl, - TouchableOpacity, - ActivityIndicator, -} from "react-native"; -import type { NativeStackScreenProps } from "@react-navigation/native-stack"; -import type { RootStackParamList } from "../navigation/RootNavigator"; -import { useSessions } from "../hooks/useSessions"; -import { useSessionNotifications } from "../hooks/useSessionNotifications"; -import SessionCard from "../components/SessionCard"; -import StatBar from "../components/StatBar"; -import { getAttentionLevel, type DashboardSession } from "../types"; - -type Props = NativeStackScreenProps; - -const ATTENTION_ORDER = ["respond", "merge", "review", "pending", "working", "done"] as const; - -function sortSessions(sessions: DashboardSession[]): DashboardSession[] { - return [...sessions].sort((a, b) => { - const la = getAttentionLevel(a); - const lb = getAttentionLevel(b); - const ia = ATTENTION_ORDER.indexOf(la); - const ib = ATTENTION_ORDER.indexOf(lb); - if (ia !== ib) return ia - ib; - return new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(); - }); -} - -export default function HomeScreen({ navigation }: Props) { - const { sessions, stats, loading, error, refresh } = useSessions(); - useSessionNotifications(sessions); - - React.useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => ( - - navigation.navigate("SpawnSession")} - style={{ flexDirection: "row", alignItems: "center", gap: 2 }} - > - + - Session - - navigation.navigate("Orchestrator")}> - {"\uD83E\uDD16"} - - navigation.navigate("Settings")} - style={{ paddingRight: 4 }} - > - {"\u2699\uFE0F"} - - - ), - }); - }, [navigation]); - - const sorted = sortSessions(sessions); - - if (loading && sessions.length === 0) { - return ( - - - Connecting... - - ); - } - - if (error && sessions.length === 0) { - return ( - - {error} - - Retry - - navigation.navigate("Settings")} - > - Configure Backend URL - - - ); - } - - return ( - - {stats && } - item.id} - renderItem={({ item }) => ( - navigation.navigate("SessionDetail", { sessionId: item.id })} - /> - )} - refreshControl={ - - } - contentContainerStyle={ - sorted.length === 0 ? styles.emptyContainer : styles.listContent - } - ListEmptyComponent={ - - No sessions - Sessions will appear here when agents are running - - } - /> - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#0d1117", - }, - center: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: 24, - }, - emptyContainer: { - flex: 1, - }, - listContent: { - paddingVertical: 8, - }, - loadingText: { - color: "#8b949e", - marginTop: 12, - fontSize: 14, - }, - errorText: { - color: "#f85149", - fontSize: 14, - textAlign: "center", - marginBottom: 16, - }, - retryButton: { - backgroundColor: "#21262d", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 6, - paddingHorizontal: 16, - paddingVertical: 8, - }, - retryText: { - color: "#e6edf3", - fontSize: 14, - }, - emptyText: { - color: "#e6edf3", - fontSize: 16, - fontWeight: "600", - marginBottom: 8, - }, - emptySubtext: { - color: "#8b949e", - fontSize: 13, - textAlign: "center", - }, -}); diff --git a/packages/mobile/src/screens/OrchestratorScreen.tsx b/packages/mobile/src/screens/OrchestratorScreen.tsx deleted file mode 100644 index 8d386a221..000000000 --- a/packages/mobile/src/screens/OrchestratorScreen.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import React, { useState, useCallback } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - ActivityIndicator, - TextInput, - Alert, -} from "react-native"; -import type { NativeStackScreenProps } from "@react-navigation/native-stack"; -import type { RootStackParamList } from "../navigation/RootNavigator"; -import { useSessions } from "../hooks/useSessions"; -import { useSession } from "../hooks/useSession"; -import { useBackend } from "../context/BackendContext"; -import AttentionBadge from "../components/AttentionBadge"; -import { - getAttentionLevel, - relativeTime, - ATTENTION_COLORS, - type DashboardSession, -} from "../types"; - -type Props = NativeStackScreenProps; - -function getZoneCounts(sessions: DashboardSession[]) { - const counts = { merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }; - for (const s of sessions) { - const level = getAttentionLevel(s); - counts[level]++; - } - return counts; -} - -export default function OrchestratorScreen({ navigation }: Props) { - const { sessions, orchestratorId, loading, error, refresh } = useSessions(); - const { sendMessage, terminalWsUrl } = useBackend(); - const { session: orchSession } = useSession(orchestratorId ?? "", { enabled: !!orchestratorId }); - const [message, setMessage] = useState(""); - const [sending, setSending] = useState(false); - - React.useLayoutEffect(() => { - navigation.setOptions({ - headerRight: () => ( - navigation.navigate("Commands")}> - Commands - - ), - }); - }, [navigation]); - - const zones = getZoneCounts(sessions); - - const handleSend = useCallback(async () => { - if (!message.trim() || !orchestratorId) return; - setSending(true); - try { - await sendMessage(orchestratorId, message.trim()); - setMessage(""); - } catch (err) { - Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message"); - } finally { - setSending(false); - } - }, [message, sendMessage, orchestratorId]); - - if (loading && sessions.length === 0) { - return ( - - - - ); - } - - if (error && sessions.length === 0) { - return ( - - {error} - - Retry - - - ); - } - - const orchLevel = orchSession ? getAttentionLevel(orchSession) : null; - const orchColor = orchLevel ? ATTENTION_COLORS[orchLevel] : "#8b949e"; - - return ( - - {/* Orchestrator Session Details */} - - Orchestrator - {orchestratorId && orchSession ? ( - - - - - Running - - - - {orchestratorId} - - {orchSession.status} - {orchSession.activity ? ` · ${orchSession.activity}` : ""} - - {orchSession.summary && !orchSession.summaryIsFallback && ( - {orchSession.summary} - )} - - Last activity: {relativeTime(orchSession.lastActivityAt)} - - - {/* Actions */} - - navigation.navigate("Terminal", { sessionId: orchestratorId, terminalWsUrl })} - > - Open Terminal - - - - {/* Send message */} - - - - {sending ? ( - - ) : ( - Send - )} - - - - ) : ( - - - - Not running - - Start with: ao start <project> - - )} - - - {/* Zone Overview */} - - Session Zones - - - - - - - - - - - - ); -} - -function ZoneBadge({ label, count, color }: { label: string; count: number; color: string }) { - return ( - - {count} - {label} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#0d1117", - }, - content: { - padding: 14, - paddingBottom: 32, - gap: 12, - }, - center: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: 24, - backgroundColor: "#0d1117", - }, - section: { - backgroundColor: "#161b22", - borderRadius: 10, - padding: 16, - }, - sectionTitle: { - color: "#8b949e", - fontSize: 11, - fontWeight: "700", - letterSpacing: 0.8, - textTransform: "uppercase", - marginBottom: 12, - }, - // Orchestrator detail card - orchDetailCard: { - backgroundColor: "#0d1117", - borderWidth: 1, - borderColor: "#30363d", - borderLeftWidth: 3, - borderRadius: 8, - padding: 14, - }, - orchHeaderRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 6, - }, - orchStatusRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - dot: { - width: 10, - height: 10, - borderRadius: 5, - }, - orchRunning: { - color: "#3fb950", - fontSize: 15, - fontWeight: "600", - }, - orchId: { - color: "#8b949e", - fontSize: 12, - fontFamily: "monospace", - marginBottom: 4, - }, - orchStatus: { - color: "#8b949e", - fontSize: 13, - marginBottom: 4, - }, - orchSummary: { - color: "#e6edf3", - fontSize: 13, - lineHeight: 18, - marginBottom: 4, - }, - orchTimingRow: { - marginBottom: 10, - }, - orchTiming: { - color: "#6e7681", - fontSize: 12, - }, - orchHint: { - color: "#6e7681", - fontSize: 12, - marginTop: 4, - }, - orchActions: { - marginBottom: 10, - }, - terminalButton: { - backgroundColor: "#21262d", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 8, - padding: 12, - alignItems: "center", - }, - terminalButtonText: { - color: "#e6edf3", - fontSize: 14, - fontWeight: "600", - }, - orchMessageRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - orchMessageInput: { - flex: 1, - backgroundColor: "#161b22", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 8, - color: "#e6edf3", - fontSize: 14, - }, - sendButton: { - backgroundColor: "#238636", - borderRadius: 8, - paddingHorizontal: 14, - height: 38, - alignItems: "center", - justifyContent: "center", - }, - sendButtonDisabled: { - backgroundColor: "#21262d", - }, - sendButtonText: { - color: "#fff", - fontSize: 14, - fontWeight: "700", - }, - // Zones grid - zonesGrid: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - }, - zoneBadge: { - backgroundColor: "#0d1117", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 8, - paddingVertical: 10, - paddingHorizontal: 14, - alignItems: "center", - minWidth: 90, - flex: 1, - }, - zoneCount: { - fontSize: 22, - fontWeight: "700", - }, - zoneLabel: { - color: "#8b949e", - fontSize: 11, - fontWeight: "600", - marginTop: 2, - }, - // Error - errorText: { - color: "#f85149", - fontSize: 14, - textAlign: "center", - marginBottom: 16, - }, - retryButton: { - backgroundColor: "#21262d", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 6, - paddingHorizontal: 16, - paddingVertical: 8, - }, - retryText: { - color: "#e6edf3", - fontSize: 14, - }, -}); diff --git a/packages/mobile/src/screens/SessionDetailScreen.tsx b/packages/mobile/src/screens/SessionDetailScreen.tsx deleted file mode 100644 index 77a3e9bc7..000000000 --- a/packages/mobile/src/screens/SessionDetailScreen.tsx +++ /dev/null @@ -1,741 +0,0 @@ -import React, { useState, useCallback, useRef, useEffect } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TextInput, - TouchableOpacity, - Alert, - ActivityIndicator, - Keyboard, - Platform, - Linking, -} from "react-native"; -import type { NativeStackScreenProps } from "@react-navigation/native-stack"; -import type { RootStackParamList } from "../navigation/RootNavigator"; -import { useSession } from "../hooks/useSession"; -import { useBackend } from "../context/BackendContext"; -import AttentionBadge from "../components/AttentionBadge"; -import { - getAttentionLevel, - relativeTime, - isRestorable, - isTerminal, - isPRRateLimited, - ATTENTION_COLORS, - type DashboardCICheck, - type DashboardUnresolvedComment, - type DashboardPR, -} from "../types"; - -type Props = NativeStackScreenProps; - -type ActionButtonState = "idle" | "sending" | "sent" | "error"; - -export default function SessionDetailScreen({ route, navigation }: Props) { - const { sessionId } = route.params; - const { session, loading, error, refresh } = useSession(sessionId); - const { sendMessage, killSession, restoreSession, mergePR, terminalWsUrl } = useBackend(); - const [message, setMessage] = useState(""); - const [sending, setSending] = useState(false); - const [merging, setMerging] = useState(false); - const [ciFixState, setCiFixState] = useState("idle"); - const [commentFixStates, setCommentFixStates] = useState>({}); - const scrollRef = useRef(null); - const [keyboardHeight, setKeyboardHeight] = useState(0); - - useEffect(() => { - const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow"; - const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; - - const showSub = Keyboard.addListener(showEvent, (e) => { - setKeyboardHeight(e.endCoordinates.height); - }); - const hideSub = Keyboard.addListener(hideEvent, () => { - setKeyboardHeight(0); - }); - - return () => { - showSub.remove(); - hideSub.remove(); - }; - }, []); - - const handleSend = useCallback(async () => { - if (!message.trim()) return; - setSending(true); - try { - await sendMessage(sessionId, message.trim()); - setMessage(""); - } catch (err) { - Alert.alert("Error", err instanceof Error ? err.message : "Failed to send message"); - } finally { - setSending(false); - } - }, [message, sendMessage, sessionId]); - - const handleKill = useCallback(() => { - Alert.alert( - "Kill Session", - "This will terminate the agent process. Are you sure?", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Kill", - style: "destructive", - onPress: async () => { - try { - await killSession(sessionId); - refresh(); - } catch (err) { - Alert.alert("Error", err instanceof Error ? err.message : "Failed to kill session"); - } - }, - }, - ], - ); - }, [killSession, sessionId, refresh]); - - 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(() => { - navigation.navigate("Terminal", { sessionId, terminalWsUrl }); - }, [navigation, sessionId, terminalWsUrl]); - - const handleMergePR = useCallback(async (prNumber: number) => { - setMerging(true); - try { - await mergePR(prNumber); - Alert.alert("Merged", `PR #${prNumber} merged successfully.`); - refresh(); - } catch (err) { - Alert.alert("Merge failed", err instanceof Error ? err.message : "Failed to merge PR"); - } finally { - setMerging(false); - } - }, [mergePR, refresh]); - - const handleAskFixCI = useCallback(async (pr: DashboardPR) => { - setCiFixState("sending"); - try { - await sendMessage(sessionId, `Please fix the failing CI checks on ${pr.url}`); - setCiFixState("sent"); - setTimeout(() => setCiFixState("idle"), 3000); - } catch { - setCiFixState("error"); - setTimeout(() => setCiFixState("idle"), 3000); - } - }, [sendMessage, sessionId]); - - const handleAskFixComment = useCallback(async (comment: DashboardUnresolvedComment) => { - const key = comment.url; - setCommentFixStates((prev) => ({ ...prev, [key]: "sending" })); - try { - const msg = `Please address this review comment:\n\nFile: ${comment.path}\nComment: ${comment.body}\n\nComment URL: ${comment.url}\n\nAfter fixing, mark the comment as resolved at ${comment.url}`; - await sendMessage(sessionId, msg); - setCommentFixStates((prev) => ({ ...prev, [key]: "sent" })); - setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000); - } catch { - setCommentFixStates((prev) => ({ ...prev, [key]: "error" })); - setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000); - } - }, [sendMessage, sessionId]); - - if (loading && !session) { - return ( - - - - ); - } - - if (error && !session) { - return ( - - {error} - - Retry - - - ); - } - - if (!session) return null; - - const level = getAttentionLevel(session); - const color = ATTENTION_COLORS[level]; - const canRestore = isRestorable(session); - const isDone = isTerminal(session); - const pr = session.pr; - const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open" && !isPRRateLimited(pr); - const failedChecks = pr?.ciChecks.filter((c) => c.status === "failed") ?? []; - const unresolvedComments = pr?.unresolvedComments ?? []; - - return ( - - - {/* Header row */} - - - - {session.id} - - - - - {session.status} - {session.activity ? ` · ${session.activity}` : ""} - - - - {/* Alerts */} - {pr && pr.ciStatus === "failing" && failedChecks.length > 0 && ( - - - - {failedChecks.length} CI check{failedChecks.length > 1 ? "s" : ""} failing - - handleAskFixCI(pr)} - /> - - - )} - - {pr && !pr.mergeability.noConflicts && ( - - Merge conflict - - )} - - {pr && pr.reviewDecision === "changes_requested" && ( - - Changes requested - - )} - - {/* Issue */} - {(session.issueLabel || session.issueTitle) && ( -
- - {session.issueLabel ? `${session.issueLabel}: ` : ""} - {session.issueTitle ?? ""} - -
- )} - - {/* Summary */} - {session.summary && !session.summaryIsFallback && ( -
- {session.summary} -
- )} - - {/* Branch */} - {session.branch && ( -
- {session.branch} -
- )} - - {/* PR */} - {pr && ( -
- Linking.openURL(pr.url)}> - - {pr.title} - - - - - - - {pr.isDraft && } - {pr.mergeability.blockers.length > 0 && ( - - Blockers: - {pr.mergeability.blockers.map((b, i) => ( - - {b} - ))} - - )} -
- )} - - {/* CI Checks */} - {pr && pr.ciChecks.length > 0 && ( -
- {pr.ciChecks.map((check, i) => ( - - ))} -
- )} - - {/* Unresolved Comments */} - {unresolvedComments.length > 0 && ( -
- {unresolvedComments.map((comment, i) => ( - - - {comment.author} - {comment.path} - - {comment.body} - - handleAskFixComment(comment)} - /> - Linking.openURL(comment.url)}> - View - - - - ))} -
- )} - - {/* Timestamps */} -
- - -
- - {/* Actions */} - - {isReadyToMerge && ( - handleMergePR(pr.number)} - disabled={merging} - > - {merging ? ( - - ) : ( - - Merge PR #{pr.number} - - )} - - )} - - - Open Terminal - - - {canRestore && ( - - Restore Session - - )} - - {!isDone && ( - - Kill Session - - )} - -
- - {/* Message input — only show for active sessions */} - {!isDone && ( - 0 ? keyboardHeight + 48 : 10 }]}> - - - {sending ? ( - - ) : ( - Send - )} - - - )} -
- ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( - - {title} - {children} - - ); -} - -function InfoRow({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) { - return ( - - {label} - {value} - - ); -} - -const CI_STATUS_ICONS: Record = { - passed: { icon: "\u2713", color: "#3fb950" }, - failed: { icon: "\u2717", color: "#f85149" }, - running: { icon: "\u25CF", color: "#e3b341" }, - pending: { icon: "\u25CF", color: "#8b949e" }, - skipped: { icon: "\u2014", color: "#6e7681" }, -}; - -function CICheckRow({ check }: { check: DashboardCICheck }) { - const info = CI_STATUS_ICONS[check.status] ?? CI_STATUS_ICONS.pending; - return ( - Linking.openURL(check.url!) : undefined} - disabled={!check.url} - > - {info.icon} - {check.name} - {check.status} - - ); -} - -function ActionButton({ state, label, onPress }: { state: ActionButtonState; label: string; onPress: () => void }) { - const isDisabled = state === "sending" || state === "sent"; - const bgColor = state === "sent" ? "#1f3a2a" : state === "error" ? "#3d1f20" : "#21262d"; - const borderColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#30363d"; - const textColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#58a6ff"; - const text = state === "sending" ? "Sending..." : state === "sent" ? "Sent" : state === "error" ? "Failed" : label; - - return ( - - {state === "sending" ? ( - - ) : ( - {text} - )} - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - backgroundColor: "#0d1117", - }, - container: { - flex: 1, - }, - content: { - padding: 12, - paddingBottom: 24, - }, - center: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: 24, - backgroundColor: "#0d1117", - }, - headerCard: { - backgroundColor: "#161b22", - borderLeftWidth: 3, - borderRadius: 8, - padding: 14, - marginBottom: 12, - }, - headerRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 6, - }, - sessionId: { - color: "#8b949e", - fontSize: 13, - fontFamily: "monospace", - flex: 1, - marginRight: 8, - }, - status: { - color: "#8b949e", - fontSize: 13, - }, - // Alerts - alertCard: { - backgroundColor: "#3d1f20", - borderLeftWidth: 3, - borderLeftColor: "#f85149", - borderRadius: 8, - padding: 12, - marginBottom: 10, - }, - alertRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - alertText: { - color: "#f85149", - fontSize: 13, - fontWeight: "600", - flex: 1, - }, - // Sections - section: { - backgroundColor: "#161b22", - borderRadius: 8, - padding: 14, - marginBottom: 10, - }, - sectionTitle: { - color: "#8b949e", - fontSize: 11, - fontWeight: "700", - letterSpacing: 0.8, - textTransform: "uppercase", - marginBottom: 8, - }, - bodyText: { - color: "#e6edf3", - fontSize: 14, - lineHeight: 20, - }, - issueText: { - color: "#e6edf3", - fontSize: 14, - fontWeight: "600", - }, - monoText: { - color: "#58a6ff", - fontSize: 13, - fontFamily: "monospace", - }, - infoRow: { - flexDirection: "row", - justifyContent: "space-between", - paddingVertical: 4, - }, - infoLabel: { - color: "#8b949e", - fontSize: 13, - }, - infoValue: { - color: "#e6edf3", - fontSize: 13, - fontWeight: "500", - }, - blockersLabel: { - color: "#f85149", - fontSize: 12, - fontWeight: "600", - marginBottom: 4, - }, - blockerText: { - color: "#f85149", - fontSize: 12, - marginLeft: 4, - marginBottom: 2, - }, - // CI Checks - ciCheckRow: { - flexDirection: "row", - alignItems: "center", - paddingVertical: 6, - gap: 8, - }, - ciCheckIcon: { - fontSize: 14, - fontWeight: "700", - width: 18, - textAlign: "center", - }, - ciCheckName: { - color: "#e6edf3", - fontSize: 13, - flex: 1, - }, - ciCheckStatus: { - fontSize: 11, - fontWeight: "600", - }, - // Unresolved Comments - commentCard: { - backgroundColor: "#0d1117", - borderRadius: 6, - padding: 10, - marginBottom: 8, - borderWidth: 1, - borderColor: "#30363d", - }, - commentHeader: { - flexDirection: "row", - justifyContent: "space-between", - marginBottom: 6, - }, - commentAuthor: { - color: "#e6edf3", - fontSize: 12, - fontWeight: "600", - }, - commentPath: { - color: "#8b949e", - fontSize: 11, - fontFamily: "monospace", - flex: 1, - textAlign: "right", - marginLeft: 8, - }, - commentBody: { - color: "#8b949e", - fontSize: 13, - lineHeight: 18, - marginBottom: 8, - }, - commentActions: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - viewLink: { - color: "#58a6ff", - fontSize: 13, - fontWeight: "600", - }, - // Inline action button (for "Ask to fix", etc.) - inlineActionButton: { - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 10, - paddingVertical: 6, - minWidth: 80, - alignItems: "center", - }, - inlineActionText: { - fontSize: 12, - fontWeight: "600", - }, - // Main actions - actionsSection: { - gap: 8, - marginTop: 4, - }, - actionButton: { - borderRadius: 8, - padding: 14, - alignItems: "center", - borderWidth: 1, - }, - mergeButton: { - backgroundColor: "#238636", - borderColor: "#2ea043", - }, - terminalButton: { - backgroundColor: "#21262d", - borderColor: "#30363d", - }, - restoreButton: { - backgroundColor: "#1f3a2a", - borderColor: "#3fb950", - }, - killButton: { - backgroundColor: "#21262d", - borderColor: "#30363d", - }, - actionButtonText: { - color: "#e6edf3", - fontSize: 15, - fontWeight: "600", - }, - messageBar: { - flexDirection: "row", - alignItems: "flex-end", - padding: 10, - backgroundColor: "#161b22", - borderTopWidth: 1, - borderTopColor: "#30363d", - gap: 8, - }, - messageInput: { - flex: 1, - backgroundColor: "#0d1117", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 8, - color: "#e6edf3", - fontSize: 14, - maxHeight: 100, - }, - sendButton: { - backgroundColor: "#238636", - borderRadius: 8, - paddingHorizontal: 16, - height: 40, - alignItems: "center", - justifyContent: "center", - }, - sendButtonDisabled: { - backgroundColor: "#21262d", - }, - sendButtonText: { - color: "#fff", - fontSize: 14, - fontWeight: "700", - }, - button: { - backgroundColor: "#21262d", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 6, - paddingHorizontal: 16, - paddingVertical: 8, - }, - buttonText: { - color: "#e6edf3", - fontSize: 14, - }, - errorText: { - color: "#f85149", - fontSize: 14, - textAlign: "center", - marginBottom: 16, - }, -}); diff --git a/packages/mobile/src/screens/SettingsScreen.tsx b/packages/mobile/src/screens/SettingsScreen.tsx deleted file mode 100644 index c3c8ade36..000000000 --- a/packages/mobile/src/screens/SettingsScreen.tsx +++ /dev/null @@ -1,443 +0,0 @@ -import React, { useState } from "react"; -import { - View, - Text, - TextInput, - TouchableOpacity, - StyleSheet, - ScrollView, - Alert, - KeyboardAvoidingView, - Platform, -} from "react-native"; -import type { NativeStackScreenProps } from "@react-navigation/native-stack"; -import type { RootStackParamList } from "../navigation/RootNavigator"; -import { useBackend } from "../context/BackendContext"; -import { scheduleNotification } from "../notifications"; -import * as Notifications from "expo-notifications"; - -type Props = NativeStackScreenProps; - -export default function SettingsScreen({ navigation }: Props) { - const { backendUrl, setBackendUrl, terminalWsUrl, terminalWsOverride, setTerminalWsOverride } = useBackend(); - const [input, setInput] = useState(backendUrl); - const [wsInput, setWsInput] = useState(terminalWsOverride); - const [saving, setSaving] = useState(false); - - const handleTestRespondNotification = async () => { - const { status } = await Notifications.getPermissionsAsync(); - if (status !== "granted") { - Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app."); - return; - } - try { - await scheduleNotification( - { - id: "ao-test-session", - projectId: "test", - status: "needs_input", - activity: "waiting_input", - branch: "feat/test", - issueId: null, - issueUrl: null, - issueLabel: "TEST-1", - issueTitle: "Fix the flaky integration test", - summary: "Waiting for your decision on the approach", - summaryIsFallback: false, - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString(), - pr: null, - metadata: {}, - }, - "respond", - ); - Alert.alert("Sent", "A test 'respond' notification was fired. Check your notification shade."); - } catch (err) { - Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification."); - } - }; - - const handleTestMergeNotification = async () => { - const { status } = await Notifications.getPermissionsAsync(); - if (status !== "granted") { - Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app."); - return; - } - try { - await scheduleNotification( - { - id: "ao-test-session", - projectId: "test", - status: "mergeable", - activity: "idle", - branch: "feat/test", - issueId: null, - issueUrl: null, - issueLabel: "TEST-1", - issueTitle: "Add user authentication flow", - summary: null, - summaryIsFallback: false, - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString(), - pr: { - number: 42, - url: "", - title: "Add auth flow", - owner: "", - repo: "", - branch: "feat/test", - baseBranch: "main", - isDraft: false, - state: "open", - additions: 120, - deletions: 8, - ciStatus: "passing", - ciChecks: [], - reviewDecision: "approved", - mergeability: { mergeable: true, ciPassing: true, approved: true, noConflicts: true, blockers: [] }, - unresolvedThreads: 0, - }, - metadata: {}, - }, - "merge", - ); - Alert.alert("Sent", "A test 'merge' notification was fired. Check your notification shade."); - } catch (err) { - Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification."); - } - }; - - const handleTestReviewNotification = async () => { - const { status } = await Notifications.getPermissionsAsync(); - if (status !== "granted") { - Alert.alert("Permission denied", "Notification permission is not granted. Enable it in your phone's Settings app."); - return; - } - try { - await scheduleNotification( - { - id: "ao-test-session", - projectId: "test", - status: "review_pending", - activity: "idle", - branch: "feat/test", - issueId: null, - issueUrl: null, - issueLabel: "TEST-1", - issueTitle: "Refactor database connection pool", - summary: "Changes requested on PR", - summaryIsFallback: false, - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString(), - pr: { - number: 99, - url: "", - title: "Refactor DB pool", - owner: "", - repo: "", - branch: "feat/test", - baseBranch: "main", - isDraft: false, - state: "open", - additions: 85, - deletions: 42, - ciStatus: "passing", - ciChecks: [], - reviewDecision: "changes_requested", - mergeability: { mergeable: true, ciPassing: true, approved: false, noConflicts: true, blockers: ["Changes requested"] }, - unresolvedThreads: 2, - }, - metadata: {}, - }, - "review", - ); - Alert.alert("Sent", "A test 'review' notification was fired. Check your notification shade."); - } catch (err) { - Alert.alert("Failed", err instanceof Error ? err.message : "Could not schedule notification."); - } - }; - - const handleSave = async () => { - const trimmed = input.trim(); - if (!trimmed) { - Alert.alert("Invalid URL", "Please enter a valid backend URL."); - return; - } - if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { - Alert.alert("Invalid URL", "URL must start with http:// or https://"); - return; - } - setSaving(true); - try { - await setBackendUrl(trimmed); - await setTerminalWsOverride(wsInput.trim()); - Alert.alert("Saved", "Settings updated.", [ - { text: "OK", onPress: () => navigation.goBack() }, - ]); - } finally { - setSaving(false); - } - }; - - return ( - - - - Backend URL - - Enter the URL where your AO dashboard is running. - - Dashboard API URL - - - Terminal WebSocket URL{" "} - (leave blank to auto-derive) - - - - {saving ? "Saving..." : "Save"} - - - - - Active URLs - - - - - {__DEV__ && ( - - Test Notifications - Fire a test notification to verify permissions are working. Tap the notification to navigate to the session. - - Test "Agent needs input" (respond) - - - Test "PR ready to merge" (merge) - - - Test "Session needs review" (review) - - - )} - - - Setup Guide — Tailscale (Recommended) - - - - - - Alternative: Local Wi-Fi - - - - Alternative: ngrok - - - - - - - ); -} - -function SettingsInfoRow({ label, value, note }: { label: string; value: string; note?: string }) { - return ( - - - {label} - {note ? {note} : null} - - - {value} - - - ); -} - -function Step({ n, text }: { n: string; text: string }) { - return ( - - - {n} - - {text} - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - backgroundColor: "#0d1117", - }, - container: { - flex: 1, - }, - content: { - padding: 14, - paddingBottom: 32, - gap: 12, - }, - section: { - backgroundColor: "#161b22", - borderRadius: 10, - padding: 16, - }, - sectionTitle: { - color: "#8b949e", - fontSize: 11, - fontWeight: "700", - letterSpacing: 0.8, - textTransform: "uppercase", - marginBottom: 12, - }, - sectionDivider: { - color: "#8b949e", - fontSize: 11, - fontWeight: "700", - letterSpacing: 0.8, - textTransform: "uppercase", - marginTop: 12, - marginBottom: 12, - paddingTop: 12, - borderTopWidth: 1, - borderTopColor: "#30363d", - }, - hint: { - color: "#8b949e", - fontSize: 13, - lineHeight: 18, - marginBottom: 12, - }, - input: { - backgroundColor: "#0d1117", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - color: "#e6edf3", - fontSize: 14, - fontFamily: "monospace", - marginBottom: 12, - }, - saveButton: { - backgroundColor: "#238636", - borderRadius: 8, - paddingVertical: 12, - alignItems: "center", - }, - saveButtonDisabled: { - backgroundColor: "#21262d", - }, - saveButtonText: { - color: "#fff", - fontSize: 15, - fontWeight: "600", - }, - testButton: { - borderWidth: 1, - borderRadius: 8, - paddingVertical: 12, - alignItems: "center", - }, - testButtonText: { - fontSize: 14, - fontWeight: "600", - }, - infoRow: { - flexDirection: "row", - justifyContent: "space-between", - paddingVertical: 6, - }, - infoLabel: { - color: "#8b949e", - fontSize: 13, - width: 100, - }, - infoValue: { - color: "#58a6ff", - fontSize: 13, - fontFamily: "monospace", - flex: 1, - textAlign: "right", - }, - infoLabelRow: { - flexDirection: "column", - width: 100, - }, - infoNote: { - color: "#8b949e", - fontSize: 10, - marginTop: 1, - }, - fieldLabel: { - color: "#8b949e", - fontSize: 12, - fontWeight: "600", - marginBottom: 6, - }, - fieldLabelMuted: { - color: "#6e7681", - fontWeight: "400", - }, - step: { - flexDirection: "row", - alignItems: "flex-start", - marginBottom: 12, - gap: 10, - }, - stepBadge: { - backgroundColor: "#21262d", - borderRadius: 10, - width: 20, - height: 20, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - stepN: { - color: "#58a6ff", - fontSize: 11, - fontWeight: "700", - }, - stepText: { - color: "#8b949e", - fontSize: 13, - lineHeight: 18, - flex: 1, - }, -}); diff --git a/packages/mobile/src/screens/SpawnSessionScreen.tsx b/packages/mobile/src/screens/SpawnSessionScreen.tsx deleted file mode 100644 index 0d52a696c..000000000 --- a/packages/mobile/src/screens/SpawnSessionScreen.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import React, { useState, useCallback } from "react"; -import { - View, - Text, - TextInput, - TouchableOpacity, - StyleSheet, - Alert, - KeyboardAvoidingView, - Platform, - ScrollView, -} from "react-native"; -import type { NativeStackScreenProps } from "@react-navigation/native-stack"; -import type { RootStackParamList } from "../navigation/RootNavigator"; -import { useBackend } from "../context/BackendContext"; - -type Props = NativeStackScreenProps; - -export default function SpawnSessionScreen({ navigation }: Props) { - const { spawnSession } = useBackend(); - const [projectId, setProjectId] = useState(""); - const [issueId, setIssueId] = useState(""); - const [spawning, setSpawning] = useState(false); - - const handleSpawn = useCallback(async () => { - const trimmedProject = projectId.trim(); - if (!trimmedProject) { - Alert.alert("Invalid", "Project ID is required."); - return; - } - if (!/^[a-zA-Z0-9_-]+$/.test(trimmedProject)) { - Alert.alert("Invalid", "Project ID must be alphanumeric (hyphens and underscores allowed)."); - return; - } - const trimmedIssue = issueId.trim(); - if (trimmedIssue && !/^[a-zA-Z0-9_-]+$/.test(trimmedIssue)) { - Alert.alert("Invalid", "Issue ID must be alphanumeric (hyphens and underscores allowed)."); - return; - } - - setSpawning(true); - try { - const session = await spawnSession(trimmedProject, trimmedIssue || undefined); - navigation.replace("SessionDetail", { sessionId: session.id }); - } catch (err) { - Alert.alert("Spawn failed", err instanceof Error ? err.message : "Failed to spawn session"); - } finally { - setSpawning(false); - } - }, [projectId, issueId, spawnSession, navigation]); - - return ( - - - - Spawn New Session - - Create a new agent session. The orchestrator will assign an agent and workspace. - - - Project ID * - - - - Issue ID{" "} - (optional) - - - - - - {spawning ? "Spawning..." : "Spawn Session"} - - - - - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - backgroundColor: "#0d1117", - }, - container: { - flex: 1, - }, - content: { - padding: 14, - }, - section: { - backgroundColor: "#161b22", - borderRadius: 10, - padding: 16, - }, - sectionTitle: { - color: "#8b949e", - fontSize: 11, - fontWeight: "700", - letterSpacing: 0.8, - textTransform: "uppercase", - marginBottom: 12, - }, - hint: { - color: "#8b949e", - fontSize: 13, - lineHeight: 18, - marginBottom: 16, - }, - fieldLabel: { - color: "#8b949e", - fontSize: 12, - fontWeight: "600", - marginBottom: 6, - }, - fieldLabelMuted: { - color: "#6e7681", - fontWeight: "400", - }, - input: { - backgroundColor: "#0d1117", - borderWidth: 1, - borderColor: "#30363d", - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - color: "#e6edf3", - fontSize: 14, - fontFamily: "monospace", - marginBottom: 14, - }, - spawnButton: { - backgroundColor: "#238636", - borderRadius: 8, - paddingVertical: 14, - alignItems: "center", - marginTop: 4, - }, - spawnButtonDisabled: { - backgroundColor: "#21262d", - }, - spawnButtonText: { - color: "#fff", - fontSize: 15, - fontWeight: "600", - }, -}); diff --git a/packages/mobile/src/screens/TerminalScreen.tsx b/packages/mobile/src/screens/TerminalScreen.tsx deleted file mode 100644 index 88d46e0b9..000000000 --- a/packages/mobile/src/screens/TerminalScreen.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { useRef, useCallback, useState } from "react"; -import { View, StyleSheet, StatusBar, Text } from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import type { NativeStackScreenProps } from "@react-navigation/native-stack"; -import type { RootStackParamList } from "../navigation/RootNavigator"; - -import { TERMINAL_HTML } from "../terminal/terminal-html"; - -type Props = NativeStackScreenProps; - -type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected"; - -const STATUS_COLORS: Record = { - connecting: "#e3b341", - connected: "#3fb950", - error: "#f85149", - disconnected: "#f85149", -}; - -export default function TerminalScreen({ route }: Props) { - const { sessionId, terminalWsUrl } = route.params; - const webViewRef = useRef(null); - const insets = useSafeAreaInsets(); - const [status, setStatus] = useState("connecting"); - - // Inject WS URL and session ID BEFORE xterm.js init runs - const injectedJS = ` - window.AO_WS_URL = ${JSON.stringify(terminalWsUrl)}; - window.AO_SESSION_ID = ${JSON.stringify(sessionId)}; - true; // required return value - `; - - const handleMessage = useCallback((event: WebViewMessageEvent) => { - try { - const msg = JSON.parse(event.nativeEvent.data) as { - type: string; - state?: ConnectionStatus; - }; - if (msg.type === "status" && msg.state) { - setStatus(msg.state); - } - } catch { - // ignore unparseable messages - } - }, []); - - const handleLoad = useCallback(() => { - // Ask xterm to fit to current viewport - webViewRef.current?.injectJavaScript( - 'window.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ type: "fit" }) })); true;', - ); - }, []); - - const dotColor = STATUS_COLORS[status]; - - return ( - - - - {/* Status indicator in top-right */} - - - - {status} - - - - {}} - /> - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#0d1117", - }, - webView: { - flex: 1, - backgroundColor: "#0d1117", - }, - statusDot: { - position: "absolute", - top: 8, - right: 12, - flexDirection: "row", - alignItems: "center", - zIndex: 10, - gap: 4, - }, - dot: { - width: 8, - height: 8, - borderRadius: 4, - }, - statusText: { - fontSize: 11, - fontWeight: "600", - }, -}); diff --git a/packages/mobile/src/terminal/terminal-html.ts b/packages/mobile/src/terminal/terminal-html.ts deleted file mode 100644 index 62f162462..000000000 --- a/packages/mobile/src/terminal/terminal-html.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Standalone xterm.js terminal page, loaded into WebView. - * WS URL and session ID are injected via injectedJavaScriptBeforeContentLoaded - * which sets window.AO_WS_URL and window.AO_SESSION_ID before the script runs. - */ -export const TERMINAL_HTML = ` - - - - -Terminal -