Merge remote-tracking branch 'origin/main' into ashish921998/design-sync-pr

This commit is contained in:
Ashish Huddar 2026-04-01 18:52:11 +05:30
commit a4e8c7f360
133 changed files with 9304 additions and 4147 deletions

34
.eslintignore Normal file
View File

@ -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

98
.github/workflows/coverage.yml vendored Normal file
View File

@ -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='...'

57
.github/workflows/deploy-vps.yml vendored Normal file
View File

@ -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)"

4
.gitignore vendored
View File

@ -64,3 +64,7 @@ CLAUDE.md
AGENTS.md
.claude/
.opencode/
# OS-specific files
.DS_Store
Thumbs.db

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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:

View File

@ -10,6 +10,7 @@ ao start <url> # 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.

114
docs/PLUGIN_SPEC.md Normal file
View File

@ -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<T = unknown> {
manifest: PluginManifest;
create(config?: Record<string, unknown>): 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
AOs 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.

View File

@ -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<Map<string, PREnrichmentData>>;
}
```
### 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, PREnrichmentData>
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<Map<string, PREnrichmentData>> {
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

View File

@ -0,0 +1,573 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Runtime Terminal Port and Project-ID Hardening</title>
<style>
:root {
--bg: #0b1020;
--panel: #111a33;
--text: #e5ecff;
--muted: #9fb0df;
--accent: #71a2ff;
--border: #2a3d74;
--code: #0e1530;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(1200px 700px at 10% -20%, #223562 0%, var(--bg) 50%);
color: var(--text);
line-height: 1.55;
}
main {
max-width: 980px;
margin: 32px auto;
padding: 24px;
background: color-mix(in srgb, var(--panel) 92%, black 8%);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
h1, h2, h3 { margin-top: 1.25em; line-height: 1.25; }
h1 { margin-top: 0; font-size: 1.9rem; }
h2 {
font-size: 1.3rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.35rem;
}
h3 { font-size: 1.05rem; color: #c8d8ff; }
p, li { color: var(--text); }
.meta {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 10px 20px;
margin: 12px 0 22px;
color: var(--muted);
}
.meta strong { color: var(--text); }
ul, ol { padding-left: 1.2rem; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.95em;
background: var(--code);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.06rem 0.35rem;
color: #c6f0ff;
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.4rem 0;
}
.muted { color: var(--muted); }
</style>
</head>
<body>
<main>
<h1>Runtime Terminal Port and Project-ID Hardening</h1>
<div class="meta">
<div><strong>Status:</strong> Draft</div>
<div><strong>Author:</strong> Agent Orchestrator</div>
<div><strong>Date:</strong> 2026-03-28</div>
<div><strong>Target Merge:</strong> <code>main</code></div>
</div>
<hr />
<h2>Overview</h2>
<p>
This design documents two production issues that surfaced in first-run and npm-installed flows:
</p>
<ol>
<li>Direct terminal stuck on <code>CONNECTING...</code> when runtime ports differ from client bundle fallback.</li>
<li><code>/api/spawn</code> receiving a session ID as <code>projectId</code>, leading to deep-core <code>Unknown project</code> failures.</li>
</ol>
<p>
The implementation introduces runtime configuration discovery for terminal WebSocket connection and stricter semantic validation for project identifiers at API and page-data boundaries.
</p>
<h2>Problem Statement</h2>
<h3>Problem A: Terminal WebSocket Port Drift</h3>
<ul>
<li><code>ao start</code> can auto-select terminal ports at runtime (for example <code>14802/14803</code>) when defaults are occupied.</li>
<li>Direct terminal server listens on <code>DIRECT_TERMINAL_PORT</code> at runtime.</li>
<li>Browser client previously relied on build-time <code>NEXT_PUBLIC_DIRECT_TERMINAL_PORT</code>, with fallback <code>14801</code>.</li>
<li>In prebuilt Next.js client bundles, <code>NEXT_PUBLIC_*</code> values are embedded at build time.</li>
<li>Result: client can attempt <code>ws://...:14801</code> while server is on <code>14803</code>, leaving UI in permanent <code>CONNECTING</code>.</li>
</ul>
<h3>Problem B: Project ID / Session ID Domain Confusion</h3>
<ul>
<li>Session IDs and project IDs share syntax (<code>[a-zA-Z0-9_-]+</code>).</li>
<li>Orchestrator session IDs are generated as <code>${project.sessionPrefix}-orchestrator</code>, which can visually resemble project keys.</li>
<li><code>/api/spawn</code> previously validated only identifier shape, not membership in <code>config.projects</code>.</li>
<li>Invalid but syntactically valid IDs reached core spawn logic and failed late with <code>500</code>.</li>
</ul>
<h2>Root Cause Analysis</h2>
<h3>A. Build-Time vs Runtime Config Boundary Mismatch</h3>
<ul>
<li>The server process controls actual runtime port assignment.</li>
<li>The browser bundle cannot safely depend on build-time env values for runtime-selected ports.</li>
<li>There was no first-party runtime endpoint for client port discovery.</li>
</ul>
<h3>B. Namespace Collision and Inconsistent Validation</h3>
<ul>
<li>Project IDs and session IDs were treated as plain strings at API boundaries.</li>
<li>Some routes sanitize project filters against config; others previously did not.</li>
<li>Validation was format-only in <code>/api/spawn</code> instead of semantic (<code>is configured project</code>).</li>
</ul>
<h2>Goals</h2>
<ol>
<li>Make direct terminal connection deterministic across runtime-selected ports in prebuilt deployments.</li>
<li>Ensure <code>/api/spawn</code> rejects non-configured project IDs early and predictably.</li>
<li>Normalize dashboard project filter values so invalid query state cannot poison project context.</li>
<li>Preserve backwards compatibility for existing default-port setups.</li>
</ol>
<h2>Non-Goals</h2>
<ol>
<li>Redesign session ID format.</li>
<li>Introduce full typed ID wrappers across all packages in this change.</li>
<li>Remove existing reverse-proxy path-based WS support.</li>
</ol>
<h2>Proposed Design</h2>
<h3>1. Runtime Terminal Config Endpoint</h3>
<p>Add <code>GET /api/runtime/terminal</code> (dynamic, no-store):</p>
<ul>
<li><code>terminalPort</code> from <code>TERMINAL_PORT</code> (normalized, fallback <code>14800</code>)</li>
<li><code>directTerminalPort</code> from <code>DIRECT_TERMINAL_PORT</code> (normalized, fallback <code>14801</code>)</li>
<li><code>proxyWsPath</code> from <code>TERMINAL_WS_PATH</code>/<code>NEXT_PUBLIC_TERMINAL_WS_PATH</code> (normalized path or <code>null</code>)</li>
</ul>
<h3>2. Runtime-Aware DirectTerminal Connection</h3>
<p>Update client connection flow:</p>
<ol>
<li>Resolve build-time values if available.</li>
<li>Fetch <code>/api/runtime/terminal</code> before socket connect when needed.</li>
<li>Parse and normalize returned values.</li>
<li>Build WS URL from runtime values.</li>
<li>Reuse the same runtime-aware logic on reconnect attempts.</li>
</ol>
<p class="muted">Default ports remain unchanged; runtime-shifted ports become deterministic.</p>
<h3>3. Semantic Project Validation in <code>/api/spawn</code></h3>
<p>Before calling spawn, verify <code>config.projects[projectId]</code> exists. If missing:</p>
<ul>
<li>Return <code>404</code> with <code>Unknown project: &lt;id&gt;</code></li>
<li>Record structured observability failure reason</li>
<li>Do not invoke core spawn path</li>
</ul>
<h3>4. Dashboard Project Filter Normalization</h3>
<ul>
<li>keep <code>"all"</code></li>
<li>keep only configured project IDs</li>
<li>otherwise fallback to first valid configured project (or primary fallback)</li>
</ul>
<h2>Flow Chart</h2>
<style>
.flow-section {
margin: 1.5rem 0 2.5rem;
padding: 1.5rem;
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: 12px;
}
.flow-section-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
margin: 0 0 1.2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.flow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.flow-node {
position: relative;
padding: 12px 20px;
border-radius: 10px;
text-align: center;
max-width: 520px;
width: 100%;
font-size: 0.88rem;
line-height: 1.45;
}
.flow-node strong { color: #fff; }
.flow-node code {
font-size: 0.82rem;
padding: 0.04rem 0.3rem;
}
.node-action {
background: linear-gradient(135deg, #1a2a55, #1e3468);
border: 1px solid #3456a0;
color: #cdd9f5;
}
.node-decision {
background: linear-gradient(135deg, #2a1a44, #3a2060);
border: 1px solid #6a4ca0;
color: #ddd0f5;
border-radius: 10px;
padding: 14px 24px;
}
.node-success {
background: linear-gradient(135deg, #0d2a1a, #143a24);
border: 1px solid #2a8050;
color: #b0e8c8;
}
.node-error {
background: linear-gradient(135deg, #2a0d0d, #3a1414);
border: 1px solid #803030;
color: #e8b0b0;
}
.node-endpoint {
background: linear-gradient(135deg, #1a2040, #1e2855);
border: 1px solid #4070b0;
color: #a8c8f0;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
color: var(--muted);
font-size: 0.75rem;
padding: 2px 0;
line-height: 1;
}
.flow-arrow .arrow-line {
width: 2px;
height: 14px;
background: var(--border);
}
.flow-arrow .arrow-head {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--border);
}
.flow-arrow .arrow-label {
margin: 2px 0;
color: var(--accent);
font-weight: 600;
}
.flow-branch {
display: flex;
gap: 16px;
width: 100%;
max-width: 560px;
align-items: flex-start;
justify-content: center;
}
.flow-branch-arm {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
gap: 0;
}
.branch-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 3px 10px;
border-radius: 20px;
margin-bottom: 2px;
}
.branch-yes { background: #1a3a24; color: #70d898; border: 1px solid #2a6040; }
.branch-no { background: #3a1a1a; color: #e88080; border: 1px solid #603030; }
.branch-match { background: #1a2a44; color: #70a8e8; border: 1px solid #2a5080; }
.flow-node .sub {
display: block;
font-size: 0.78rem;
color: var(--muted);
margin-top: 4px;
}
/* Filter table */
.filter-table {
width: 100%;
max-width: 540px;
border-collapse: separate;
border-spacing: 0;
margin: 0 auto;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
font-size: 0.85rem;
}
.filter-table th {
background: #1a2550;
color: var(--accent);
text-align: left;
padding: 10px 16px;
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.filter-table td {
padding: 9px 16px;
border-top: 1px solid var(--border);
}
.filter-table tr:nth-child(even) td { background: rgba(0,0,0,0.15); }
.filter-table tr:nth-child(odd) td { background: rgba(0,0,0,0.08); }
.filter-table .result-keep { color: #70d898; }
.filter-table .result-fallback { color: #e8c870; }
</style>
<!-- TERMINAL CONNECTION FLOW -->
<div class="flow-section">
<div class="flow-section-title">Terminal Connection Flow</div>
<div class="flow">
<div class="flow-node node-action">
<strong>1.</strong> User runs <code>ao start &lt;project-path&gt;</code>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>2.</strong> CLI runtime setup — <code>buildDashboardEnv()</code>
<span class="sub">Picks <code>TERMINAL_PORT</code> / <code>DIRECT_TERMINAL_PORT</code> at runtime</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>3.</strong> <code>start-all</code> launches servers
<span class="sub">Next.js server + direct-terminal-ws on <code>DIRECT_TERMINAL_PORT</code></span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>4.</strong> Browser opens session page
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-decision">
<strong>5.</strong> <code>resolveConnectionConfig()</code>
<span class="sub">Build-time <code>NEXT_PUBLIC_*</code> values available and valid?</span>
</div>
<div class="flow-arrow"><div class="arrow-line" style="height:6px"></div></div>
<div class="flow-branch">
<div class="flow-branch-arm">
<span class="branch-label branch-yes">Yes</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-success" style="font-size:0.82rem;">
Use build-time values directly
</div>
</div>
<div class="flow-branch-arm">
<span class="branch-label branch-no">No</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-endpoint" style="font-size:0.82rem;">
<code>GET /api/runtime/terminal</code>
<span class="sub">Read runtime env ports, normalize, return config</span>
</div>
</div>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-label">merge</div><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>6.</strong> Client builds WS URL from resolved runtime config
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>7.</strong> WebSocket connect to direct-terminal-ws
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>8.</strong> Resolve tmux session
<span class="sub">Exact match first → hash-prefixed suffix fallback</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-success">
<strong>9.</strong> PTY attach succeeds — terminal interactive (<code>CONNECTED</code>)
</div>
</div>
</div>
<!-- SPAWN PATH FLOW -->
<div class="flow-section">
<div class="flow-section-title">Spawn Path</div>
<div class="flow">
<div class="flow-node node-action">
<strong>A.</strong> User triggers spawn
<span class="sub">Dashboard / mobile / API caller</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-endpoint">
<strong>B.</strong> <code>POST /api/spawn</code> with <code>body.projectId</code>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>C.</strong> <code>validateIdentifier(projectId)</code>
<span class="sub">Syntax check — format only</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-decision">
<strong>D.</strong> Semantic guard: <code>config.projects[projectId]</code> exists?
</div>
<div class="flow-arrow"><div class="arrow-line" style="height:6px"></div></div>
<div class="flow-branch">
<div class="flow-branch-arm">
<span class="branch-label branch-yes">Yes</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action" style="font-size:0.82rem;">
<code>sessionManager.spawn()</code>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-success" style="font-size:0.82rem;">
<code>201 Created</code> + session payload
<span class="sub">Dashboard/SSE shows active state</span>
</div>
</div>
<div class="flow-branch-arm">
<span class="branch-label branch-no">No</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-error" style="font-size:0.82rem;">
<code>404</code><code>Unknown project: &lt;id&gt;</code>
<span class="sub">Stop early — core spawn not invoked</span>
</div>
</div>
</div>
</div>
</div>
<!-- PROJECT FILTER NORMALIZATION -->
<div class="flow-section">
<div class="flow-section-title">Project Filter Normalization</div>
<div class="flow" style="gap: 12px;">
<div class="flow-node node-endpoint" style="margin-bottom:8px;">
Incoming <code>?project=&lt;value&gt;</code> from dashboard query
</div>
<table class="filter-table">
<thead>
<tr>
<th>Condition</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>value == <code>"all"</code></td>
<td class="result-keep">Keep <code>"all"</code></td>
</tr>
<tr>
<td>value ∈ configured projects</td>
<td class="result-keep">Keep value</td>
</tr>
<tr>
<td>otherwise (e.g. session ID like <code>mono-orchestrator</code>)</td>
<td class="result-fallback">Fallback to primary valid project</td>
</tr>
</tbody>
</table>
<p style="font-size:0.82rem; color:var(--muted); text-align:center; margin-top:4px;">
Invalid values cannot become active project context.
</p>
</div>
</div>
<h2>Alternatives Considered</h2>
<ol>
<li>Keep fixed ports only (<code>14800/14801</code>) and disable auto-shift. Rejected: blocks multi-instance startup and fails on legitimate port conflicts.</li>
<li>Continue relying on <code>NEXT_PUBLIC_*</code> runtime injection. Rejected: production client bundles are build-time materialized.</li>
<li>Accept any <code>projectId</code> in <code>/api/spawn</code> and let core throw. Rejected: late 500 errors and poor API ergonomics.</li>
</ol>
<h2>Risks and Mitigations</h2>
<ol>
<li>Runtime endpoint unavailable. Mitigation: client retains safe fallback and reconnect logic.</li>
<li>Reverse-proxy deployments with custom WS path. Mitigation: preserve proxy-path precedence and include runtime proxy field.</li>
<li>Behavior change for invalid project query. Mitigation: normalization affects only unknown IDs; valid IDs and <code>all</code> remain unchanged.</li>
</ol>
<h2>Validation Plan</h2>
<ul>
<li><code>GET /api/runtime/terminal</code> returns runtime env ports.</li>
<li><code>POST /api/spawn</code> returns <code>404</code> for unknown project and does not call spawn.</li>
<li>Project filter normalization keeps valid IDs, keeps <code>all</code>, and falls back on unknown IDs.</li>
<li>Existing <code>DirectTerminal</code> URL construction tests remain green.</li>
</ul>
<h2>Rollout Plan</h2>
<ol>
<li>Merge patch to main.</li>
<li>Release new npm package version containing web/client and API updates.</li>
<li>Announce behavior note:
<ul>
<li>default-port users unaffected</li>
<li>runtime-shifted port users no longer hit <code>CONNECTING</code> deadlock</li>
</ul>
</li>
</ol>
<h2>Release Checklist (Commands)</h2>
<pre><code># 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 &lt;project-path&gt;
# verify terminal works when direct terminal port is non-default</code></pre>
<h2>Acceptance Criteria</h2>
<ol>
<li>Direct terminal connects in npm prebuilt flow even when runtime direct port is not <code>14801</code>.</li>
<li><code>/api/spawn</code> never returns <code>500</code> for unknown-but-valid-format project IDs.</li>
<li>Invalid <code>project</code> query values do not become active project state.</li>
<li>Existing default-port flows remain functional without configuration changes.</li>
</ol>
</main>
</body>
</html>

View File

@ -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/**",
],
},

View File

@ -1,5 +1,11 @@
# @composio/ao
## 0.2.2
### Patch Changes
- @composio/ao-cli@0.2.2
## 0.2.1
### Patch Changes

View File

@ -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",

View File

@ -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

View File

@ -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<typeof vi.spyOn>;
let processExitSpy: ReturnType<typeof vi.spyOn>;
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();
});
});

View File

@ -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<string, string>();
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<string, string>;
};
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<Record<string, string>>;
};
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<Record<string, string>>;
};
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<Record<string, string>>;
};
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");
});
});

View File

@ -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";

View File

@ -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<string, string[]>;
};
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<string, string[]>;
};
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(() => {

View File

@ -66,6 +66,7 @@ vi.mock("../../src/lib/metadata.js", () => ({
let tmpDir: string;
let configPath: string;
let cwdSpy: ReturnType<typeof vi.spyOn> | 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<string, unknown>).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",

View File

@ -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 <url>` triggers handleUrlStart
// ---------------------------------------------------------------------------

View File

@ -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<string, string>. */
@ -179,6 +207,7 @@ function makeSession(overrides: Partial<Session> & { id: string; projectId: stri
vi.mock("../../src/lib/create-session-manager.js", () => ({
getSessionManager: async (): Promise<SessionManager> => 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<typeof vi.spyOn>;
let setIntervalSpy: ReturnType<typeof vi.spyOn> | undefined;
let clearIntervalSpy: ReturnType<typeof vi.spyOn> | undefined;
let processOnceSpy: ReturnType<typeof vi.spyOn> | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-"));
@ -221,8 +253,8 @@ beforeEach(() => {
reactions: {},
} as Record<string, unknown>;
// 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<typeof setInterval>;
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => watchTimer);
clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => undefined);
const signalHandlers = new Map<string, () => 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<string, unknown>),
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<string, unknown>;
// 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<string, unknown>),
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<string, unknown>;
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<void>((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,
});
});
});

View File

@ -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<string, unknown>;
return {
...actual,
existsSync: (...args: unknown[]) => mockExistsSync(...args),
};
});
vi.mock("node:child_process", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
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", () => {

View File

@ -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<string, { agent?: string }>,
projects?: Record<string, { agent?: string; scm?: { plugin: string } }>,
): 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<string, Agent>;
scm?: Record<string, SCM>;
}): 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");
});
});

View File

@ -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();
});
});

View File

@ -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",

View File

@ -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"
}
]

View File

@ -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[] = [];

View File

@ -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<PluginRegistry> {
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<PluginRegistry> {
console.log("");
console.log("Plugin resolution:");
const registry = await loadPluginRegistry(config);
const loadedBySlot = new Map<CheckedPluginSlot, Set<string>>();
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<string, unknown>;
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<void> {
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<string, NotifierTarget>();
// 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>("notifier", name);
for (const target of targets.values()) {
const notifier = registry.get<Notifier>("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<typeof loadConfig> | 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}`);

View File

@ -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<string, any>) ?? {};
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<void> {
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<InstalledPluginConfig> {
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<string> {
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<InstalledPluginConfig> {
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<typeof loadMarketplaceCatalog>,
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 <slot>", "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("<query>", "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 <name>", "Display/plugin name")
.option("--slot <slot>", "Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal")
.option("--description <description>", "Short plugin description")
.option("--author <author>", "Package author")
.option("--package-name <packageName>", "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("<reference>", "Marketplace id, package name, or local path")
.option("--url <url>", "OpenClaw webhook URL (passed to setup when installing notifier-openclaw)")
.option("--token <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("<reference>", "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}`));
});
}

View File

@ -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,
};

View File

@ -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})`)];

View File

@ -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<ResolvedConfig> {
}
}
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<ResolvedConfig> {
// ---------------------------------------------------------------------------
async function nonInteractiveSetup(opts: SetupOptions): Promise<ResolvedConfig> {
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<ResolvedConfig>
// 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 <url>", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)")
.option("--token <token>", "OpenClaw hooks auth token")
.option("--non-interactive", "Skip prompts — requires --url (token auto-generated if not provided)")
.option(
"--routing-preset <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<void> {
export async function runSetupAction(opts: SetupOptions): Promise<void> {
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
// --- Find existing config ------------------------------------------------

View File

@ -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(

View File

@ -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<boolean> {
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<void> {
await new Promise<void>((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<De
console.log(chalk.yellow("⚠ No supported agent runtime detected."));
console.log(chalk.dim(" You can install one now (recommended) or continue and install later.\n"));
const skipOption = AGENT_INSTALL_OPTIONS.length + 1;
AGENT_INSTALL_OPTIONS.forEach((option, i) => {
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<void> {
process.exit(1);
}
async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise<void> {
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,

View File

@ -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 <id>", "Filter by project ID")
.option("--json", "Output as JSON")
.action(async (opts: { project?: string; json?: boolean }) => {
let config: ReturnType<typeof loadConfig>;
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 <seconds>", "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<string, Session[]>();
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<void> => {
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<typeof loadConfig>;
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<string, Session[]>();
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>("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>("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<void> {
// 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<void> {
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();

View File

@ -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>("tracker", project.tracker.plugin);
if (!tracker) {

View File

@ -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")

View File

@ -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}` : ""}`);
}

View File

@ -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
#

View File

@ -16,25 +16,37 @@ import {
type PluginRegistry,
type LifecycleManager,
} from "@composio/ao-core";
import { importPluginModuleFromSource } from "./plugin-store.js";
let registryPromise: Promise<PluginRegistry> | null = null;
const registryPromises = new Map<string, Promise<PluginRegistry>>();
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<PluginRegistry> {
export async function getPluginRegistry(config: OrchestratorConfig): Promise<PluginRegistry> {
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<PluginRegistry>
export async function getSessionManager(
config: OrchestratorConfig,
): Promise<OpenCodeSessionManager> {
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<LifecycleManager> {
const registry = await getRegistry(config);
const registry = await getPluginRegistry(config);
const sessionManager = createSessionManager({ config, registry });
return createLifecycleManager({ config, registry, sessionManager, projectId });
}

View File

@ -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<string, string> {
const keys: Record<string, string> = {};
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<string, unknown>;
// 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<string, unknown>)) {
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<string, unknown>)) {
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 };

View File

@ -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,
}))
);
}

View File

@ -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<ProbeResult> {
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<OpenClawInstallation> {
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 };

View File

@ -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<MarketplacePluginEntry>;
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<string, MarketplacePluginEntry>();
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<MarketplacePluginEntry[]> {
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,
};
}

View File

@ -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<PluginSlot, string> = {
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<string, unknown>) {
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;
}

View File

@ -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<void> {
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<string> {
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<boolean> {
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<unknown> {
if (isPackageSpecifier(specifier)) {
const storeSpecifier = tryResolveInstalledPluginSpecifier(specifier);
if (storeSpecifier) {
return import(storeSpecifier);
}
}
return import(specifier);
}
export async function getLatestPublishedPackageVersion(packageName: string): Promise<string> {
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}.`);
}

View File

@ -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<string, { create(): SCM }> = {
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>("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>("scm", scmName);
if (!plugin) {
throw new Error(`Unknown SCM plugin: ${scmName}`);
}
return plugin;
}

View File

@ -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<T extends ProjectWithPath>(
projects: Record<string, T>,
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;
}

View File

@ -0,0 +1,30 @@
import chalk from "chalk";
import { confirm, isCancel, select, type Option } from "@clack/prompts";
type SelectOption<T extends string> = Option<T>;
export async function promptConfirm(message: string, initialValue = true): Promise<boolean> {
const result = await confirm({ message, initialValue });
if (isCancel(result)) {
console.log(chalk.yellow("\nCancelled."));
process.exit(0);
}
return result;
}
export async function promptSelect<T extends string>(
message: string,
options: SelectOption<T>[],
initialValue?: T,
): Promise<T> {
const result = await select({
message,
options,
...(initialValue !== undefined ? { initialValue } : {}),
});
if (isCancel(result)) {
console.log(chalk.yellow("\nRequest Cancelled."));
process.exit(0);
}
return result;
}

View File

@ -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));

View File

@ -11,5 +11,9 @@ export default defineConfig({
maxThreads: 8,
},
},
coverage: {
provider: "v8",
reporter: ["lcov"],
},
},
});

View File

@ -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<T>(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/<hash>-my-app/sessions/app-3` shows full state
- No database dependency (survives crashes, easy to inspect)
- Backwards-compatible with bash script orchestrator

View File

@ -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);
});

View File

@ -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();
});
});

View File

@ -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 });
}
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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", () => {

View File

@ -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;
}

View File

@ -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";

View File

@ -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<string, PREnrichmentData>();
/**
* 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<void> {
// Clear previous cache
prEnrichmentCache.clear();
// Collect all unique PRs
const prs = sessions
.map((s) => s.pr)
.filter((pr): pr is NonNullable<typeof pr> => 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<string, typeof uniquePRs>();
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>("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)));

View File

@ -18,6 +18,7 @@ export type ObservabilityMetricName =
| "api_request"
| "claim_pr"
| "cleanup"
| "graphql_batch"
| "kill"
| "lifecycle_poll"
| "restore"

View File

@ -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<string, { manifest: PluginManifest; instance: unknown }>;
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<string, unknown>;
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<PluginModule>;
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<string, unknown>;
const dotEntry = exportsRecord["."];
if (typeof dotEntry === "string") return dotEntry;
if (dotEntry && typeof dotEntry === "object") {
const importEntry = (dotEntry as Record<string, unknown>)["import"];
if (typeof importEntry === "string") return importEntry;
const defaultEntry = (dotEntry as Record<string, unknown>)["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);
}
}
},
};
}

View File

@ -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<typeof setTimeout> | null = null;
const enrichTimeout = new Promise<void>((resolve) => {
enrichTimeoutId = setTimeout(resolve, 2_000);
enrichTimeoutId = setTimeout(resolve, OPENCODE_DISCOVERY_TIMEOUT_MS + 2_000);
});
const enrichPromise = ensureHandleAndEnrich(
session,

View File

@ -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<MergeReadiness>;
/**
* 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<Map<string, PREnrichmentData>>;
}
// --- 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<string, ProjectConfig>;
@ -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;

View File

@ -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"],
},
},
});

View File

@ -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"
},

View File

@ -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

View File

@ -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"
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1,6 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

View File

@ -1,21 +0,0 @@
{
"cli": {
"version": ">= 12.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
}
},
"production": {}
},
"submit": {
"production": {}
}
}

View File

@ -1,4 +0,0 @@
import { registerRootComponent } from "expo";
import App from "./src/App";
registerRootComponent(App);

View File

@ -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;

View File

@ -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"
}
}

View File

@ -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 (
<SafeAreaProvider>
<BackendProvider>
<RootNavigator />
</BackendProvider>
</SafeAreaProvider>
);
}

View File

@ -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<AttentionLevel, string> = {
merge: "MERGE",
respond: "RESPOND",
review: "REVIEW",
pending: "PENDING",
working: "WORKING",
done: "DONE",
};
export default function AttentionBadge({ level }: Props) {
const color = ATTENTION_COLORS[level];
return (
<View style={[styles.badge, { borderColor: color, backgroundColor: color + "22" }]}>
<Text style={[styles.label, { color }]}>{LABELS[level]}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
borderWidth: 1,
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
label: {
fontSize: 10,
fontWeight: "700",
letterSpacing: 0.5,
},
});

View File

@ -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 (
<TouchableOpacity style={[styles.card, { borderLeftColor: color }]} onPress={onPress} activeOpacity={0.75}>
<View style={styles.header}>
<Text style={styles.id} numberOfLines={1} ellipsizeMode="middle">
{session.id}
</Text>
<AttentionBadge level={level} />
</View>
{session.issueLabel || session.issueTitle ? (
<Text style={styles.issue} numberOfLines={1}>
{session.issueLabel ? `${session.issueLabel}: ` : ""}
{session.issueTitle ?? ""}
</Text>
) : null}
{session.summary && !session.summaryIsFallback ? (
<Text style={styles.summary} numberOfLines={2}>
{session.summary}
</Text>
) : null}
<View style={styles.footer}>
{session.branch ? (
<Text style={styles.branch} numberOfLines={1}>
{session.branch}
</Text>
) : null}
<Text style={styles.time}>{time}</Text>
</View>
{session.pr ? (
<View style={styles.prRow}>
<Text style={styles.prLabel}>PR #{session.pr.number}</Text>
{session.pr.ciStatus !== "none" && (
<Text
style={[
styles.ciStatus,
{
color:
session.pr.ciStatus === "passing"
? "#3fb950"
: session.pr.ciStatus === "failing"
? "#f85149"
: "#8b949e",
},
]}
>
{session.pr.ciStatus.toUpperCase()}
</Text>
)}
</View>
) : null}
</TouchableOpacity>
);
}
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,
},
});

View File

@ -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 (
<View style={styles.container}>
<StatItem label="Sessions" value={stats.totalSessions} color="#e6edf3" />
<StatItem label="Working" value={stats.workingSessions} color="#58a6ff" />
<StatItem label="PRs" value={stats.openPRs} color="#3fb950" />
<StatItem label="Review" value={stats.needsReview} color="#d29922" />
</View>
);
}
function StatItem({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View style={styles.item}>
<Text style={[styles.value, { color }]}>{value}</Text>
<Text style={styles.label}>{label}</Text>
</View>
);
}
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,
},
});

View File

@ -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<void>;
/** 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<void>;
fetchSessions: () => Promise<SessionsResponse>;
fetchSession: (id: string) => Promise<DashboardSession>;
sendMessage: (id: string, message: string) => Promise<void>;
killSession: (id: string) => Promise<void>;
restoreSession: (id: string) => Promise<void>;
mergePR: (prNumber: number) => Promise<void>;
spawnSession: (projectId: string, issueId?: string) => Promise<DashboardSession>;
}
const BackendContext = createContext<BackendContextValue | null>(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<SessionsResponse> => {
const res = await apiFetch("/api/sessions");
return res.json() as Promise<SessionsResponse>;
}, [apiFetch]);
const fetchSession = useCallback(async (id: string): Promise<DashboardSession> => {
const res = await apiFetch(`/api/sessions/${encodeURIComponent(id)}`);
return res.json() as Promise<DashboardSession>;
}, [apiFetch]);
const sendMessage = useCallback(async (id: string, message: string): Promise<void> => {
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/message`, {
method: "POST",
body: JSON.stringify({ message }),
});
}, [apiFetch]);
const killSession = useCallback(async (id: string): Promise<void> => {
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/kill`, { method: "POST" });
}, [apiFetch]);
const restoreSession = useCallback(async (id: string): Promise<void> => {
await apiFetch(`/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" });
}, [apiFetch]);
const mergePR = useCallback(async (prNumber: number): Promise<void> => {
await apiFetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
}, [apiFetch]);
const spawnSession = useCallback(async (projectId: string, issueId?: string): Promise<DashboardSession> => {
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 (
<BackendContext.Provider
value={{
backendUrl,
setBackendUrl,
terminalWsUrl,
terminalWsOverride,
setTerminalWsOverride,
fetchSessions,
fetchSession,
sendMessage,
killSession,
restoreSession,
mergePR,
spawnSession,
}}
>
{children}
</BackendContext.Provider>
);
}
export function useBackend(): BackendContextValue {
const ctx = useContext(BackendContext);
if (!ctx) throw new Error("useBackend must be used inside BackendProvider");
return ctx;
}

View File

@ -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<DashboardSession | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 };
}

View File

@ -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<Record<string, AttentionLevel>>({});
// Tracks when we last sent a notification per session
const lastNotifiedAt = useRef<Record<string, number>>({});
// 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<string, AttentionLevel> = {};
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]);
}

View File

@ -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<DashboardSession[]>([]);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [orchestratorId, setOrchestratorId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 };
}

View File

@ -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<RootStackParamList>();
const Stack = createNativeStackNavigator<RootStackParamList>();
const AoDarkTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: "#0d1117",
card: "#161b22",
text: "#e6edf3",
border: "#30363d",
primary: "#58a6ff",
},
};
export default function RootNavigator() {
return (
<NavigationContainer ref={navigationRef} theme={AoDarkTheme}>
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerStyle: { backgroundColor: "#161b22" },
headerTintColor: "#e6edf3",
headerTitleStyle: { fontWeight: "600" },
}}
>
<Stack.Screen
name="Home"
component={HomeScreen}
options={{ title: "Agent Orchestrator" }}
/>
<Stack.Screen
name="SessionDetail"
component={SessionDetailScreen}
options={{ title: "Session" }}
/>
<Stack.Screen
name="Terminal"
component={TerminalScreen}
options={{
title: "Terminal",
headerStyle: { backgroundColor: "#0d1117" },
}}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{ title: "Settings" }}
/>
<Stack.Screen
name="SpawnSession"
component={SpawnSessionScreen}
options={{ title: "New Session" }}
/>
<Stack.Screen
name="Orchestrator"
component={OrchestratorScreen}
options={{ title: "Orchestrator" }}
/>
<Stack.Screen
name="Commands"
component={CommandsScreen}
options={{ title: "CLI Commands" }}
/>
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@ -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<string, AttentionLevel> = prevRaw
? (JSON.parse(prevRaw) as Record<string, AttentionLevel>)
: {};
const timestamps: Record<string, number> = tsRaw
? (JSON.parse(tsRaw) as Record<string, number>)
: {};
const nextState: Record<string, AttentionLevel> = {};
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<void> {
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
}
}

View File

@ -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<boolean> {
// 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<void> {
const sessionLabel =
session.issueLabel ??
session.id;
const body =
session.issueTitle ??
(session.summary && !session.summaryIsFallback ? session.summary : null) ??
session.activity ??
session.status;
const titles: Record<typeof level, string> = {
respond: "Agent needs your input",
merge: "PR ready to merge",
review: "Session needs review",
};
const bodies: Record<typeof level, string> = {
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,
},
});
}

View File

@ -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 <project> [issue]", desc: "Spawn a session for an issue" },
{ cmd: "ao batch-spawn <project> <issues...>", desc: "Spawn multiple sessions" },
{ cmd: "ao session ls [-p <project>]", desc: "List all active sessions" },
{ cmd: "ao session kill <session>", desc: "Kill a session" },
{ cmd: "ao session restore <session>", desc: "Restore a crashed session" },
{ cmd: "ao session cleanup [-p <project>]", desc: "Clean up merged/closed sessions" },
{ cmd: "ao send <session> [message]", desc: "Send message to a session" },
{ cmd: "ao status [-p <project>]", desc: "Show sessions with PR/CI status" },
{ cmd: "ao review-check [project]", desc: "Check PRs and trigger agents" },
{ cmd: "ao dashboard [-p <port>]", 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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.section}>
<Text style={styles.hint}>Quick reference for managing sessions from terminal.</Text>
{CLI_COMMANDS.map((c, i) => (
<View key={i} style={styles.cmdRow}>
<Text style={styles.cmdText}>{c.cmd}</Text>
<Text style={styles.cmdDesc}>{c.desc}</Text>
</View>
))}
</View>
</ScrollView>
);
}
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,
},
});

View File

@ -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<RootStackParamList, "Home">;
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: () => (
<View style={{ flexDirection: "row", alignItems: "center", gap: 14 }}>
<TouchableOpacity
onPress={() => navigation.navigate("SpawnSession")}
style={{ flexDirection: "row", alignItems: "center", gap: 2 }}
>
<Text style={{ color: "#3fb950", fontSize: 18, fontWeight: "700" }}>+</Text>
<Text style={{ color: "#3fb950", fontSize: 13, fontWeight: "600" }}>Session</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("Orchestrator")}>
<Text style={{ fontSize: 20 }}>{"\uD83E\uDD16"}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigation.navigate("Settings")}
style={{ paddingRight: 4 }}
>
<Text style={{ fontSize: 20 }}>{"\u2699\uFE0F"}</Text>
</TouchableOpacity>
</View>
),
});
}, [navigation]);
const sorted = sortSessions(sessions);
if (loading && sessions.length === 0) {
return (
<View style={styles.center}>
<ActivityIndicator color="#58a6ff" size="large" />
<Text style={styles.loadingText}>Connecting...</Text>
</View>
);
}
if (error && sessions.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.retryButton, { marginTop: 8 }]}
onPress={() => navigation.navigate("Settings")}
>
<Text style={styles.retryText}>Configure Backend URL</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
{stats && <StatBar stats={stats} />}
<FlatList
data={sorted}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SessionCard
session={item}
onPress={() => navigation.navigate("SessionDetail", { sessionId: item.id })}
/>
)}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refresh}
tintColor="#58a6ff"
colors={["#58a6ff"]}
/>
}
contentContainerStyle={
sorted.length === 0 ? styles.emptyContainer : styles.listContent
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.emptyText}>No sessions</Text>
<Text style={styles.emptySubtext}>Sessions will appear here when agents are running</Text>
</View>
}
/>
</View>
);
}
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",
},
});

View File

@ -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<RootStackParamList, "Orchestrator">;
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: () => (
<TouchableOpacity onPress={() => navigation.navigate("Commands")}>
<Text style={{ color: "#58a6ff", fontSize: 14, fontWeight: "600" }}>Commands</Text>
</TouchableOpacity>
),
});
}, [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 (
<View style={styles.center}>
<ActivityIndicator color="#58a6ff" size="large" />
</View>
);
}
if (error && sessions.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={refresh}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
const orchLevel = orchSession ? getAttentionLevel(orchSession) : null;
const orchColor = orchLevel ? ATTENTION_COLORS[orchLevel] : "#8b949e";
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Orchestrator Session Details */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Orchestrator</Text>
{orchestratorId && orchSession ? (
<View style={[styles.orchDetailCard, { borderLeftColor: orchColor }]}>
<View style={styles.orchHeaderRow}>
<View style={styles.orchStatusRow}>
<View style={[styles.dot, { backgroundColor: "#3fb950" }]} />
<Text style={styles.orchRunning}>Running</Text>
</View>
<AttentionBadge level={orchLevel!} />
</View>
<Text style={styles.orchId}>{orchestratorId}</Text>
<Text style={styles.orchStatus}>
{orchSession.status}
{orchSession.activity ? ` · ${orchSession.activity}` : ""}
</Text>
{orchSession.summary && !orchSession.summaryIsFallback && (
<Text style={styles.orchSummary} numberOfLines={3}>{orchSession.summary}</Text>
)}
<View style={styles.orchTimingRow}>
<Text style={styles.orchTiming}>Last activity: {relativeTime(orchSession.lastActivityAt)}</Text>
</View>
{/* Actions */}
<View style={styles.orchActions}>
<TouchableOpacity
style={styles.terminalButton}
onPress={() => navigation.navigate("Terminal", { sessionId: orchestratorId, terminalWsUrl })}
>
<Text style={styles.terminalButtonText}>Open Terminal</Text>
</TouchableOpacity>
</View>
{/* Send message */}
<View style={styles.orchMessageRow}>
<TextInput
style={styles.orchMessageInput}
placeholder="Send message to orchestrator..."
placeholderTextColor="#8b949e"
value={message}
onChangeText={setMessage}
returnKeyType="send"
onSubmitEditing={handleSend}
/>
<TouchableOpacity
style={[styles.sendButton, (!message.trim() || sending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!message.trim() || sending}
>
{sending ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.sendButtonText}>Send</Text>
)}
</TouchableOpacity>
</View>
</View>
) : (
<View style={styles.orchDetailCard}>
<View style={styles.orchStatusRow}>
<View style={[styles.dot, { backgroundColor: "#8b949e" }]} />
<Text style={[styles.orchRunning, { color: "#8b949e" }]}>Not running</Text>
</View>
<Text style={styles.orchHint}>Start with: ao start &lt;project&gt;</Text>
</View>
)}
</View>
{/* Zone Overview */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Session Zones</Text>
<View style={styles.zonesGrid}>
<ZoneBadge label="Merge" count={zones.merge} color="#3fb950" />
<ZoneBadge label="Respond" count={zones.respond} color="#f85149" />
<ZoneBadge label="Review" count={zones.review} color="#d29922" />
<ZoneBadge label="Pending" count={zones.pending} color="#e3b341" />
<ZoneBadge label="Working" count={zones.working} color="#58a6ff" />
<ZoneBadge label="Done" count={zones.done} color="#8b949e" />
</View>
</View>
</ScrollView>
);
}
function ZoneBadge({ label, count, color }: { label: string; count: number; color: string }) {
return (
<View style={styles.zoneBadge}>
<Text style={[styles.zoneCount, { color }]}>{count}</Text>
<Text style={styles.zoneLabel}>{label}</Text>
</View>
);
}
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,
},
});

View File

@ -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<RootStackParamList, "SessionDetail">;
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<ActionButtonState>("idle");
const [commentFixStates, setCommentFixStates] = useState<Record<string, ActionButtonState>>({});
const scrollRef = useRef<ScrollView>(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 (
<View style={styles.center}>
<ActivityIndicator color="#58a6ff" size="large" />
</View>
);
}
if (error && !session) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.button} onPress={refresh}>
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
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 (
<View style={styles.root}>
<ScrollView ref={scrollRef} style={styles.container} contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* Header row */}
<View style={[styles.headerCard, { borderLeftColor: color }]}>
<View style={styles.headerRow}>
<Text style={styles.sessionId} numberOfLines={1} ellipsizeMode="middle">
{session.id}
</Text>
<AttentionBadge level={level} />
</View>
<Text style={styles.status}>
{session.status}
{session.activity ? ` · ${session.activity}` : ""}
</Text>
</View>
{/* Alerts */}
{pr && pr.ciStatus === "failing" && failedChecks.length > 0 && (
<View style={styles.alertCard}>
<View style={styles.alertRow}>
<Text style={styles.alertText}>
{failedChecks.length} CI check{failedChecks.length > 1 ? "s" : ""} failing
</Text>
<ActionButton
state={ciFixState}
label="Ask to fix"
onPress={() => handleAskFixCI(pr)}
/>
</View>
</View>
)}
{pr && !pr.mergeability.noConflicts && (
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
<Text style={[styles.alertText, { color: "#d29922" }]}>Merge conflict</Text>
</View>
)}
{pr && pr.reviewDecision === "changes_requested" && (
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
<Text style={[styles.alertText, { color: "#d29922" }]}>Changes requested</Text>
</View>
)}
{/* Issue */}
{(session.issueLabel || session.issueTitle) && (
<Section title="Issue">
<Text style={styles.issueText}>
{session.issueLabel ? `${session.issueLabel}: ` : ""}
{session.issueTitle ?? ""}
</Text>
</Section>
)}
{/* Summary */}
{session.summary && !session.summaryIsFallback && (
<Section title="Summary">
<Text style={styles.bodyText}>{session.summary}</Text>
</Section>
)}
{/* Branch */}
{session.branch && (
<Section title="Branch">
<Text style={styles.monoText}>{session.branch}</Text>
</Section>
)}
{/* PR */}
{pr && (
<Section title={`PR #${pr.number}`}>
<TouchableOpacity onPress={() => Linking.openURL(pr.url)}>
<Text style={[styles.issueText, { color: "#58a6ff", marginBottom: 8 }]} numberOfLines={2}>
{pr.title}
</Text>
</TouchableOpacity>
<InfoRow label="CI" value={pr.ciStatus} valueColor={pr.ciStatus === "passing" ? "#3fb950" : pr.ciStatus === "failing" ? "#f85149" : undefined} />
<InfoRow label="Review" value={pr.reviewDecision} valueColor={pr.reviewDecision === "approved" ? "#3fb950" : pr.reviewDecision === "changes_requested" ? "#f85149" : undefined} />
<InfoRow label="Mergeable" value={pr.mergeability.mergeable ? "Yes" : "No"} valueColor={pr.mergeability.mergeable ? "#3fb950" : "#f85149"} />
<InfoRow label="Changes" value={`+${pr.additions} / -${pr.deletions}`} />
{pr.isDraft && <InfoRow label="Draft" value="Yes" />}
{pr.mergeability.blockers.length > 0 && (
<View style={{ marginTop: 6 }}>
<Text style={styles.blockersLabel}>Blockers:</Text>
{pr.mergeability.blockers.map((b, i) => (
<Text key={i} style={styles.blockerText}>- {b}</Text>
))}
</View>
)}
</Section>
)}
{/* CI Checks */}
{pr && pr.ciChecks.length > 0 && (
<Section title="CI Checks">
{pr.ciChecks.map((check, i) => (
<CICheckRow key={i} check={check} />
))}
</Section>
)}
{/* Unresolved Comments */}
{unresolvedComments.length > 0 && (
<Section title={`Unresolved Comments (${unresolvedComments.length})`}>
{unresolvedComments.map((comment, i) => (
<View key={i} style={styles.commentCard}>
<View style={styles.commentHeader}>
<Text style={styles.commentAuthor}>{comment.author}</Text>
<Text style={styles.commentPath} numberOfLines={1}>{comment.path}</Text>
</View>
<Text style={styles.commentBody} numberOfLines={4}>{comment.body}</Text>
<View style={styles.commentActions}>
<ActionButton
state={commentFixStates[comment.url] ?? "idle"}
label="Ask Agent to Fix"
onPress={() => handleAskFixComment(comment)}
/>
<TouchableOpacity onPress={() => Linking.openURL(comment.url)}>
<Text style={styles.viewLink}>View</Text>
</TouchableOpacity>
</View>
</View>
))}
</Section>
)}
{/* Timestamps */}
<Section title="Timing">
<InfoRow label="Created" value={relativeTime(session.createdAt)} />
<InfoRow label="Last activity" value={relativeTime(session.lastActivityAt)} />
</Section>
{/* Actions */}
<View style={styles.actionsSection}>
{isReadyToMerge && (
<TouchableOpacity
style={[styles.actionButton, styles.mergeButton]}
onPress={() => handleMergePR(pr.number)}
disabled={merging}
>
{merging ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={[styles.actionButtonText, { color: "#fff" }]}>
Merge PR #{pr.number}
</Text>
)}
</TouchableOpacity>
)}
<TouchableOpacity style={[styles.actionButton, styles.terminalButton]} onPress={handleOpenTerminal}>
<Text style={styles.actionButtonText}>Open Terminal</Text>
</TouchableOpacity>
{canRestore && (
<TouchableOpacity style={[styles.actionButton, styles.restoreButton]} onPress={handleRestore}>
<Text style={styles.actionButtonText}>Restore Session</Text>
</TouchableOpacity>
)}
{!isDone && (
<TouchableOpacity style={[styles.actionButton, styles.killButton]} onPress={handleKill}>
<Text style={[styles.actionButtonText, { color: "#f85149" }]}>Kill Session</Text>
</TouchableOpacity>
)}
</View>
</ScrollView>
{/* Message input — only show for active sessions */}
{!isDone && (
<View style={[styles.messageBar, { paddingBottom: keyboardHeight > 0 ? keyboardHeight + 48 : 10 }]}>
<TextInput
style={styles.messageInput}
placeholder="Send message to agent..."
placeholderTextColor="#8b949e"
value={message}
onChangeText={setMessage}
multiline
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
/>
<TouchableOpacity
style={[styles.sendButton, (!message.trim() || sending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!message.trim() || sending}
>
{sending ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.sendButtonText}>Send</Text>
)}
</TouchableOpacity>
</View>
)}
</View>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
}
function InfoRow({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={[styles.infoValue, valueColor ? { color: valueColor } : undefined]}>{value}</Text>
</View>
);
}
const CI_STATUS_ICONS: Record<string, { icon: string; color: string }> = {
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 (
<TouchableOpacity
style={styles.ciCheckRow}
onPress={check.url ? () => Linking.openURL(check.url!) : undefined}
disabled={!check.url}
>
<Text style={[styles.ciCheckIcon, { color: info.color }]}>{info.icon}</Text>
<Text style={styles.ciCheckName} numberOfLines={1}>{check.name}</Text>
<Text style={[styles.ciCheckStatus, { color: info.color }]}>{check.status}</Text>
</TouchableOpacity>
);
}
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 (
<TouchableOpacity
style={[styles.inlineActionButton, { backgroundColor: bgColor, borderColor }]}
onPress={onPress}
disabled={isDisabled}
>
{state === "sending" ? (
<ActivityIndicator color="#58a6ff" size="small" />
) : (
<Text style={[styles.inlineActionText, { color: textColor }]}>{text}</Text>
)}
</TouchableOpacity>
);
}
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,
},
});

View File

@ -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<RootStackParamList, "Settings">;
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 (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Backend URL</Text>
<Text style={styles.hint}>
Enter the URL where your AO dashboard is running.
</Text>
<Text style={styles.fieldLabel}>Dashboard API URL</Text>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="http://100.x.x.x:3000 or https://abc.ngrok-free.app"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="next"
/>
<Text style={styles.fieldLabel}>
Terminal WebSocket URL{" "}
<Text style={styles.fieldLabelMuted}>(leave blank to auto-derive)</Text>
</Text>
<TextInput
style={styles.input}
value={wsInput}
onChangeText={setWsInput}
placeholder="wss://xyz.ngrok-free.app (only needed for ngrok)"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
/>
<TouchableOpacity
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={saving}
>
<Text style={styles.saveButtonText}>{saving ? "Saving..." : "Save"}</Text>
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Active URLs</Text>
<SettingsInfoRow label="Dashboard" value={backendUrl} />
<SettingsInfoRow
label="Terminal WS"
value={terminalWsUrl}
note={terminalWsOverride ? "manual" : "auto"}
/>
</View>
{__DEV__ && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Test Notifications</Text>
<Text style={styles.hint}>Fire a test notification to verify permissions are working. Tap the notification to navigate to the session.</Text>
<TouchableOpacity style={[styles.testButton, { borderColor: "#f85149" }]} onPress={handleTestRespondNotification}>
<Text style={[styles.testButtonText, { color: "#f85149" }]}>Test "Agent needs input" (respond)</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.testButton, { borderColor: "#3fb950", marginTop: 8 }]} onPress={handleTestMergeNotification}>
<Text style={[styles.testButtonText, { color: "#3fb950" }]}>Test "PR ready to merge" (merge)</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.testButton, { borderColor: "#d29922", marginTop: 8 }]} onPress={handleTestReviewNotification}>
<Text style={[styles.testButtonText, { color: "#d29922" }]}>Test "Session needs review" (review)</Text>
</TouchableOpacity>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Setup Guide Tailscale (Recommended)</Text>
<Step n="1" text="Install Tailscale on your Mac and phone (tailscale.com)" />
<Step n="2" text="Sign in to the same Tailscale account on both devices" />
<Step n="3" text="Find your Mac's Tailscale IP: run 'tailscale ip -4' in terminal (starts with 100.x)" />
<Step n="4" text="Make sure the orchestrator is running: pnpm build && pnpm dev" />
<Step n="5" text="Enter http://<TAILSCALE_IP>:3000 above and tap Save" />
<Text style={styles.sectionDivider}>Alternative: Local Wi-Fi</Text>
<Step n="1" text="Your phone must be on the same Wi-Fi as your Mac" />
<Step n="2" text="Find your Mac's LAN IP: System Settings > Wi-Fi > Details > IP Address" />
<Step n="3" text="Enter http://<LAN_IP>:3000 above and tap Save" />
<Text style={styles.sectionDivider}>Alternative: ngrok</Text>
<Step n="1" text="Run: ngrok http 3000" />
<Step n="2" text="Paste the https:// URL above as Dashboard API URL" />
<Step n="3" text="For terminal, run: ngrok http 14801 and paste the wss:// URL in Terminal WebSocket URL" />
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
function SettingsInfoRow({ label, value, note }: { label: string; value: string; note?: string }) {
return (
<View style={styles.infoRow}>
<View style={styles.infoLabelRow}>
<Text style={styles.infoLabel}>{label}</Text>
{note ? <Text style={styles.infoNote}>{note}</Text> : null}
</View>
<Text style={styles.infoValue} numberOfLines={1} ellipsizeMode="middle">
{value}
</Text>
</View>
);
}
function Step({ n, text }: { n: string; text: string }) {
return (
<View style={styles.step}>
<View style={styles.stepBadge}>
<Text style={styles.stepN}>{n}</Text>
</View>
<Text style={styles.stepText}>{text}</Text>
</View>
);
}
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,
},
});

View File

@ -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<RootStackParamList, "SpawnSession">;
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 (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Spawn New Session</Text>
<Text style={styles.hint}>
Create a new agent session. The orchestrator will assign an agent and workspace.
</Text>
<Text style={styles.fieldLabel}>Project ID *</Text>
<TextInput
style={styles.input}
value={projectId}
onChangeText={setProjectId}
placeholder="e.g. my-project"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
<Text style={styles.fieldLabel}>
Issue ID{" "}
<Text style={styles.fieldLabelMuted}>(optional)</Text>
</Text>
<TextInput
style={styles.input}
value={issueId}
onChangeText={setIssueId}
placeholder="e.g. 42 or PROJ-123"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
/>
<TouchableOpacity
style={[styles.spawnButton, spawning && styles.spawnButtonDisabled]}
onPress={handleSpawn}
disabled={spawning}
>
<Text style={styles.spawnButtonText}>
{spawning ? "Spawning..." : "Spawn Session"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
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",
},
});

View File

@ -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<RootStackParamList, "Terminal">;
type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected";
const STATUS_COLORS: Record<ConnectionStatus, string> = {
connecting: "#e3b341",
connected: "#3fb950",
error: "#f85149",
disconnected: "#f85149",
};
export default function TerminalScreen({ route }: Props) {
const { sessionId, terminalWsUrl } = route.params;
const webViewRef = useRef<WebView>(null);
const insets = useSafeAreaInsets();
const [status, setStatus] = useState<ConnectionStatus>("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 (
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
<StatusBar barStyle="light-content" backgroundColor="#0d1117" />
{/* Status indicator in top-right */}
<View style={styles.statusDot}>
<View style={[styles.dot, { backgroundColor: dotColor }]} />
<Text style={[styles.statusText, { color: dotColor }]}>
{status}
</Text>
</View>
<WebView
ref={webViewRef}
source={{ html: TERMINAL_HTML }}
injectedJavaScriptBeforeContentLoaded={injectedJS}
onMessage={handleMessage}
onLoad={handleLoad}
style={styles.webView}
originWhitelist={["*"]}
// Required for ws:// from about:blank on Android
mixedContentMode="always"
allowFileAccess={false}
javaScriptEnabled={true}
domStorageEnabled={false}
scrollEnabled={true}
bounces={false}
overScrollMode="never"
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
keyboardDisplayRequiresUserAction={false}
// Suppress "Can't open file" logs for blob: URLs
onError={() => {}}
/>
</View>
);
}
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",
},
});

View File

@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Terminal</title>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"><\/script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"><\/script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: #0d1117; overflow: hidden; }
#terminal { width: 100%; height: 100%; }
#status {
position: fixed;
top: 6px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #8b949e;
z-index: 10;
transition: background 0.3s;
}
#status.connecting { background: #e3b341; }
#status.connected { background: #3fb950; }
#status.error { background: #f85149; }
<\/style>
</head>
<body>
<div id="status" class="connecting"></div>
<div id="terminal"></div>
<script>
(function () {
var WS_BASE = window.AO_WS_URL || 'ws://localhost:3003';
var SESSION = window.AO_SESSION_ID || '';
var statusEl = document.getElementById('status');
var term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#e6edf3',
black: '#0d1117',
red: '#f85149',
green: '#3fb950',
yellow: '#e3b341',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#b1bac4',
brightBlack: '#6e7681',
brightRed: '#ff7b72',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc',
},
allowProposedApi: true,
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
fitAddon.fit();
function postToRN(obj) {
try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
}
} catch (e) { /* ignore */ }
}
function sendResize() {
fitAddon.fit();
var cols = term.cols;
var rows = term.rows;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: cols, rows: rows }));
}
postToRN({ type: 'resize', cols: cols, rows: rows });
}
window.addEventListener('resize', sendResize);
var ws;
var reconnectDelay = 1000;
function connect() {
var url = WS_BASE + '/ws?session=' + encodeURIComponent(SESSION);
statusEl.className = 'connecting';
postToRN({ type: 'status', state: 'connecting' });
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = function () {
statusEl.className = 'connected';
postToRN({ type: 'status', state: 'connected' });
reconnectDelay = 1000;
sendResize();
};
ws.onmessage = function (evt) {
if (typeof evt.data === 'string') {
// Filter out JSON control messages (e.g. resize echoes)
try {
var msg = JSON.parse(evt.data);
if (msg.type === 'resize') return; // echo, ignore
} catch (e) { /* not JSON — write as terminal text */ }
term.write(evt.data);
} else {
// Binary (ArrayBuffer)
term.write(new Uint8Array(evt.data));
}
};
ws.onerror = function () {
statusEl.className = 'error';
postToRN({ type: 'status', state: 'error' });
};
ws.onclose = function () {
statusEl.className = 'error';
postToRN({ type: 'status', state: 'disconnected' });
setTimeout(function () {
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
connect();
}, reconnectDelay);
};
}
term.onData(function (data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// XDA handler: respond to CSI > q with XTerm identity (enables tmux clipboard)
// Must use parser.registerCsiHandler — onData only captures outgoing user input,
// not incoming data from the WebSocket. The XDA query arrives via ws → term.write().
term.parser.registerCsiHandler({ prefix: '>', final: 'q' }, function () {
term.write('\\x1bP>|XTerm(370)\\x1b\\\\');
return true;
});
document.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
window.addEventListener('message', function (evt) { handleRNMessage(evt.data); });
function handleRNMessage(raw) {
try {
var msg = JSON.parse(raw);
if (msg.type === 'fit') sendResize();
else if (msg.type === 'focus') term.focus();
} catch (e) { /* ignore */ }
}
if (SESSION) {
connect();
} else {
term.write('\\x1b[31mError: No session ID provided.\\x1b[0m\\r\\n');
statusEl.className = 'error';
}
})();
<\/script>
</body>
</html>`;

View File

@ -1,222 +0,0 @@
/**
* Mobile-local types mirroring the web dashboard types.
* These must stay in sync with packages/web/src/lib/types.ts.
*/
export type SessionStatus =
| "spawning"
| "working"
| "pr_open"
| "ci_failed"
| "review_pending"
| "changes_requested"
| "approved"
| "mergeable"
| "merged"
| "cleanup"
| "needs_input"
| "stuck"
| "errored"
| "killed"
| "done"
| "terminated";
export type ActivityState =
| "active"
| "ready"
| "idle"
| "waiting_input"
| "blocked"
| "exited";
export type CIStatus = "none" | "pending" | "passing" | "failing";
export type ReviewDecision = "none" | "pending" | "approved" | "changes_requested";
export type AttentionLevel = "merge" | "respond" | "review" | "pending" | "working" | "done";
export interface DashboardCICheck {
name: string;
status: string;
url?: string;
}
export interface DashboardMergeability {
mergeable: boolean;
ciPassing: boolean;
approved: boolean;
noConflicts: boolean;
blockers: string[];
}
export interface DashboardUnresolvedComment {
url: string;
path: string;
author: string;
body: string;
}
export interface DashboardPR {
number: number;
url: string;
title: string;
owner: string;
repo: string;
branch: string;
baseBranch: string;
isDraft: boolean;
state: "open" | "merged" | "closed";
additions: number;
deletions: number;
ciStatus: CIStatus;
ciChecks: DashboardCICheck[];
reviewDecision: ReviewDecision;
mergeability: DashboardMergeability;
unresolvedThreads: number;
unresolvedComments?: DashboardUnresolvedComment[];
}
export interface DashboardSession {
id: string;
projectId: string;
status: SessionStatus;
activity: ActivityState | null;
branch: string | null;
issueId: string | null;
issueUrl: string | null;
issueLabel: string | null;
issueTitle: string | null;
summary: string | null;
summaryIsFallback: boolean;
createdAt: string;
lastActivityAt: string;
pr: DashboardPR | null;
metadata: Record<string, string>;
}
export interface DashboardStats {
totalSessions: number;
workingSessions: number;
openPRs: number;
needsReview: number;
}
export interface SessionsResponse {
sessions: DashboardSession[];
stats: DashboardStats;
orchestratorId: string | null;
}
/** Attention level colors matching the web dashboard */
export const ATTENTION_COLORS: Record<AttentionLevel, string> = {
merge: "#3fb950",
respond: "#f85149",
review: "#d29922",
pending: "#e3b341",
working: "#58a6ff",
done: "#8b949e",
};
/** Statuses that indicate the session is in a terminal (dead) state.
* Must stay in sync with packages/core/src/types.ts TERMINAL_STATUSES. */
const TERMINAL_STATUSES: SessionStatus[] = ["killed", "terminated", "done", "cleanup", "errored", "merged"];
const TERMINAL_ACTIVITIES: ActivityState[] = ["exited"];
/** Statuses that must never be restored (e.g. already merged).
* Must stay in sync with packages/core/src/types.ts NON_RESTORABLE_STATUSES. */
const NON_RESTORABLE_STATUSES: SessionStatus[] = ["merged"];
export function isTerminal(session: DashboardSession): boolean {
return (
TERMINAL_STATUSES.includes(session.status) ||
(session.activity !== null && TERMINAL_ACTIVITIES.includes(session.activity))
);
}
export function isRestorable(session: DashboardSession): boolean {
return isTerminal(session) && !NON_RESTORABLE_STATUSES.includes(session.status);
}
export function isPRRateLimited(pr: DashboardPR): boolean {
return pr.mergeability.blockers.includes("API rate limited or unavailable");
}
/** Determines which attention zone a session belongs to */
export function getAttentionLevel(session: DashboardSession): AttentionLevel {
// Done: terminal states
if (
session.status === "merged" ||
session.status === "killed" ||
session.status === "cleanup" ||
session.status === "done" ||
session.status === "terminated"
) {
return "done";
}
if (session.pr) {
if (session.pr.state === "merged" || session.pr.state === "closed") {
return "done";
}
}
// Merge: PR is ready
if (session.status === "mergeable" || session.status === "approved") {
return "merge";
}
if (session.pr?.mergeability.mergeable) {
return "merge";
}
// Respond: agent waiting for human input
if (session.activity === "waiting_input" || session.activity === "blocked") {
return "respond";
}
if (
session.status === "needs_input" ||
session.status === "stuck" ||
session.status === "errored"
) {
return "respond";
}
if (session.activity === "exited") {
return "respond";
}
// Review: problems that need investigation
if (session.status === "ci_failed" || session.status === "changes_requested") {
return "review";
}
if (session.pr && !isPRRateLimited(session.pr)) {
const pr = session.pr;
if (pr.ciStatus === "failing") return "review";
if (pr.reviewDecision === "changes_requested") return "review";
if (!pr.mergeability.noConflicts) return "review";
}
// Pending: waiting on external
if (session.status === "review_pending") {
return "pending";
}
if (session.pr && !isPRRateLimited(session.pr)) {
const pr = session.pr;
if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending";
if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
return "pending";
}
}
// Working: agents doing their thing
return "working";
}
/** Human-readable relative time */
export function relativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}

Some files were not shown because too many files have changed in this diff Show More