Merge pull request #2166 from AgentWrapper/reverbcode-port-overlay
Reverbcode port overlay
This commit is contained in:
commit
2e12a4451f
|
|
@ -1,8 +0,0 @@
|
|||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [
|
||||
[
|
||||
"@aoagents/ao-core",
|
||||
"@aoagents/ao-cli",
|
||||
"@aoagents/ao",
|
||||
"@aoagents/ao-notifier-macos",
|
||||
"@aoagents/ao-plugin-runtime-tmux",
|
||||
"@aoagents/ao-plugin-runtime-process",
|
||||
"@aoagents/ao-plugin-agent-claude-code",
|
||||
"@aoagents/ao-plugin-agent-codex",
|
||||
"@aoagents/ao-plugin-agent-aider",
|
||||
"@aoagents/ao-plugin-agent-opencode",
|
||||
"@aoagents/ao-plugin-agent-cursor",
|
||||
"@aoagents/ao-plugin-agent-kimicode",
|
||||
"@aoagents/ao-plugin-workspace-worktree",
|
||||
"@aoagents/ao-plugin-workspace-clone",
|
||||
"@aoagents/ao-plugin-tracker-github",
|
||||
"@aoagents/ao-plugin-tracker-linear",
|
||||
"@aoagents/ao-plugin-tracker-gitlab",
|
||||
"@aoagents/ao-plugin-scm-github",
|
||||
"@aoagents/ao-plugin-scm-gitlab",
|
||||
"@aoagents/ao-plugin-notifier-desktop",
|
||||
"@aoagents/ao-plugin-notifier-slack",
|
||||
"@aoagents/ao-plugin-notifier-webhook",
|
||||
"@aoagents/ao-plugin-notifier-composio",
|
||||
"@aoagents/ao-plugin-notifier-dashboard",
|
||||
"@aoagents/ao-plugin-notifier-discord",
|
||||
"@aoagents/ao-plugin-notifier-openclaw",
|
||||
"@aoagents/ao-plugin-terminal-iterm2",
|
||||
"@aoagents/ao-plugin-terminal-web",
|
||||
"@aoagents/ao-web"
|
||||
]
|
||||
],
|
||||
"snapshot": {
|
||||
"useCalculatedVersion": true,
|
||||
"prereleaseTemplate": "{tag}-{commit}"
|
||||
},
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@aoagents/ao-integration-tests"]
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# BugBot Configuration
|
||||
|
||||
## Project Context
|
||||
|
||||
Agent Orchestrator is a TypeScript monorepo for managing parallel AI coding agents. It uses pnpm workspaces with packages under `packages/`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- TypeScript (strict mode, ESM with `.js` extensions in imports)
|
||||
- Node.js 20+ (use `node:` prefix for built-in modules)
|
||||
- pnpm workspaces
|
||||
- Next.js 15 (App Router) for web dashboard
|
||||
- Commander.js for CLI
|
||||
- vitest for testing
|
||||
|
||||
## Review Focus
|
||||
|
||||
- **Security**: Watch for command injection (especially in shell/tmux/git/PowerShell commands and Windows named-pipe session IDs — `validateSessionId()` should guard those), AppleScript injection, GraphQL injection, unsanitized user input in API routes
|
||||
- **Shell execution**: Prefer `execFile` over `exec` to avoid shell injection. Flag any use of `exec` or string concatenation in shell commands
|
||||
- **Plugin pattern**: Plugins must export `{ manifest, create } satisfies PluginModule<T>` with types from `@aoagents/ao-core`
|
||||
- **Type safety**: Flag `as unknown as T` casts, unguarded `JSON.parse`, and type re-declarations that should import from core
|
||||
- **Resource leaks**: Check for uncleared intervals/timeouts, uncleaned event listeners, missing `cancel()` on streams
|
||||
- **ESM compliance**: Imports must use `.js` extension for local files, `node:` prefix for builtins
|
||||
|
||||
## Ignore
|
||||
|
||||
- `packages/web/src/lib/mock-data.ts` — temporary mock data, will be replaced
|
||||
- `scripts/` — legacy bash scripts, not part of the TypeScript codebase
|
||||
- `artifacts/` — design documents, not code
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
# Copilot Instructions
|
||||
|
||||
Instructions for GitHub Copilot when generating code, reviewing PRs, and suggesting changes in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Agent Orchestrator (AO) is a TypeScript monorepo that manages fleets of parallel AI coding agents. Each agent gets its own git worktree, branch, and PR. The system handles CI feedback routing, review comment handling, and session lifecycle.
|
||||
|
||||
**Stack:** TypeScript (strict), pnpm monorepo, Next.js 15 + React 19, Tailwind CSS v4, Vitest, ESLint flat config.
|
||||
|
||||
**Architecture:** 8 plugin slots (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Lifecycle). All interfaces are defined in `packages/core/src/types.ts`. There is no database; the system uses flat files and memory.
|
||||
|
||||
Full conventions: `CLAUDE.md`. Plugin development: `docs/DEVELOPMENT.md`. Design system: `DESIGN.md`.
|
||||
|
||||
---
|
||||
|
||||
## Code Generation Rules
|
||||
|
||||
### Think Before Generating
|
||||
|
||||
- If a task is ambiguous, suggest the two most likely interpretations and ask which one applies. Do not choose silently.
|
||||
- If there is a simpler approach than the one requested, say so. Push back when warranted.
|
||||
- State assumptions explicitly when generating non-trivial code.
|
||||
|
||||
### Simplicity First
|
||||
|
||||
- No speculative features. No abstractions for single-use code. No "flexibility" that was not requested.
|
||||
- Plugin slots are the extension point. If the user asks for configurability, consider whether a new plugin slot is the right answer instead.
|
||||
- If you are generating 200 lines and it could be 50, rewrite it.
|
||||
- Do not add error handling for impossible scenarios.
|
||||
|
||||
### Match Existing Patterns
|
||||
|
||||
- Before generating new code in an existing file, read how similar features are already implemented in that same file. Match the pattern.
|
||||
- Do not introduce new patterns when established ones already exist. Search the codebase first.
|
||||
- Match existing naming conventions, import styles, and file organization.
|
||||
- Use `@aoagents/ao-core` for cross-package imports.
|
||||
- Use the `workspace:*` protocol in `package.json`.
|
||||
|
||||
### TypeScript Strict Mode
|
||||
|
||||
- No `any` types unless they are in test files, where `any` and `console.log` are allowed.
|
||||
- Use `import type { Foo }` for type-only imports.
|
||||
- Prefix unused variables with `_`.
|
||||
- Do not use `eval`, `new Function`, or `require()`; use ES module imports.
|
||||
|
||||
### Web / UI Specific
|
||||
|
||||
- Use Tailwind utility classes only. Do not use inline `style=` attributes.
|
||||
- Use CSS custom properties via `var(--color-*)` from the `globals.css` `@theme` block. Never hardcode hex colors.
|
||||
- Do not use external UI component libraries such as Radix, shadcn, or Headless UI.
|
||||
- Preserve the dark theme at all times.
|
||||
- Border radius must be `0px` everywhere except status dots and avatar circles. Hard edges are part of the visual identity.
|
||||
- Mark client components with `"use client"`. Use server components for pages.
|
||||
- SSE updates run at a 5-second interval via the `useSessionEvents` hook. Do not change this interval.
|
||||
- Keep component files under 400 lines.
|
||||
|
||||
---
|
||||
|
||||
## PR Review Instructions
|
||||
|
||||
### What to Focus On
|
||||
|
||||
These are the areas where Copilot review adds the most value: issues CI cannot catch.
|
||||
|
||||
**1. Design over implementation.** A perfectly coded bad design is worse than a messy good one. Question:
|
||||
- Side-channel communication, such as hidden flags or dynamic attribute setting
|
||||
- Boolean parameters that switch between fundamentally different behaviors and should be separate code paths
|
||||
- New internal contracts between components without interface documentation
|
||||
- Missing migration paths for behavioral changes
|
||||
|
||||
**2. Pattern consistency.** If a file uses one pattern and the PR introduces a different one, flag it. Common violations:
|
||||
- Using class attributes in one place and instance properties in another for the same concept
|
||||
- Mixing callback styles when the file uses one style consistently
|
||||
- Introducing a new error-handling pattern when the file uses `throw new Error("msg", { cause: err })`
|
||||
|
||||
**3. State machine safety.** Changes to `SessionStatus`, `ActivityState`, or lifecycle transitions require extra scrutiny:
|
||||
- Verify that no invalid state transitions are introduced
|
||||
- Check that `isTerminalSession()` and `TERMINAL_STATUSES` are updated if new statuses are added
|
||||
- Flag any change that could cause a session to be incorrectly marked `killed` or `exited`
|
||||
|
||||
**4. Plugin interface stability.** Any change to interfaces in `types.ts` is potentially breaking:
|
||||
- New required methods on plugin interfaces break all existing plugins
|
||||
- Changed method signatures break all existing plugins
|
||||
- New optional methods are acceptable
|
||||
- Flag any non-optional interface change as "breaking — requires updating all N plugins implementing this slot"
|
||||
|
||||
**5. Backward compatibility.** Flag changes to:
|
||||
- CLI flags or arguments in `packages/cli/`
|
||||
- Config schema, including `agent-orchestrator.yaml` structure and Zod validation in `packages/core/src/config.ts`
|
||||
- Exported types from `packages/core/src/index.ts`, which are a stable public API and should not break
|
||||
- Default config values or behavior
|
||||
|
||||
**6. Plugin isolation.** Plugins must never import each other directly. They communicate through:
|
||||
- The `Session` object
|
||||
- The `LifecycleManager` event system
|
||||
- Core utilities exported from `@aoagents/ao-core`
|
||||
|
||||
**7. Resource cleanup.** Check that:
|
||||
- File handles, subprocesses, and runtime sessions (tmux on Unix, ConPTY pty-host processes on Windows) are cleaned up on all exit paths: success, error, and early return
|
||||
- `destroy()` methods exist and use best-effort semantics
|
||||
- There are no resource leaks in error paths
|
||||
|
||||
**8. Shell safety.** Any command construction must use `shellEscape()` from `@aoagents/ao-core` for all dynamic arguments. Flag raw string interpolation in shell commands.
|
||||
|
||||
### What to Ignore
|
||||
|
||||
These are handled by automated tooling and should not be raised in review:
|
||||
|
||||
- Formatting, whitespace, and trailing commas; Prettier handles them
|
||||
- Import ordering; ESLint handles it
|
||||
- Type errors; TypeScript strict mode and CI catch them
|
||||
- Lint rule violations; ESLint and CI catch them
|
||||
- Conventional commit format; CI validates it
|
||||
- Test file style, including `any` types and `console.log`; relaxed rules apply there
|
||||
|
||||
### High-Risk Files
|
||||
|
||||
These files have a wide blast radius and deserve extra scrutiny:
|
||||
|
||||
| File | Why it's risky |
|
||||
|------|----------------|
|
||||
| `packages/core/src/types.ts` | All 8 plugin interfaces live here. Changes can break every plugin. |
|
||||
| `packages/core/src/lifecycle-manager.ts` | State machine and polling loop with subtle state dependencies. |
|
||||
| `packages/core/src/session-manager.ts` | Session CRUD + stale runtime reconciliation. `list()` persists `runtime_lost` to disk when enrichment detects dead runtimes. Invariant violations can cause phantom `killed` or `exited` sessions. |
|
||||
| `packages/core/src/lifecycle-state.ts` | Canonical lifecycle → legacy status mapping. New terminal reasons (e.g. `runtime_lost`) must be added to `deriveLegacyStatus()`. |
|
||||
| `packages/cli/src/commands/start.ts` | ao start/stop + Ctrl+C shutdown. Cross-project scoping logic is subtle — `ao stop <project>` must not kill parent process. On Windows, also calls `sweepWindowsPtyHosts()` to gracefully tear down detached ConPTY pty-host processes that `taskkill /T` cannot reach. |
|
||||
| `packages/core/src/config.ts` | Zod validation schema. Changes affect every `ao` command. |
|
||||
| `packages/core/src/index.ts` | Stable public API. Do not break it without deprecation. |
|
||||
| `packages/web/src/app/globals.css` | Design tokens used by 50+ components. Renaming tokens breaks the UI. |
|
||||
| `packages/cli/src/index.ts` | CLI entry point. Flag and argument changes are user-facing. |
|
||||
| `agent-orchestrator.yaml.example` | Config reference. It must stay in sync with the Zod schema. |
|
||||
|
||||
### Behavioral Rules for Reviews
|
||||
|
||||
1. **If it is worth mentioning, it is worth fixing.** Do not leave "nits" or minor suggestions. Only raise actionable findings with specific remediation.
|
||||
2. **Reference file paths and line numbers.** Name the specific function, class, or pattern the author should use instead. Do not give generic advice like "consider using a different approach."
|
||||
3. **Do not suggest refactoring adjacent code that already works.** Review the diff, not the whole file.
|
||||
4. **Every finding must trace to a specific line in the diff.** If you cannot point to the line, do not raise it.
|
||||
5. **Do not repeat points.** Each observation should appear exactly once in the review.
|
||||
6. **Assume competence.** The author knows the codebase. Explain only non-obvious context: why something is risky, not what it does.
|
||||
7. **For backward-compatible deprecations, provide the specific pattern:**
|
||||
- TypeScript: `@deprecated` JSDoc, `console.warn`, and preserved old behavior during the deprecation period
|
||||
- Config: keep the old key working with a warning and add the new key
|
||||
- CLI: keep the old flag working and add a deprecation notice to `--help`
|
||||
|
||||
### Review Output Format
|
||||
|
||||
Omit sections where you have no findings. Do not write "No concerns" for empty sections.
|
||||
|
||||
Summary
|
||||
[1-2 sentence overall assessment]
|
||||
|
||||
Architecture & Design
|
||||
[Pattern violations, design issues, missing abstractions]
|
||||
|
||||
State Machine / Lifecycle
|
||||
[Any changes to session status, activity state, or transitions]
|
||||
|
||||
Plugin Interface Stability
|
||||
[Breaking interface changes, new required methods]
|
||||
|
||||
Backward Compatibility
|
||||
[Breaking changes to CLI, config, or exported APIs]
|
||||
|
||||
Testing
|
||||
[Missing edge cases, uncovered error paths, test adequacy]
|
||||
|
||||
Security
|
||||
[Shell injection, credential exposure, input validation]
|
||||
|
||||
Performance
|
||||
[Unnecessary allocations, missing cleanup, hot path regressions]
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns to Use
|
||||
|
||||
### Plugin Implementation
|
||||
|
||||
```typescript
|
||||
import type { PluginModule, Runtime } from "@aoagents/ao-core";
|
||||
|
||||
export const manifest = {
|
||||
name: "tmux",
|
||||
slot: "runtime" as const,
|
||||
description: "tmux session runtime",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(config?: Record<string, unknown>): Runtime {
|
||||
// Validate config here and store it via closure.
|
||||
return { /* ... */ };
|
||||
}
|
||||
|
||||
export function detect(): boolean {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
export default { manifest, create, detect } satisfies PluginModule<Runtime>;
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
// Wrap with cause for debugging.
|
||||
throw new Error("Failed to create tmux session", { cause: err });
|
||||
|
||||
// Return null for "not found", throw for unexpected errors.
|
||||
const issue = await tracker.getIssue("123"); // null if not found
|
||||
```
|
||||
|
||||
### Activity Detection
|
||||
|
||||
```typescript
|
||||
// Always implement the full cascade:
|
||||
// 1. Process check (exited if not running)
|
||||
// 2. Actionable states (waiting_input/blocked from JSONL)
|
||||
// 3. Native signal (agent-specific API)
|
||||
// 4. JSONL entry fallback (MUST NOT skip — use getActivityFallbackState())
|
||||
```
|
||||
|
||||
### Shell Commands
|
||||
|
||||
```typescript
|
||||
import { shellEscape } from "@aoagents/ao-core";
|
||||
|
||||
const cmd = `git checkout ${shellEscape(branchName)}`;
|
||||
// NEVER: `git checkout ${branchName}`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes to Flag
|
||||
|
||||
- **Cross-plugin imports.** Plugin A importing plugin B directly. It must go through core.
|
||||
- **Hardcoded secrets.** Use `process.env` and throw if the value is missing.
|
||||
- **Shell injection.** Dynamic values in shell commands without `shellEscape()`.
|
||||
- **Missing `setupWorkspaceHooks`.** A new agent plugin without metadata hooks means the dashboard will not show PRs.
|
||||
- **Skipping JSONL fallback.** An agent plugin's `getActivityState` without `getActivityFallbackState()` means the dashboard shows no activity.
|
||||
- **New `SessionStatus` without updating `isTerminalSession` / `TERMINAL_STATUSES`.** The session can get stuck in limbo.
|
||||
- **New session reason without updating `deriveLegacyStatus()`.** Terminal reasons like `runtime_lost` must map to a legacy status (e.g. `killed`), or sessions show wrong status.
|
||||
- **Scoping `useSessionEvents` with project filter in Dashboard.tsx.** The sidebar must see ALL sessions — only the Kanban filters by project (client-side via `projectSessions`).
|
||||
- **ao stop killing parent process when targeting a specific project.** `ao stop <project>` must only kill that project's sessions, not the parent `ao start` process or dashboard.
|
||||
- **CSS color hardcoding.** Using `#hex` or `rgb()` instead of `var(--color-*)` tokens.
|
||||
- **Rounded corners.** Using `rounded-md` or `rounded-lg` on cards or buttons. Hard edges only.
|
||||
- **External UI libraries.** Importing from Radix, shadcn, or Headless UI. Use native HTML and Tailwind.
|
||||
- **SSE interval changes.** Modifying the 5-second polling interval in `useSessionEvents`.
|
||||
- **Inline styles.** Using `style={{ ... }}` for theme values. Use Tailwind with `var(--token)` or a CSS class instead.
|
||||
- **New `package.json` dependencies without justification.** The monorepo should stay lean.
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
/**
|
||||
* Parses Vitest JSON coverage reports, filters to PR-changed files,
|
||||
* and writes a Markdown summary to coverage-comment.md.
|
||||
*
|
||||
* Expects:
|
||||
* - changed-files.txt in cwd (one relative path per line)
|
||||
* - coverage-final.json in each package's coverage/ directory
|
||||
*
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef -- Node.js CI script; process/console are globals */
|
||||
import { readFileSync, writeFileSync, existsSync, realpathSync, readdirSync } from "node:fs";
|
||||
import { resolve, relative } from "node:path";
|
||||
import libCoverage from "istanbul-lib-coverage";
|
||||
|
||||
const COMMENT_TAG = "<!-- coverage-report -->";
|
||||
const cwd = realpathSync(process.cwd());
|
||||
|
||||
// ── 1. Read changed files ──────────────────────────────────────────
|
||||
const changedFiles = readFileSync("changed-files.txt", "utf-8")
|
||||
.split("\n")
|
||||
.map((f) => f.trim())
|
||||
.filter((f) => f && (f.endsWith(".ts") || f.endsWith(".tsx")))
|
||||
.filter((f) => !f.includes("__tests__") && !f.includes(".test."));
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
const comment = `${COMMENT_TAG}\n## Test Coverage Report\n\n_No TypeScript source files changed in this PR._\n`;
|
||||
writeFileSync("coverage-comment.md", comment);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── 2. Discover and merge coverage-final.json files ────────────────
|
||||
function findCoverageFiles(baseDir) {
|
||||
const results = [];
|
||||
const packagesDir = resolve(baseDir, "packages");
|
||||
|
||||
function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
const full = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "coverage") {
|
||||
const jsonFile = resolve(full, "coverage-final.json");
|
||||
if (existsSync(jsonFile)) results.push(jsonFile);
|
||||
} else {
|
||||
walk(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(packagesDir);
|
||||
return results;
|
||||
}
|
||||
|
||||
const coverageMap = libCoverage.createCoverageMap({});
|
||||
|
||||
for (const jsonPath of findCoverageFiles(cwd)) {
|
||||
const raw = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||
coverageMap.merge(raw);
|
||||
}
|
||||
|
||||
// ── 3. Filter to changed files and collect metrics ─────────────────
|
||||
let totalLines = 0;
|
||||
let coveredLines = 0;
|
||||
const fileReports = [];
|
||||
|
||||
for (const absPath of coverageMap.files()) {
|
||||
// Normalize to handle symlinks (e.g. /tmp -> /private/tmp on macOS)
|
||||
const realAbsPath = existsSync(absPath) ? realpathSync(absPath) : absPath;
|
||||
const relPath = relative(cwd, realAbsPath);
|
||||
|
||||
if (!changedFiles.includes(relPath)) continue;
|
||||
|
||||
const fc = coverageMap.fileCoverageFor(absPath);
|
||||
const summary = fc.toSummary();
|
||||
const lineCoverage = fc.getLineCoverage();
|
||||
|
||||
const fileTotalLines = summary.lines.total;
|
||||
const fileCoveredLines = summary.lines.covered;
|
||||
const uncoveredLineNums = Object.entries(lineCoverage)
|
||||
.filter(([, hits]) => hits === 0)
|
||||
.map(([line]) => Number(line))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
totalLines += fileTotalLines;
|
||||
coveredLines += fileCoveredLines;
|
||||
|
||||
if (fileTotalLines > 0) {
|
||||
fileReports.push({
|
||||
path: relPath,
|
||||
total: fileTotalLines,
|
||||
covered: fileCoveredLines,
|
||||
pct: summary.lines.pct.toFixed(1),
|
||||
uncoveredLines: uncoveredLineNums,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Build Markdown comment ──────────────────────────────────────
|
||||
|
||||
/** Collapse consecutive line numbers into ranges: [1,2,3,7,9,10] -> "L1-L3, L7, L9-L10" */
|
||||
function consolidateRanges(lines) {
|
||||
if (lines.length === 0) return "";
|
||||
const ranges = [];
|
||||
let start = lines[0];
|
||||
let end = lines[0];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i] === end + 1) {
|
||||
end = lines[i];
|
||||
} else {
|
||||
ranges.push(start === end ? `L${start}` : `L${start}-L${end}`);
|
||||
start = lines[i];
|
||||
end = lines[i];
|
||||
}
|
||||
}
|
||||
ranges.push(start === end ? `L${start}` : `L${start}-L${end}`);
|
||||
return ranges.join(", ");
|
||||
}
|
||||
|
||||
let comment = `${COMMENT_TAG}\n## Test Coverage Report\n\n`;
|
||||
|
||||
if (fileReports.length === 0) {
|
||||
comment +=
|
||||
"_Changed files have no coverage data (not instrumented or no tests ran)._\n";
|
||||
} else {
|
||||
const pct =
|
||||
totalLines > 0 ? ((coveredLines / totalLines) * 100).toFixed(1) : "0.0";
|
||||
const uncoveredTotal = totalLines - coveredLines;
|
||||
|
||||
comment += "| Metric | Value |\n";
|
||||
comment += "|--------|-------|\n";
|
||||
comment += `| Lines covered | ${coveredLines}/${totalLines} |\n`;
|
||||
comment += `| Lines not covered | ${uncoveredTotal}/${totalLines} |\n`;
|
||||
comment += `| Overall coverage | ${pct}% |\n\n`;
|
||||
|
||||
// Per-file breakdown
|
||||
if (fileReports.length > 1) {
|
||||
comment += "<details>\n<summary>Per-file breakdown</summary>\n\n";
|
||||
comment += "| File | Coverage |\n";
|
||||
comment += "|------|----------|\n";
|
||||
for (const f of fileReports.sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
comment += `| \`${f.path}\` | ${f.covered}/${f.total} (${f.pct}%) |\n`;
|
||||
}
|
||||
comment += "\n</details>\n\n";
|
||||
}
|
||||
|
||||
// Uncovered lines section
|
||||
const filesWithUncovered = fileReports.filter(
|
||||
(f) => f.uncoveredLines.length > 0,
|
||||
);
|
||||
if (filesWithUncovered.length > 0) {
|
||||
comment += "### Uncovered lines\n\n";
|
||||
for (const file of filesWithUncovered.sort((a, b) =>
|
||||
a.path.localeCompare(b.path),
|
||||
)) {
|
||||
const ranges = consolidateRanges(file.uncoveredLines);
|
||||
comment += `- \`${file.path}\`: ${ranges}\n`;
|
||||
}
|
||||
comment += "\n";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
writeFileSync("coverage-comment.md", comment);
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
name: Canary
|
||||
|
||||
# Two-stage release pipeline (nightly canary side).
|
||||
#
|
||||
# Nightly canary creates snapshot versions, tags, and a GitHub prerelease.
|
||||
# npm publishing is handled by a private cron job (AO) that polls GitHub
|
||||
# releases and publishes when a new prerelease tag is ahead of the current
|
||||
# npm nightly version.
|
||||
#
|
||||
# No NPM_TOKEN or publisher dispatch secrets are needed in this repo.
|
||||
# The only secret used is GITHUB_TOKEN (automatic).
|
||||
#
|
||||
# Cron schedule: 23:30 IST = 18:00 UTC, on Fri,Sat,Sun,Mon,Tue
|
||||
# (DOW 5,6,0,1,2). Wed/Thu are the bake window — no scheduled publishes.
|
||||
# `workflow_dispatch` lets the release captain re-cut a nightly during
|
||||
# bake when a fix lands and the Discord cohort should test the patched
|
||||
# candidate.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 18 * * 5,6,0,1,2"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: canary
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
canary:
|
||||
name: Publish canary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
# Skip-if-unchanged guard: if the most recent prerelease in this
|
||||
# repo was cut from the current tip of main, there's nothing new
|
||||
# to publish — short-circuit the whole job. Prevents republishing
|
||||
# the same SHA on every cron tick during quiet stretches.
|
||||
#
|
||||
# We filter to prereleases only (`isPrerelease == true`). If the
|
||||
# most recent release were a stable cut from `release.yml`, an
|
||||
# explicit `workflow_dispatch` nightly right after the stable
|
||||
# release would otherwise be suppressed — which we don't want.
|
||||
#
|
||||
# The previous-nightly tag points at the snapshot commit created
|
||||
# by the "Commit snapshot version bumps" step below. The snapshot
|
||||
# commit's first parent is the source main HEAD, so `${LAST_TAG}^`
|
||||
# is the right anchor to compare against `GITHUB_SHA` (main HEAD
|
||||
# at this run).
|
||||
#
|
||||
# `workflow_dispatch` always proceeds — recovery path for partial
|
||||
# failures. A human triggered the run, they want it to run.
|
||||
- name: Check if main has new commits since the last nightly
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ]; then
|
||||
echo 'Manual trigger via workflow_dispatch — bypassing skip guard.'
|
||||
echo 'skip=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LAST_TAG=$(gh release list --json tagName,isPrerelease --jq '[.[] | select(.isPrerelease)][0].tagName // empty')
|
||||
LAST_SHA=""
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
LAST_SHA=$(git rev-parse "${LAST_TAG}^" 2>/dev/null || echo "")
|
||||
fi
|
||||
if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" = "$GITHUB_SHA" ]; then
|
||||
echo "Last prerelease ($LAST_TAG) was cut from $GITHUB_SHA — skipping."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
# No `registry-url`: this workflow does not publish to npm.
|
||||
|
||||
- if: steps.check.outputs.skip != 'true'
|
||||
run: echo "HUSKY=0" >> $GITHUB_ENV
|
||||
|
||||
- if: steps.check.outputs.skip != 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- if: steps.check.outputs.skip != 'true'
|
||||
run: pnpm -r build
|
||||
|
||||
# Pre-publish guard: same as release.yml — catches workspace:* deps
|
||||
# on private packages before they'd silently break a published
|
||||
# install. Still runs here so the public repo blocks a malformed
|
||||
# snapshot before it reaches npm.
|
||||
- if: steps.check.outputs.skip != 'true'
|
||||
run: node scripts/check-publishable-deps.mjs
|
||||
|
||||
- name: Create snapshot versions
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
# If no changesets exist (e.g. right after a Version Packages merge),
|
||||
# create a minimal one. `changeset version --snapshot` consumes and
|
||||
# deletes it, so no cleanup is needed.
|
||||
if [ -z "$(ls .changeset/*.md 2>/dev/null | grep -vi 'README')" ]; then
|
||||
node << 'SCRIPT'
|
||||
const fs = require('fs');
|
||||
const pkgs = [];
|
||||
|
||||
const addPackage = (m) => {
|
||||
if (!fs.existsSync(m)) return;
|
||||
const d = JSON.parse(fs.readFileSync(m, 'utf8'));
|
||||
if (!d.private && d.publishConfig?.access === 'public') pkgs.push(d.name);
|
||||
};
|
||||
|
||||
const scanPackages = (dir) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) addPackage(`${dir}/${entry.name}/package.json`);
|
||||
}
|
||||
};
|
||||
|
||||
scanPackages('packages');
|
||||
scanPackages('packages/plugins');
|
||||
|
||||
let body = '---\n';
|
||||
pkgs.forEach(p => body += '"' + p + '": patch\n');
|
||||
body += '---\n\nchore: canary build\n';
|
||||
fs.writeFileSync('.changeset/canary-temp.md', body);
|
||||
SCRIPT
|
||||
fi
|
||||
pnpm changeset version --snapshot nightly
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# `pnpm changeset version --snapshot` modifies package.json files
|
||||
# on disk but does NOT create a git commit. Without this commit,
|
||||
# the umbrella tag we push below would point at the pre-snapshot
|
||||
# HEAD, so when the npm publisher checks out the tag it would see
|
||||
# the un-bumped versions and either publish wrong version numbers
|
||||
# or fail trying to republish an existing version.
|
||||
#
|
||||
# The commit is NEVER pushed to main — only the tag below is
|
||||
# pushed, and the snapshot commit travels with it as part of the
|
||||
# tag's reachable history. `[skip ci]` is defensive in case any
|
||||
# future change ever pushes this commit to a branch.
|
||||
- name: Commit snapshot version bumps
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add packages/*/package.json packages/plugins/*/package.json .changeset/
|
||||
git diff --cached --quiet || git commit -m 'chore: snapshot version bump [skip ci]'
|
||||
|
||||
# Push the umbrella `vX.Y.Z` tag pointing at the snapshot commit.
|
||||
# The npm publisher resolves this tag to the snapshot commit with
|
||||
# the bumped package.json versions.
|
||||
#
|
||||
# We deliberately skip `pnpm changeset tag`: it would create one
|
||||
# tag per publishable package (~27 here) every night, which adds
|
||||
# up fast on the nightly cadence. The npm publisher only consumes
|
||||
# the umbrella tag, so the per-package tags are pure decoration.
|
||||
- name: Tag snapshot versions
|
||||
id: tag
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
version=$(node -p "require('./packages/ao/package.json').version")
|
||||
git tag "v$version"
|
||||
git push origin "v$version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Public-facing GitHub prerelease. `--prerelease` flags it as such
|
||||
# so consumers and tooling that filter by stability stay correct.
|
||||
# No `--target`: the tag was just pushed and GitHub resolves the
|
||||
# commitish from it (avoids the race where another commit lands
|
||||
# on main between the tag push and this step).
|
||||
- name: Create GitHub prerelease
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "v${{ steps.tag.outputs.version }}" \
|
||||
--prerelease \
|
||||
--generate-notes
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Minimal token scope — all jobs only checkout, install, build, and test.
|
||||
# None of them push code, comment on PRs, or call mutating GitHub APIs.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Build all non-web packages
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" build
|
||||
# Typecheck all non-web packages
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" typecheck
|
||||
# Build web (Next.js build includes its own typecheck)
|
||||
- run: pnpm --filter @aoagents/ao-web build
|
||||
|
||||
test:
|
||||
name: Test (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" build
|
||||
# Verify node-pty's Windows prebuild loads cleanly before any test that
|
||||
# depends on it. A broken prebuild fails this step in seconds with a
|
||||
# clear "node-pty" stack rather than a buried integration-test failure.
|
||||
- name: Verify node-pty prebuild (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: packages/plugins/runtime-process
|
||||
run: node -e "const p=require('node-pty');const t=p.spawn('cmd.exe',['/c','exit'],{cols:80,rows:24});t.onExit(({exitCode})=>process.exit(exitCode));setTimeout(()=>process.exit(2),5000)"
|
||||
- run: pnpm test
|
||||
|
||||
test-web:
|
||||
name: Test Web (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
# tmux is the Linux/macOS terminal runtime backing direct-terminal-ws
|
||||
# integration tests. Windows uses runtime-process + named pipes (covered
|
||||
# by mux-websocket-windows.test.ts) — those tmux tests self-skip.
|
||||
- name: Install tmux
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
- name: Start tmux server
|
||||
if: runner.os == 'Linux'
|
||||
run: tmux start-server
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r --filter "!@aoagents/ao-web" build
|
||||
# Full web suite — components, hooks, libs, app routes, and server tests.
|
||||
# Previously this job was scoped to server/__tests__/ only; broadening it
|
||||
# closes a long-standing coverage gap on both Linux and Windows.
|
||||
- run: pnpm --filter @aoagents/ao-web test
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
name: CLI E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "test/cli/**"
|
||||
- ".github/workflows/cli-e2e.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Primary tier: the cross-platform Go E2E suite (build tag `e2e`) runs the real
|
||||
# `ao` binary against isolated state on every OS GitHub hosts. These runners
|
||||
# are the "VMs" — the only place that exercises the OS-specific process-detach
|
||||
# paths (unix Setsid vs Windows CREATE_NEW_PROCESS_GROUP) and os.UserConfigDir
|
||||
# resolution. The suite builds its own binary and self-allocates a free port.
|
||||
native:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache: false
|
||||
|
||||
- name: CLI E2E (native)
|
||||
run: go test -tags e2e -v ./internal/cli/...
|
||||
|
||||
# Secondary hardening tier: prove that a freshly installed binary works on a
|
||||
# clean machine with no Go toolchain and no developer state. The Dockerfile
|
||||
# installs `ao` on PATH in a slim image and runs test/cli/install-check.sh.
|
||||
# --init gives a real PID-1 reaper so the daemon the check starts is reaped
|
||||
# after `stop` instead of lingering as a zombie.
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build fresh-install image
|
||||
run: docker build -f test/cli/Dockerfile -t ao-cli-smoke .
|
||||
|
||||
- name: Fresh-install check (container)
|
||||
run: docker run --rm --init ao-cli-smoke
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
name: PR Coverage Report
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: coverage-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Coverage Report
|
||||
runs-on: ubuntu-latest
|
||||
# Non-blocking: never prevent merging even if this job fails
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install tmux
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
|
||||
- name: Start tmux server
|
||||
run: tmux start-server
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm -r --filter '!@aoagents/ao-web' build
|
||||
|
||||
- name: Get changed files
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git diff --name-only origin/$BASE_REF...HEAD -- '*.ts' '*.tsx' > changed-files.txt
|
||||
echo "Changed TS files:"
|
||||
cat changed-files.txt
|
||||
|
||||
- name: Run tests with coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
CHANGED=$(cat changed-files.txt)
|
||||
|
||||
# Only run coverage for packages that have changed files
|
||||
if echo "$CHANGED" | grep -q '^packages/core/'; then
|
||||
echo "::group::Core tests"
|
||||
pnpm --filter @aoagents/ao-core exec vitest run --coverage.enabled --coverage.reporter=json || true
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -q '^packages/cli/'; then
|
||||
echo "::group::CLI tests"
|
||||
pnpm --filter @aoagents/ao-cli exec vitest run --coverage.enabled --coverage.reporter=json || true
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -q '^packages/web/'; then
|
||||
echo "::group::Web tests"
|
||||
pnpm --filter @aoagents/ao-web exec vitest run --coverage.enabled --coverage.reporter=json || true
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if echo "$CHANGED" | grep -q '^packages/plugins/'; then
|
||||
# Run coverage for each changed plugin
|
||||
for plugin_dir in $(echo "$CHANGED" | grep '^packages/plugins/' | cut -d/ -f1-3 | sort -u); do
|
||||
if [ -f "$plugin_dir/package.json" ]; then
|
||||
pkg_name=$(jq -r .name "$plugin_dir/package.json")
|
||||
echo "::group::$pkg_name tests"
|
||||
pnpm --filter "$pkg_name" exec vitest run --coverage.enabled --coverage.reporter=json || true
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
- name: Generate coverage report
|
||||
run: node .github/scripts/coverage-report.mjs
|
||||
|
||||
- name: Post or update PR comment
|
||||
# Fork PR tokens lack comment permissions — don't fail the job
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
REPO=${{ github.repository }}
|
||||
COMMENT_TAG="<!-- coverage-report -->"
|
||||
|
||||
# Find existing coverage comment
|
||||
COMMENT_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \
|
||||
--paginate --jq ".[] | select(.body | startswith(\"$COMMENT_TAG\")) | .id" \
|
||||
| tail -1)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
# Update may fail on fork PRs (403) — fall back to creating a new comment
|
||||
if gh api "repos/$REPO/issues/comments/$COMMENT_ID" \
|
||||
-X PATCH \
|
||||
-F body=@coverage-comment.md 2>/dev/null; then
|
||||
echo "Updated existing comment $COMMENT_ID"
|
||||
else
|
||||
echo "Could not update comment $COMMENT_ID, creating new one"
|
||||
gh pr comment "$PR_NUMBER" --body-file coverage-comment.md
|
||||
echo "Created new comment"
|
||||
fi
|
||||
else
|
||||
gh pr comment "$PR_NUMBER" --body-file coverage-comment.md
|
||||
echo "Created new comment"
|
||||
fi
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
name: Deploy to VPS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy_sha:
|
||||
description: Optional exact commit SHA to deploy. Defaults to the head of the dispatched ref.
|
||||
required: false
|
||||
type: string
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
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: Resolve deploy target
|
||||
id: resolve_deploy
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DEPLOY_SHA_INPUT: ${{ inputs.deploy_sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
DEPLOY_SHA="$DEPLOY_SHA_INPUT"
|
||||
if [ -z "$DEPLOY_SHA" ]; then
|
||||
DEPLOY_SHA="${GITHUB_SHA}"
|
||||
fi
|
||||
if ! printf '%s' "$DEPLOY_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
echo "Invalid deploy_sha: expected a 40-character lowercase hex SHA, got '$DEPLOY_SHA'." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Manual dispatch: deploying SHA $DEPLOY_SHA from ref ${GITHUB_REF_NAME}."
|
||||
echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "fetch_ref=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "should_deploy=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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."
|
||||
echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "fetch_ref=main" >> "$GITHUB_OUTPUT"
|
||||
echo "should_deploy=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "SHA verified: $DEPLOY_SHA is the current tip of main."
|
||||
echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "fetch_ref=main" >> "$GITHUB_OUTPUT"
|
||||
echo "should_deploy=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require VPS host fingerprint
|
||||
env:
|
||||
VPS_HOST_FINGERPRINT: ${{ secrets.VPS_HOST_FINGERPRINT }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "$VPS_HOST_FINGERPRINT" ]; then
|
||||
echo "VPS_HOST_FINGERPRINT secret is required for host key verification." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy via SSH
|
||||
if: steps.resolve_deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
DEPLOY_SHA: ${{ steps.resolve_deploy.outputs.deploy_sha }}
|
||||
FETCH_REF: ${{ steps.resolve_deploy.outputs.fetch_ref }}
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: aoagent
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
fingerprint: ${{ secrets.VPS_HOST_FINGERPRINT }}
|
||||
envs: DEPLOY_SHA,FETCH_REF
|
||||
script: |
|
||||
set -e
|
||||
cd /home/aoagent/agent-orchestrator
|
||||
echo "==> Ensuring full worktree..."
|
||||
if [ "$(git config --bool core.sparseCheckout || echo false)" = "true" ]; then
|
||||
git sparse-checkout disable
|
||||
fi
|
||||
echo "==> Fetching latest changes for $FETCH_REF..."
|
||||
git fetch origin "$FETCH_REF"
|
||||
echo "==> Current: $(git rev-parse --short HEAD)"
|
||||
echo "==> Deploying target SHA: ${DEPLOY_SHA:0:7}"
|
||||
git checkout --force "$DEPLOY_SHA"
|
||||
echo "==> Updated: $(git rev-parse --short HEAD)"
|
||||
test -f packages/web/server/start-all.ts
|
||||
echo "==> Installing dependencies..."
|
||||
pnpm install --frozen-lockfile
|
||||
echo "==> Building..."
|
||||
pnpm build
|
||||
echo "==> Release requirement: AO_ALLOW_FILESYSTEM_BROWSE=1 must be present in the ao web runtime for the multi-project add-project browser."
|
||||
echo "==> Restarting services..."
|
||||
pm2 restart ao --update-env
|
||||
pm2 restart openclaw-gateway --update-env || true
|
||||
echo "==> Deploy complete: $(git rev-parse --short HEAD)"
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
name: Desktop testing build
|
||||
|
||||
# Builds UNSIGNED desktop artifacts on a `0.0.0-testing-<sha>` tag and attaches
|
||||
# them to a GitHub prerelease, so the packaging pipeline can be exercised
|
||||
# end-to-end before any signing/notarization secrets exist.
|
||||
#
|
||||
# Per OS the current electron-forge makers produce:
|
||||
# - macOS → .zip (the .dmg maker is a follow-up)
|
||||
# - Windows → NSIS installer (.exe)
|
||||
# - Linux → .deb and .rpm
|
||||
#
|
||||
# Each OS builds on its own native runner because build-daemon.mjs compiles the
|
||||
# bundled `ao` daemon for the build host's platform; cross-OS packaging would
|
||||
# ship the wrong daemon (issues #235/#256). The macOS runner is arm64, so the
|
||||
# macOS artifact is arm64-only until per-arch builds are wired.
|
||||
#
|
||||
# Signing is intentionally OFF (no CSC_LINK / APPLE_ID / Windows cert), so these
|
||||
# builds do NOT pass Gatekeeper/SmartScreen. They are for pipeline validation,
|
||||
# not distribution.
|
||||
|
||||
# Disabled: the Linux-only `linux-testing-build.yml` owns the 0.0.0-testing-* tag
|
||||
# for now. Re-enable by restoring the `push.tags` trigger below when macOS/Windows
|
||||
# testing builds are wanted again.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# tags:
|
||||
# - "0.0.0-testing-*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
# The daemon is compiled by build-daemon.mjs during premake, so the Go
|
||||
# toolchain must be present and pinned on every runner.
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
# The Linux rpm maker needs rpmbuild, which ubuntu-latest does not ship.
|
||||
- name: Install rpm tooling (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
- run: npm ci
|
||||
- name: Build artifacts (unsigned)
|
||||
run: npm run make
|
||||
- name: Publish artifacts to the tag's GitHub release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Create the prerelease once. Parallel matrix jobs race here, so a
|
||||
# second job's "already exists" failure is expected and ignored.
|
||||
gh release create "$TAG" --prerelease --title "$TAG" \
|
||||
--notes "Unsigned desktop testing build (pipeline validation only — not signed or notarized)." \
|
||||
|| true
|
||||
# Upload every maker output. NUL-delimited to survive spaces in the
|
||||
# app name ("Agent Orchestrator-..."); --clobber makes re-runs idempotent.
|
||||
find out/make -type f -print0 | while IFS= read -r -d '' f; do
|
||||
echo "uploading: $f"
|
||||
gh release upload "$TAG" "$f" --clobber
|
||||
done
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
name: Desktop release
|
||||
|
||||
# Builds and publishes the Electron desktop app via electron-forge.
|
||||
# Generates a GitHub Release (draft) with installers + update manifests.
|
||||
# Triggered by a `desktop-v*` tag or manually.
|
||||
#
|
||||
# Each target OS builds on its own runner so the bundled `ao` daemon is compiled
|
||||
# natively for that platform. build-daemon.mjs keys the binary off the build
|
||||
# host's platform, so cross-OS packaging (e.g. building the Windows installer on
|
||||
# macOS) would ship a non-Windows binary named `ao` and the app could not launch
|
||||
# the daemon (issues #235/#256). The per-OS matrix keeps host == target.
|
||||
#
|
||||
# ⚠️ Until macOS code signing + notarization secrets are configured (see
|
||||
# frontend/docs/desktop-release.md), published builds are UNSIGNED and will
|
||||
# NOT auto-update on macOS. The workflow still produces installable artifacts.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "desktop-v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
# The daemon is compiled by build-daemon.mjs during prepackage/premake, so
|
||||
# the Go toolchain must be present and pinned on every runner.
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
- run: npm ci
|
||||
- name: Publish
|
||||
run: npm run publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# macOS signing + notarization — add as repository secrets and
|
||||
# set osxSign/osxNotarize in forge.config.ts to enable.
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
name: Frontend
|
||||
|
||||
# Runs the renderer vitest suite. This suite was silently dead for months
|
||||
# because no workflow executed it (vitest only auto-loads vite.config.ts /
|
||||
# vitest.config.ts, and the repo had neither until #171) — this job is the
|
||||
# guard against that happening again.
|
||||
#
|
||||
# Typecheck is intentionally NOT run here yet: forge.config.ts and
|
||||
# update-electron-app carry pre-existing type errors. Add `npm run typecheck`
|
||||
# once those are fixed.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Run vitest suite
|
||||
run: npx vitest run
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
name: gitleaks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# gitleaks-action v1 scans for committed secrets and needs no license
|
||||
# key (v2 requires GITLEAKS_LICENSE for organization repos).
|
||||
- name: Scan for secrets
|
||||
uses: zricethezav/gitleaks-action@v1.6.0
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
name: Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "frontend/src/api/schema.ts"
|
||||
- "package.json"
|
||||
- ".github/workflows/go.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
# Read the version from go.mod so CI can't drift from the module
|
||||
# (it previously pinned 1.22 while go.mod declared 1.25).
|
||||
go-version-file: backend/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "These files need gofmt:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -race ./...
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
# v8 of the action drives golangci-lint v2 (the schema this config uses);
|
||||
# the v6 action speaks v1 CLI flags and errors against a v2 binary.
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
# Pinned for reproducibility: bump intentionally rather than letting an
|
||||
# upstream release change CI. Must be built with Go >= the module's
|
||||
# (go.mod is 1.25); v2.12.2 is built with go1.25 — older v2 tags
|
||||
# (e.g. v2.1.x) are built with go1.24 and refuse to analyze 1.25 code.
|
||||
version: v2.12.2
|
||||
working-directory: backend
|
||||
# Blocking on the full ruleset: the tree is clean at zero findings, so
|
||||
# any new issue fails CI rather than being grandfathered.
|
||||
|
||||
api-drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Regenerate API spec and TS types
|
||||
run: npm run api
|
||||
|
||||
# openapi.yaml drift is already caught by TestBuild_MatchesEmbedded in
|
||||
# the build-test job (go test -race ./...). Only schema.ts needs checking here.
|
||||
- name: Check for schema.ts drift
|
||||
run: git diff --exit-code -- frontend/src/api/schema.ts
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
name: Integration Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch: # allow manual runs
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
# --- Install tmux ---
|
||||
- name: Install tmux
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
|
||||
# --- Start tmux server ---
|
||||
- name: Start tmux server
|
||||
run: tmux start-server
|
||||
|
||||
# --- Install agent binaries ---
|
||||
- name: Install Claude Code
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Install Codex
|
||||
run: npm install -g @openai/codex
|
||||
|
||||
- name: Install Aider
|
||||
run: pip install aider-chat
|
||||
|
||||
- name: Install OpenCode
|
||||
run: npm install -g opencode-ai
|
||||
|
||||
# --- Build project ---
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r --filter '!@aoagents/ao-web' build
|
||||
|
||||
# --- Run integration tests ---
|
||||
- name: Run integration tests
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
|
||||
# Note: COMPOSIO_API_KEY is intentionally not passed here.
|
||||
# When both keys are set, the plugin prefers the Composio transport
|
||||
# which requires @aoagents/core SDK installed. The direct LINEAR_API_KEY
|
||||
# transport needs no extra dependencies.
|
||||
run: pnpm test:integration
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
name: Onboarding Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'scripts/setup.sh'
|
||||
- 'tests/integration/**'
|
||||
- '.github/workflows/onboarding-test.yml'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
onboarding-test:
|
||||
name: Test Fresh Onboarding
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
|
||||
- name: Build test image
|
||||
working-directory: tests/integration
|
||||
run: docker compose build
|
||||
|
||||
- name: Run onboarding test
|
||||
id: test
|
||||
working-directory: tests/integration
|
||||
run: |
|
||||
docker compose up --abort-on-container-exit --exit-code-from onboarding-test
|
||||
|
||||
- name: Extract metrics
|
||||
if: always()
|
||||
run: |
|
||||
# Extract onboarding time from container logs
|
||||
docker logs ao-onboarding-test 2>&1 | grep "Total onboarding time" || echo "Metrics not available"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
working-directory: tests/integration
|
||||
run: docker compose down -v
|
||||
|
||||
- name: Upload test logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: onboarding-test-logs
|
||||
path: |
|
||||
tests/integration/*.log
|
||||
retention-days: 7
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
name: Prettier
|
||||
|
||||
# Auto-formats the codebase on every push and commits the result back.
|
||||
# Formatting is a CI concern — developers never need to run Prettier locally
|
||||
# and formatted output never shows up as local uncommitted changes.
|
||||
#
|
||||
# GitHub Actions does not re-trigger workflows on commits made with GITHUB_TOKEN,
|
||||
# so there is no feedback loop risk.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
- "entire/**"
|
||||
- "worktree-**"
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Format with Prettier
|
||||
run: npx --yes prettier@3 --write .
|
||||
|
||||
- name: Commit formatted files
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git diff --quiet && exit 0
|
||||
git add -A
|
||||
git commit -m "chore: format with prettier [skip ci]"
|
||||
git push
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
name: React Doctor
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/src/landing/**"
|
||||
- ".github/workflows/react-doctor.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/src/landing/**"
|
||||
- ".github/workflows/react-doctor.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
doctor:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Composite action: installs react-doctor itself, runs the scan against
|
||||
# the landing site, posts a sticky PR summary + inline review comments,
|
||||
# and publishes a commit status. Default blocking=error means only
|
||||
# error-severity findings fail the job; warnings are reported but don't
|
||||
# block.
|
||||
- uses: millionco/react-doctor@v2
|
||||
with:
|
||||
directory: frontend/src/landing
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
name: Release
|
||||
|
||||
# Two-stage release pipeline.
|
||||
#
|
||||
# This public repo is responsible for version bumps, tagging, and creating
|
||||
# the GitHub release. npm publishing is handled by a private cron job (AO)
|
||||
# that polls GitHub releases and publishes when a new tag is ahead of the
|
||||
# current npm version.
|
||||
#
|
||||
# No NPM_TOKEN or publisher dispatch secrets are needed in this repo.
|
||||
# The only secret used is GITHUB_TOKEN (automatic).
|
||||
#
|
||||
# See CONTRIBUTING.md → "Release architecture" for the full picture.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
# Depends on the workflow named "CI" in .github/workflows/ci.yml — if
|
||||
# you rename that file or change its `name:` field, update this string
|
||||
# too. GitHub matches by name (not filename) and silently no-ops on
|
||||
# mismatch — so a rename here will mean releases never trigger again.
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
# No `registry-url`: this workflow does not publish to npm.
|
||||
- run: echo "HUSKY=0" >> $GITHUB_ENV
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r build
|
||||
# Pre-publish guard: catches the case where a publishable package has a
|
||||
# workspace:* runtime dep on a `private: true` package — pnpm would
|
||||
# rewrite the dep on publish to a version that doesn't exist on npm,
|
||||
# breaking `npm install -g @aoagents/ao`. Still runs here so the
|
||||
# public repo blocks a malformed version bump before it reaches npm.
|
||||
- run: node scripts/check-publishable-deps.mjs
|
||||
|
||||
# changesets/action manages the "Version Packages" PR — when there
|
||||
# are pending changesets it opens/updates the PR; when there are
|
||||
# none (i.e. that PR was just merged) it would normally invoke the
|
||||
# `publish:` command. We deliberately omit `publish:` so the action
|
||||
# never runs `changeset publish`. npm publishing is handled by a
|
||||
# private cron that detects the GitHub release.
|
||||
- uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b
|
||||
id: changesets
|
||||
with:
|
||||
version: pnpm changeset version
|
||||
title: "chore: version packages"
|
||||
commit: "chore: version packages"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Determine release state. Each downstream step (tag push, GH
|
||||
# release creation) is gated on its own piece of state so the
|
||||
# workflow is idempotent and recovers cleanly on re-run after a
|
||||
# partial failure.
|
||||
#
|
||||
# `is_release_commit` is the crucial signal: it filters out
|
||||
# regular commits to main (which also have `hasChangesets ==
|
||||
# 'false'` but should NOT trigger a publish). We detect a
|
||||
# version-bump commit by comparing the umbrella package's
|
||||
# version against its value in the parent commit — a Version
|
||||
# Packages merge changes that version, a regular commit does not.
|
||||
# The umbrella `@aoagents/ao` is the canonical version source for
|
||||
# the `vX.Y.Z` tag scheme and is part of the linked group, so
|
||||
# any release that bumps the cohort will bump this file.
|
||||
- name: Determine release state
|
||||
id: state
|
||||
if: steps.changesets.outputs.hasChangesets == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
version=$(node -p "require('./packages/ao/package.json').version")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prev_version=""
|
||||
if git rev-parse HEAD^ >/dev/null 2>&1; then
|
||||
prev_version=$(git show HEAD^:packages/ao/package.json 2>/dev/null | jq -r .version 2>/dev/null || echo "")
|
||||
fi
|
||||
if [ -n "$prev_version" ] && [ "$prev_version" != "$version" ]; then
|
||||
echo "is_release_commit=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_release_commit=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if git ls-remote --tags --exit-code origin "refs/tags/v$version" >/dev/null 2>&1; then
|
||||
echo "tag_on_remote=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag_on_remote=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if gh release view "v$version" --json tagName >/dev/null 2>&1; then
|
||||
echo "release_exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release_exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Push the umbrella `vX.Y.Z` tag only if this is a fresh
|
||||
# version-bump commit AND the tag isn't already on the remote.
|
||||
# Skipped on re-runs where the tag was pushed on a prior run
|
||||
# (idempotent recovery).
|
||||
#
|
||||
# We deliberately skip `pnpm changeset tag`: it would create one
|
||||
# tag per publishable package (~27 here) on every release, which
|
||||
# creates partial-recovery conflicts on re-run when `git push --tags`
|
||||
# tries to re-push existing per-package tags. The npm publisher
|
||||
# only consumes the umbrella tag, so the per-package tags add no
|
||||
# value.
|
||||
- name: Tag versioned packages
|
||||
if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.tag_on_remote == 'false'
|
||||
run: |
|
||||
git tag "v${{ steps.state.outputs.version }}"
|
||||
git push origin "v${{ steps.state.outputs.version }}"
|
||||
|
||||
# Public-facing GitHub release. Stable channel — not a prerelease.
|
||||
# No `--target`: the `vX.Y.Z` tag is on the remote at the
|
||||
# version-bump commit, and `gh release create` resolves the
|
||||
# commitish from the existing tag. Avoids the race where another
|
||||
# commit lands on main between the tag push and this step,
|
||||
# pulling unrelated commits into the auto-generated release notes.
|
||||
# Skipped if a release for this version already exists.
|
||||
- name: Create GitHub release
|
||||
if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.release_exists == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "v${{ steps.state.outputs.version }}" \
|
||||
--generate-notes
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
name: Security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Run weekly to catch new vulnerabilities
|
||||
- cron: "0 8 * * 1"
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: Scan for Secrets
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 0 # Full history to ensure base/head SHAs are available for PR scans
|
||||
|
||||
- name: Install Gitleaks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GITLEAKS_VERSION="8.24.3"
|
||||
GITLEAKS_BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
|
||||
RUNNER_ARCH="$(uname -m)"
|
||||
|
||||
case "${RUNNER_ARCH}" in
|
||||
x86_64|amd64)
|
||||
GITLEAKS_ARCH="x64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
GITLEAKS_ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported runner architecture: ${RUNNER_ARCH}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ARCHIVE_NAME="gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz"
|
||||
|
||||
# Download gitleaks archive using the release filename so checksum verification works
|
||||
curl -sSfL "${GITLEAKS_BASE_URL}/${ARCHIVE_NAME}" -o "${ARCHIVE_NAME}"
|
||||
|
||||
# Download the combined checksums file
|
||||
curl -sSfL "${GITLEAKS_BASE_URL}/gitleaks_${GITLEAKS_VERSION}_checksums.txt" -o gitleaks_checksums.txt
|
||||
|
||||
# Verify checksum (sha256sum -c expects the filename in the checksums file to match the local file)
|
||||
grep "${ARCHIVE_NAME}" gitleaks_checksums.txt | sha256sum -c -
|
||||
|
||||
# Extract the verified binary to a user-writable directory and add it to PATH
|
||||
INSTALL_DIR="${RUNNER_TEMP}/gitleaks-bin"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
tar -xzf "${ARCHIVE_NAME}" -C "${INSTALL_DIR}" gitleaks
|
||||
echo "${INSTALL_DIR}" >> "${GITHUB_PATH}"
|
||||
|
||||
# Clean up
|
||||
rm -f "${ARCHIVE_NAME}" gitleaks_checksums.txt
|
||||
|
||||
- name: Run Gitleaks
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
# Ensure the base and head SHAs exist locally when checkout uses the PR merge ref
|
||||
git fetch origin ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} --depth=1
|
||||
gitleaks detect --source . --verbose --log-opts "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
gitleaks detect --source . --verbose --log-opts "-n 10"
|
||||
fi
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3
|
||||
with:
|
||||
fail-on-severity: moderate
|
||||
|
||||
npm-audit:
|
||||
name: NPM Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run npm audit
|
||||
run: pnpm audit --audit-level=moderate
|
||||
continue-on-error: true # Don't fail build on vulnerabilities in deps
|
||||
|
||||
- name: Run pnpm audit (strict)
|
||||
run: pnpm audit --prod --audit-level=high
|
||||
continue-on-error: true # npm's legacy audit endpoint returns 410 Gone — non-blocking until pnpm upgrades to bulk advisory API
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
name: Testing build (all platforms)
|
||||
|
||||
# Unsigned testing builds for Linux + Windows + macOS in one matrix. Click
|
||||
# "Run workflow" in the Actions tab, or push a 0.0.0-testing-* tag. All three jobs
|
||||
# publish to a single 0.0.0-testing-<short-sha> prerelease (distinct asset names).
|
||||
#
|
||||
# Per OS:
|
||||
# Linux -> .deb
|
||||
# Windows -> NSIS installer (.exe)
|
||||
# macOS -> .zip (arm64; dmg + signing are follow-ups)
|
||||
#
|
||||
# Unsigned: macOS is quarantined/Gatekeeper-blocked once downloaded
|
||||
# (xattr -dr com.apple.quarantine "Agent Orchestrator.app"); Windows SmartScreen
|
||||
# warns ("More info" -> "Run anyway"). Each OS builds on its own native runner so
|
||||
# build-daemon.mjs compiles the bundled ao for that platform (host == target).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "0.0.0-testing-*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: "@electron-forge/maker-deb"
|
||||
- os: windows-latest
|
||||
# Our custom NSIS maker's `name` (see makers/maker-nsis.ts); forge
|
||||
# `--targets` matches the configured maker instance by this name.
|
||||
target: "nsis"
|
||||
- os: macos-latest
|
||||
target: "@electron-forge/maker-zip"
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
env:
|
||||
# Pure-Go sqlite (modernc) needs no cgo; on Linux this also keeps the daemon
|
||||
# static and portable across glibc.
|
||||
CGO_ENABLED: 0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
- run: npm ci
|
||||
- name: Build (unsigned)
|
||||
# `npm run make` keeps the premake daemon build; --targets restricts to this
|
||||
# platform's maker.
|
||||
run: npm run make -- --targets ${{ matrix.target }}
|
||||
# Smoke-install the NSIS installer on a clean, native x64 Windows runner.
|
||||
# This is a build-vs-host verdict: if it installs here it proves the artifact
|
||||
# is good and a failing user machine is host-side (AV/disk/signing); if it
|
||||
# fails here the build/NSIS config is wrong. continue-on-error so a failed
|
||||
# install never blocks publishing the artifacts. The runner has no real-time
|
||||
# AV blocking, so a clean install here does NOT prove SmartScreen/Defender
|
||||
# won't reject the unsigned binaries on end-user machines.
|
||||
- name: Smoke-install the Windows installer
|
||||
if: runner.os == 'Windows'
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: |
|
||||
$setup = Get-ChildItem -Path out/make -Recurse -Filter '*.exe' |
|
||||
Where-Object { $_.Name -like '*Setup*' } | Select-Object -First 1
|
||||
if (-not $setup) { $setup = Get-ChildItem -Path out/make -Recurse -Filter '*.exe' | Select-Object -First 1 }
|
||||
if (-not $setup) { Write-Host '::error::no NSIS installer (.exe) produced under out/make'; exit 1 }
|
||||
Write-Host "Running $($setup.FullName) /S (silent)"
|
||||
# electron-builder NSIS (assisted installer): /S installs silently.
|
||||
$proc = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -PassThru -Wait
|
||||
Write-Host "Installer exit code: $($proc.ExitCode)"
|
||||
# Per-user assisted install lands under %LOCALAPPDATA%\Programs; a
|
||||
# per-machine install would land under Program Files.
|
||||
$installDir = @(
|
||||
(Join-Path $env:LOCALAPPDATA 'Programs\Agent Orchestrator'),
|
||||
(Join-Path ${env:ProgramFiles} 'Agent Orchestrator')
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ($installDir) {
|
||||
Write-Host "INSTALL OK: $installDir created"
|
||||
Get-ChildItem $installDir | Select-Object Name | Format-Table -AutoSize
|
||||
} else {
|
||||
Write-Host "::warning::INSTALL: no known install dir found (checked LOCALAPPDATA\Programs and Program Files)"
|
||||
}
|
||||
- name: Publish to a 0.0.0-testing-<sha> prerelease
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Tag push: use the pushed tag. Manual run: mint 0.0.0-testing-<sha>.
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG="0.0.0-testing-${GITHUB_SHA::7}"
|
||||
fi
|
||||
# Matrix jobs race here; first one creates the release, the rest hit
|
||||
# "already exists" which is fine (|| true). Distinct asset names + --clobber
|
||||
# make uploads idempotent across re-runs.
|
||||
gh release create "$TAG" --prerelease --target "$GITHUB_SHA" --title "$TAG" \
|
||||
--notes "Unsigned testing build (Linux .deb / Windows NSIS .exe / macOS .zip). Not signed; for testing only." \
|
||||
|| true
|
||||
# NUL-delimited to survive spaces in the app name ("Agent Orchestrator-...").
|
||||
find out/make -type f -print0 | while IFS= read -r -d '' f; do
|
||||
echo "uploading: $f"
|
||||
gh release upload "$TAG" "$f" --clobber
|
||||
done
|
||||
|
|
@ -1,71 +1,64 @@
|
|||
# Node / Electron
|
||||
node_modules/
|
||||
.pnpm/
|
||||
dist/
|
||||
.pnpm-store/
|
||||
.next/
|
||||
.next-dev/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
*.patch
|
||||
*-context.md
|
||||
packages/web/screenshots/
|
||||
.playwright-cli/
|
||||
out/
|
||||
build/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment files (secrets)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
# Credentials and secrets
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.cer
|
||||
*.crt
|
||||
*.der
|
||||
*.csr
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
credentials.json
|
||||
credentials.yaml
|
||||
*-credentials.*
|
||||
.secrets/
|
||||
.credentials/
|
||||
|
||||
# API keys and tokens
|
||||
.token
|
||||
.api-key
|
||||
*-token.txt
|
||||
*-api-key.txt
|
||||
|
||||
# Cloud provider credentials
|
||||
.aws/
|
||||
.gcloud/
|
||||
.azure/
|
||||
|
||||
# SSH keys
|
||||
id_rsa
|
||||
id_dsa
|
||||
id_ecdsa
|
||||
id_ed25519
|
||||
*.ppk
|
||||
|
||||
# Development symlinks (created per-worktree, not committed)
|
||||
.claude
|
||||
packages/web/agent-orchestrator.yaml
|
||||
|
||||
# Local agent orchestrator config (may contain secrets)
|
||||
# Go
|
||||
.go/
|
||||
bin/
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
# compiled daemon binary
|
||||
/backend/backend
|
||||
agent-orchestrator.yaml
|
||||
|
||||
# Agent configuration and activity logs
|
||||
.claude/
|
||||
.opencode/
|
||||
# Backend runtime data artifacts (SQLite store + WAL, CDC event log).
|
||||
# Created at AO_DATA_DIR (outside the repo by default); ignored here so a
|
||||
# data dir pointed at the tree never gets committed.
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
session-events.jsonl
|
||||
session-events.jsonl.*
|
||||
|
||||
# Agent Orchestrator local session state
|
||||
.ao/
|
||||
|
||||
# OS-specific files
|
||||
# AO reviewer scratch output. The reviewer agent runs inside the worker's
|
||||
# worktree; its review writeup must never be committed onto the worker branch.
|
||||
/review.md
|
||||
|
||||
# Environment
|
||||
.direnv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor / IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
package-lock.json
|
||||
|
||||
# Personal local overrides (not for the team)
|
||||
.envrc.local
|
||||
|
||||
# electron-forge / vite build output
|
||||
.vite/
|
||||
dist-electron/
|
||||
# electron-builder debug dump, written to the cwd on every NSIS build
|
||||
builder-debug.yml
|
||||
|
||||
# playwright artifacts
|
||||
frontend/test-results/
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
# Gitleaks configuration for Agent Orchestrator
|
||||
# Prevents accidental commits of secrets, API keys, tokens, etc.
|
||||
|
||||
title = "Agent Orchestrator Secret Scanning"
|
||||
|
||||
# Use all default gitleaks rules
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
# Allowlist to ignore false positives
|
||||
[allowlist]
|
||||
description = "Allowlisted patterns"
|
||||
|
||||
paths = [
|
||||
"node_modules/",
|
||||
"dist/",
|
||||
".next/",
|
||||
"coverage/",
|
||||
"pnpm-lock.yaml",
|
||||
]
|
||||
|
||||
regexes = [
|
||||
# Environment variable references
|
||||
"\\$\\{[A-Z_]+\\}",
|
||||
|
||||
# Placeholder values
|
||||
"your-api-key-here",
|
||||
"your-token-here",
|
||||
"example\\.com",
|
||||
|
||||
]
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Gitleaks pre-commit hook
|
||||
# Scans staged files for secrets before allowing commit
|
||||
|
||||
echo "🔒 Scanning staged files for secrets..."
|
||||
|
||||
if ! command -v gitleaks > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "❌ gitleaks is not installed!"
|
||||
echo ""
|
||||
echo "Install gitleaks to enable secret scanning:"
|
||||
echo " macOS: brew install gitleaks"
|
||||
echo " Linux: See https://github.com/gitleaks/gitleaks#installing"
|
||||
echo ""
|
||||
echo "Secret scanning is REQUIRED to prevent credential leaks."
|
||||
echo "Commit blocked until gitleaks is installed."
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run gitleaks on staged files only
|
||||
gitleaks protect --staged --verbose
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Secret(s) detected in staged files!"
|
||||
echo ""
|
||||
echo "To fix:"
|
||||
echo " 1. Remove the secret from the file"
|
||||
echo " 2. Use environment variables instead: \${SECRET_NAME}"
|
||||
echo " 3. Add to .env.local (which is in .gitignore)"
|
||||
echo " 4. Update agent-orchestrator.yaml.example with placeholder values"
|
||||
echo ""
|
||||
echo "If this is a false positive, update .gitleaks.toml allowlist"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No secrets detected"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
|
|
@ -1,6 +1,17 @@
|
|||
dist/
|
||||
node_modules/
|
||||
.next/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
pnpm-lock.yaml
|
||||
# Generated — never hand-edit; regenerated by `npm run api` / sqlc / openapi-typescript
|
||||
frontend/src/api/schema.ts
|
||||
backend/internal/httpd/apispec/openapi.yaml
|
||||
|
||||
# Build outputs
|
||||
frontend/dist
|
||||
frontend/dist-electron
|
||||
frontend/release
|
||||
frontend/test-results
|
||||
frontend/playwright-report
|
||||
|
||||
# Lockfiles
|
||||
package-lock.json
|
||||
frontend/package-lock.json
|
||||
|
||||
# Go uses gofmt, not Prettier
|
||||
backend/
|
||||
|
|
|
|||
14
.prettierrc
14
.prettierrc
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
|
|
|
|||
152
AGENTS.md
152
AGENTS.md
|
|
@ -1,79 +1,129 @@
|
|||
# AGENTS.md
|
||||
|
||||
> Full project context, architecture, conventions, and plugin standards are in **CLAUDE.md**.
|
||||
Operational guidance for coding agents working in this repository. Keep changes small, match the current rewrite architecture, and prefer the documented daemon/API boundaries over behavior from the old TypeScript implementation.
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `backend/` — Go rewrite of Agent Orchestrator: Cobra `ao` CLI, loopback HTTP daemon, services, SQLite storage, lifecycle/reaper, runtime/workspace/agent/tracker adapters, terminal mux, and tests.
|
||||
- `frontend/` — Electron + React supervisor wired to the daemon via the generated typed client. Treat it as a thin supervisor/UI surface; do not move daemon logic into it.
|
||||
- `docs/` — current architecture/status notes. Start here before changing lifecycle, CLI, agents, storage, or daemon behavior.
|
||||
- `test/` — external smoke/e2e assets, including the CLI fresh-install container check.
|
||||
- `.github/workflows/` — CI definitions. Mirror these commands locally when possible.
|
||||
|
||||
## Commands
|
||||
|
||||
From the repo root unless noted:
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies
|
||||
pnpm build # Build all packages
|
||||
pnpm dev # Web dashboard dev server (Next.js + 2 WS servers)
|
||||
pnpm typecheck # Type check all packages
|
||||
pnpm test # All tests (excludes web)
|
||||
pnpm --filter @aoagents/ao-web test # Web tests
|
||||
pnpm lint # ESLint check
|
||||
pnpm lint:fix # ESLint fix
|
||||
pnpm format # Prettier format
|
||||
npm run lint # backend go test ./... + golangci-lint v2.12.2
|
||||
npm run frontend:typecheck # frontend TypeScript check
|
||||
npm run sqlc # regenerate backend/internal/storage/sqlite/gen from queries/schema
|
||||
npm run api # regenerate OpenAPI spec + frontend TS types (see API contract changes below)
|
||||
npx @redwoodjs/agent-ci run --all # local workflow validation; requires Docker socket
|
||||
```
|
||||
|
||||
## Architecture TL;DR
|
||||
Backend-specific checks:
|
||||
|
||||
Monorepo (pnpm) with packages: `core`, `cli`, `web`, and `plugins/*`. The web dashboard is a Next.js 15 app (App Router) with React 19 and Tailwind CSS v4. Data flows from `agent-orchestrator.yaml` through core's `loadConfig()` to API routes, served via SSR and a 5s-interval SSE stream. Terminal sessions use WebSocket connections to tmux PTYs. See CLAUDE.md for the full plugin architecture (8 slots), session lifecycle, and data flow.
|
||||
```bash
|
||||
cd backend
|
||||
go build ./...
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
go run ./cmd/ao start
|
||||
```
|
||||
|
||||
## Working Principles
|
||||
Frontend-specific checks:
|
||||
|
||||
- **Think before coding.** State assumptions. Ask when unclear. Push back when a simpler approach exists.
|
||||
- **Simplicity first.** No speculative features. No abstractions for single-use code. Plugin slots are the extension point.
|
||||
- **Surgical changes.** Touch only what you must. Match existing style. Don't refactor things that aren't broken. Every changed line traces to the task.
|
||||
- **Goal-driven.** Define verifiable success criteria before implementing. Write tests that reproduce bugs before fixing them.
|
||||
```bash
|
||||
cd frontend
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
Full guidelines with AO-specific context: see "Working Principles" in CLAUDE.md.
|
||||
When showing or demoing frontend changes, run `ao preview [url]` from inside the session so the change renders in the desktop browser panel (the inspector rail's Browser tab); do not just describe it.
|
||||
|
||||
## Skills
|
||||
## Where to look first
|
||||
|
||||
Agents working on this repo should use these checked-in skills:
|
||||
- `README.md` — current run/config/test quickstart.
|
||||
- `docs/README.md` — docs index.
|
||||
- `docs/architecture.md` — backend mental model, package layout, lifecycle/session/service boundaries, and load-bearing rules.
|
||||
- `docs/STATUS.md` — what is shipped on `main` today and what is still in flight.
|
||||
- `docs/cli/README.md` — intended CLI shape: thin Cobra client over daemon HTTP, never direct storage/runtime access.
|
||||
- `docs/agent/README.md` — agent adapter contract and hook behavior.
|
||||
- `CLAUDE.md` — compatibility pointer for Claude Code; it directs agents back to `AGENTS.md`.
|
||||
|
||||
### Bug Triage (`skills/bug-triage/`)
|
||||
For code entry points:
|
||||
|
||||
**When to use:** Any time a bug is reported — in chat, issues, or live observation.
|
||||
- CLI commands: `backend/internal/cli/*.go`; follow nearby command/test patterns before adding a new style.
|
||||
- HTTP controllers and DTOs: `backend/internal/httpd/controllers/`.
|
||||
- Service read/write boundaries: `backend/internal/service/`.
|
||||
- Domain vocabulary: `backend/internal/domain/`.
|
||||
- Port contracts: `backend/internal/ports/`.
|
||||
- SQLite queries/migrations/store: `backend/internal/storage/sqlite/`.
|
||||
- Generated sqlc code: `backend/internal/storage/sqlite/gen/`.
|
||||
|
||||
**What it covers:**
|
||||
- Full triage workflow: gather context → search duplicates → file/update GitHub issues → push fix PRs
|
||||
- Root cause analysis with `git log -S` archaeology and upstream dependency research
|
||||
- GitHub API-based file editing (no local checkout needed) via `scripts/push_fix_to_github.py`
|
||||
- NPM package regression diffing
|
||||
- Remote code inspection when the repo isn't cloned locally
|
||||
## Coding conventions
|
||||
|
||||
**How to load:** Read `skills/bug-triage/SKILL.md` and follow its step-by-step workflow. The `scripts/` directory contains executable tools:
|
||||
- `push_fix_to_github.py` — Push a single-file fix and create a PR entirely via GitHub API
|
||||
- Keep every change surgical and directly tied to the task. Avoid drive-by cleanup, broad renames, formatting churn, speculative abstractions, and architectural refactors unless the task explicitly asks for them.
|
||||
- Follow existing Go package boundaries. CLI code should call daemon HTTP routes through shared CLI client helpers; it should not open SQLite, spawn runtimes, or call adapters directly.
|
||||
- Keep Cobra commands in the relevant command file and table-test them in the style of `backend/internal/cli/*_test.go`.
|
||||
- Mirror existing response/request DTOs in the CLI instead of importing HTTP controller packages into CLI code, unless the package already establishes that dependency.
|
||||
- Return usage errors as `usageError` so CLI misuse exits 2; runtime/daemon failures should exit 1.
|
||||
- Preserve API error envelopes and request IDs when surfacing daemon errors.
|
||||
- Use `context.Context` as the first argument for functions that do I/O or blocking work.
|
||||
- Do not add abstractions for one-off use cases. Add helpers only when they remove duplication across real call sites.
|
||||
- Tests should cover the user-visible behavior and boundary being changed: happy path, validation/missing args, daemon error envelopes, and any destructive confirmation path.
|
||||
|
||||
**Always pull latest main before triaging.** Stale code = bad triage. No exceptions.
|
||||
## Hard rules and boundaries
|
||||
|
||||
## Key Files
|
||||
- The daemon is a loopback-only sidecar. Do not make the bind host configurable or expose it beyond `127.0.0.1`.
|
||||
- The CLI is a thin client. Do not port old in-process TypeScript CLI behavior that bypasses daemon HTTP routes.
|
||||
- Do not store derived/display session status. Status is derived from durable facts (`activity_state`, `is_terminated`, PR/check/comment facts) at service read time.
|
||||
- Do not treat failed/unknown runtime probes as proof a session is dead.
|
||||
- Do not force-delete dirty registered worktrees.
|
||||
- Do not modify already-merged SQLite migrations. Add a new migration instead.
|
||||
- Do not hand-edit `backend/internal/storage/sqlite/gen/*`; change `backend/internal/storage/sqlite/queries/*` or migrations and run `npm run sqlc`.
|
||||
- SQLite change events come from DB triggers into `change_log`; do not add parallel manual CDC emission from store methods unless the architecture changes explicitly.
|
||||
- Keep generated OpenAPI/API DTO drift in mind: controller response shapes live in `backend/internal/httpd/controllers/dto.go` and tests may assert CLI/HTTP wire compatibility.
|
||||
- Do not add network calls to tests unless the package already has an integration/e2e pattern for them. Prefer `httptest`, fakes, and injected dependencies.
|
||||
- Do not commit local run state, daemon data, temporary worktrees, build outputs, or credentials.
|
||||
- All app state lives under `~/.ao` only. The daemon's data dir, `running.json`, worktrees, and the Electron supervisor's `userData` (Chromium cache, cookies, local/session storage, crash dumps) must resolve under `~/.ao` (overridable via `AO_DATA_DIR`/`AO_RUN_FILE`). Never write to or read from `~/Library/Application Support` or any other OS default app-data location. `main.ts` pins Electron's `userData` to `~/.ao/electron`; do not remove that override or rely on Electron's default path.
|
||||
|
||||
- `packages/core/src/types.ts` — All plugin interfaces (Agent, Runtime, Workspace, etc.)
|
||||
- `packages/core/src/session-manager.ts` — Session CRUD + stale runtime reconciliation (detects dead runtimes, persists `runtime_lost`)
|
||||
- `packages/core/src/lifecycle-manager.ts` — State machine + polling loop
|
||||
- `packages/core/src/lifecycle-state.ts` — Canonical lifecycle → legacy status mapping (`deriveLegacyStatus`)
|
||||
- `packages/cli/src/commands/start.ts` — ao start/stop commands + Ctrl+C graceful shutdown
|
||||
- `packages/cli/src/lib/running-state.ts` — RunningState + LastStopState management
|
||||
- `packages/web/src/components/Dashboard.tsx` — Main dashboard view (sidebar uses unscoped sessions, kanban filters by project)
|
||||
- `packages/web/src/components/SessionDetail.tsx` — Session detail view
|
||||
- `packages/web/src/app/globals.css` — Design tokens
|
||||
## API contract changes
|
||||
|
||||
## CLI Behavior Notes
|
||||
The daemon API is code-first. The OpenAPI spec and frontend TypeScript types are generated artifacts — edit the source, then regenerate.
|
||||
|
||||
- `ao stop` loads global config to see all projects; `ao stop <project>` only kills that project's sessions
|
||||
- Ctrl+C on `ao start` performs full graceful shutdown (same as `ao stop`)
|
||||
- `LastStopState` includes `otherProjects` for cross-project session restore on next `ao start`
|
||||
- Dashboard sidebar always shows ALL projects' sessions regardless of active project view
|
||||
**Source files to edit:**
|
||||
|
||||
## Cross-Platform (Windows) Compatibility
|
||||
- `backend/internal/httpd/controllers/dto.go` — request/response shapes.
|
||||
- `backend/internal/httpd/apispec/specgen/build.go` — operation registry; add a `schemaNames` entry for any new named type.
|
||||
|
||||
AO ships on macOS, Linux, **and Windows**. All three are first-class.
|
||||
**Regenerate after editing:**
|
||||
|
||||
**Golden Rule:** Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helpers don't cover, add it to `packages/core/src/platform.ts` — never inline at the call site. Inline checks bypass the central platform-mock test pattern and become silent regressions.
|
||||
```bash
|
||||
npm run api # runs api:spec then api:ts in sequence
|
||||
```
|
||||
|
||||
**Read `docs/CROSS_PLATFORM.md` before merging any change that touches:** process spawning/killing/signalling, file paths, shell commands, network binding, POSIX shell-outs (`tmux`, `lsof`, etc.), runtime/agent/workspace plugins, agent-plugin internals (`setupPathWrapperWorkspace`, `getActivityState`, `formatLaunchCommand`, `isProcessRunning`, `detect()`), the Windows pty-host pipe protocol or registry, or any new `process.platform === "win32"` check.
|
||||
This is equivalent to running:
|
||||
|
||||
That doc has the **full helper inventory** (every import path), the EPERM-vs-ESRCH gotcha when probing processes, path case-insensitivity rules, PowerShell-vs-bash differences (`& ` call-operator, `$env:VAR`, no `/dev/null`, no `$(cat …)`, `.cmd` shim resolution via `shell: isWindows()`), IPv6 `localhost` stalls on Windows, agent-plugin Windows specifics, the test pattern for mocking `process.platform`, and a 10-point pre-merge checklist. CLAUDE.md has the quick-reference helper table; CROSS_PLATFORM.md has the depth.
|
||||
```bash
|
||||
npm run api:spec # cd backend && go generate ./internal/httpd/apispec/...
|
||||
npm run api:ts # npx openapi-typescript@7.4.4 backend/internal/httpd/apispec/openapi.yaml -o frontend/src/api/schema.ts
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
cd backend && go test ./internal/httpd/... # spec drift + route/spec parity tests (does not cover schema.ts — that is checked by the api-drift CI job)
|
||||
```
|
||||
|
||||
Commit `openapi.yaml` and `frontend/src/api/schema.ts` together with the Go changes. CI will regenerate both files and fail if the committed versions are out of date. The CLI hand-mirrored DTOs remain a deliberate manual boundary and are not generated.
|
||||
|
||||
## PR hygiene
|
||||
|
||||
- Branch from `main` unless explicitly continuing an existing PR.
|
||||
- Keep one issue per PR. If asked for separate work, create a separate branch and PR.
|
||||
- Use conventional commit messages (`feat:`, `fix:`, `docs:`, `test:`, `chore:`).
|
||||
- Explain intentional omissions in the PR body, especially when the TypeScript original had more behavior than the Go rewrite domain currently supports.
|
||||
- Run the narrowest relevant tests first, then the repo/CI commands that match the touched area.
|
||||
|
|
|
|||
311
ARCHITECTURE.md
311
ARCHITECTURE.md
|
|
@ -1,311 +0,0 @@
|
|||
# Final Architecture Plan
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Convention over configuration** - Auto-derive everything possible
|
||||
2. **Single source of truth** - Config file in repo, runtime data in `~/.agent-orchestrator/`
|
||||
3. **Zero path configuration** - All paths determined automatically
|
||||
4. **Global uniqueness** - Hash-based namespacing prevents collisions
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Structure
|
||||
|
||||
```
|
||||
Repo (versioned):
|
||||
~/any/path/to/agent-orchestrator/
|
||||
agent-orchestrator.yaml ← Config file (only this matters)
|
||||
packages/
|
||||
...
|
||||
|
||||
Runtime Data (not versioned):
|
||||
~/.agent-orchestrator/ ← Single parent directory
|
||||
a3b4c5d6e7f8-integrator/ ← {hash}-{projectId}
|
||||
sessions/
|
||||
int-1 ← Session metadata files (no hash prefix)
|
||||
int-2
|
||||
worktrees/
|
||||
int-1/ ← Git worktrees (no hash prefix)
|
||||
int-2/
|
||||
archive/
|
||||
int-3_2026-02-17T10-30-00
|
||||
.origin ← Config path reference
|
||||
|
||||
a3b4c5d6e7f8-backend/ ← Same hash (same config!)
|
||||
sessions/
|
||||
be-1 ← No hash prefix (already namespaced)
|
||||
worktrees/
|
||||
be-1/
|
||||
.origin
|
||||
```
|
||||
|
||||
**Hash Derivation (from config location):**
|
||||
|
||||
```typescript
|
||||
const configDir = path.dirname(configPath); // /Users/alice/code/agent-orchestrator
|
||||
const hash = sha256(configDir).slice(0, 12); // a3b4c5d6e7f8
|
||||
|
||||
// Each project managed by this config gets a directory
|
||||
// Format: {hash}-{projectId}
|
||||
const projectId = path.basename(projectPath); // integrator, backend, etc.
|
||||
const instanceId = `${hash}-${projectId}`; // a3b4c5d6e7f8-integrator
|
||||
|
||||
// Not configurable!
|
||||
const projectBaseDir = `~/.agent-orchestrator/${instanceId}`;
|
||||
const sessionsDir = `${projectBaseDir}/sessions`;
|
||||
const worktreesDir = `${projectBaseDir}/worktrees`;
|
||||
```
|
||||
|
||||
**Key insight:** All projects from the same config share the same hash prefix!
|
||||
|
||||
---
|
||||
|
||||
## 2. Config File (Minimal)
|
||||
|
||||
```yaml
|
||||
# agent-orchestrator.yaml
|
||||
|
||||
projects:
|
||||
- path: ~/repos/integrator # Required: where is the repo?
|
||||
repo: ComposioHQ/integrator # Required: GitHub repo
|
||||
defaultBranch: next # Required: base branch
|
||||
|
||||
# Optional overrides:
|
||||
name: Composio Integrator # Display name (default: folder name)
|
||||
sessionPrefix: int # Override auto-generated prefix
|
||||
```
|
||||
|
||||
**Auto-derived:**
|
||||
|
||||
- Project ID: `basename(path)` → `integrator`
|
||||
- Session prefix: `generatePrefix("integrator")` → `int`
|
||||
- Worktree path: `{worktreeDir}/integrator/`
|
||||
|
||||
**That's it! No dataDir, no worktreeDir, no explicit IDs.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Session Naming
|
||||
|
||||
### User-Facing Names (Elegant)
|
||||
|
||||
```
|
||||
{sessionPrefix}-{num}
|
||||
|
||||
int-1, int-2 (integrator)
|
||||
ao-1, ao-2 (agent-orchestrator)
|
||||
ss-1, ss-2 (safe-split)
|
||||
```
|
||||
|
||||
### Runtime Session Names (Globally Unique)
|
||||
|
||||
```
|
||||
{hash}-{sessionPrefix}-{num}
|
||||
|
||||
a3b4c5d6e7f8-int-1
|
||||
a3b4c5d6e7f8-ao-1
|
||||
f1e2d3c4b5a6-int-1 (different checkout, no collision!)
|
||||
```
|
||||
|
||||
On Unix this is the tmux session name. On Windows (where the default runtime is `process`, not `tmux`) the same string identifies the named pipe path `\\.\pipe\ao-pty-{sessionId}` and is recorded in `~/.agent-orchestrator/windows-pty-hosts.json`.
|
||||
|
||||
### Prefix Generation (Clean Heuristic)
|
||||
|
||||
```typescript
|
||||
function generateSessionPrefix(projectId: string): string {
|
||||
if (projectId.length <= 4) return projectId.toLowerCase();
|
||||
|
||||
// CamelCase: PyTorch → pt
|
||||
const uppercase = projectId.match(/[A-Z]/g);
|
||||
if (uppercase?.length > 1) {
|
||||
return uppercase.join("").toLowerCase();
|
||||
}
|
||||
|
||||
// kebab-case: agent-orchestrator → ao
|
||||
if (projectId.includes("-") || projectId.includes("_")) {
|
||||
const sep = projectId.includes("-") ? "-" : "_";
|
||||
return projectId
|
||||
.split(sep)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Single word: integrator → int
|
||||
return projectId.slice(0, 3).toLowerCase();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Metadata Storage
|
||||
|
||||
### File Structure (One Directory Per Project)
|
||||
|
||||
```
|
||||
~/.agent-orchestrator/a3b4c5d6e7f8-integrator/
|
||||
sessions/
|
||||
int-1 ← Metadata file (user-facing session name)
|
||||
int-2
|
||||
worktrees/
|
||||
int-1/
|
||||
int-2/
|
||||
archive/
|
||||
int-3_2026-02-17T10-30-00
|
||||
```
|
||||
|
||||
### Metadata File Format (key=value)
|
||||
|
||||
```
|
||||
project=integrator
|
||||
issue=INT-100
|
||||
branch=feat/INT-100
|
||||
status=working
|
||||
tmuxName=a3b4c5d6e7f8-int-1 # Unix; on Windows the runtime handle is `pipePath=\\.\pipe\ao-pty-<sessionId>` plus `ptyHostPid`
|
||||
worktree=/Users/alice/.agent-orchestrator/a3b4c5d6e7f8-integrator/worktrees/int-1
|
||||
createdAt=2026-02-17T10:30:00Z
|
||||
pr=https://github.com/ComposioHQ/integrator/pull/123
|
||||
```
|
||||
|
||||
**Key fields:**
|
||||
|
||||
- `project` - Which project this session belongs to (for filtering)
|
||||
- `issue` - Linear/GitHub issue ID
|
||||
- `branch` - Git branch name
|
||||
- `worktree` - Path to git worktree
|
||||
- `status` - working/idle/pr_open/merged
|
||||
|
||||
---
|
||||
|
||||
## 5. User Commands (Simple)
|
||||
|
||||
```bash
|
||||
# List all sessions
|
||||
ao list
|
||||
|
||||
# List sessions for specific project
|
||||
ao list integrator
|
||||
|
||||
# Spawn new session
|
||||
ao spawn integrator INT-100
|
||||
|
||||
# Attach to session (orchestrator finds the runtime handle: tmux name on Unix, named pipe on Windows)
|
||||
ao attach int-1
|
||||
|
||||
# Kill session
|
||||
ao kill int-1
|
||||
|
||||
# Show instance info
|
||||
ao info
|
||||
```
|
||||
|
||||
**No config paths in commands! Everything auto-discovered.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-Instance Support
|
||||
|
||||
### Same Config → Same Hash
|
||||
|
||||
```yaml
|
||||
# ~/code/my-orchestrator/agent-orchestrator.yaml
|
||||
projects:
|
||||
- path: ~/repos/integrator
|
||||
- path: ~/repos/backend
|
||||
```
|
||||
|
||||
Results in:
|
||||
|
||||
```
|
||||
~/.agent-orchestrator/
|
||||
a3b4c5d6e7f8-integrator/ ← Same hash (same config)
|
||||
a3b4c5d6e7f8-backend/ ← Same hash (same config)
|
||||
```
|
||||
|
||||
### Different Config Locations → Different Hashes
|
||||
|
||||
```
|
||||
~/code/orchestrator/ → hash: a3b4c5d6e7f8
|
||||
~/code/orchestrator-v2/ → hash: f1e2d3c4b5a6
|
||||
~/splitly-orchestrator/ → hash: 9876abcd5432
|
||||
```
|
||||
|
||||
Results in:
|
||||
|
||||
```
|
||||
~/.agent-orchestrator/
|
||||
a3b4c5d6e7f8-integrator/ ← From ~/code/orchestrator
|
||||
f1e2d3c4b5a6-integrator/ ← From ~/code/orchestrator-v2 (different checkout!)
|
||||
9876abcd5432-safesplit/ ← From ~/splitly-orchestrator
|
||||
|
||||
# Sessions (no collisions):
|
||||
a3b4c5d6e7f8-int-1 (main checkout)
|
||||
f1e2d3c4b5a6-int-1 (v2 checkout)
|
||||
9876abcd5432-ss-1 (splitly)
|
||||
```
|
||||
|
||||
**Each orchestrator checkout gets unique hash. Projects within same config share that hash.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Complete Example
|
||||
|
||||
```yaml
|
||||
# ~/code/my-orchestrator/agent-orchestrator.yaml
|
||||
projects:
|
||||
- path: ~/repos/integrator
|
||||
repo: ComposioHQ/integrator
|
||||
defaultBranch: next
|
||||
|
||||
- path: ~/repos/backend
|
||||
repo: ComposioHQ/backend
|
||||
defaultBranch: main
|
||||
sessionPrefix: be # Override auto-generated "bac"
|
||||
```
|
||||
|
||||
**Results in:**
|
||||
|
||||
```
|
||||
Config location:
|
||||
~/code/my-orchestrator/
|
||||
→ Hash: a3b4c5d6e7f8
|
||||
|
||||
Runtime data:
|
||||
~/.agent-orchestrator/
|
||||
a3b4c5d6e7f8-integrator/ ← Project 1
|
||||
sessions/
|
||||
int-1
|
||||
worktrees/
|
||||
int-1/
|
||||
|
||||
a3b4c5d6e7f8-backend/ ← Project 2 (same hash!)
|
||||
sessions/
|
||||
be-1
|
||||
worktrees/
|
||||
be-1/
|
||||
|
||||
Session names:
|
||||
User-facing: int-1, be-1
|
||||
Tmux: a3b4c5d6e7f8-int-1, a3b4c5d6e7f8-be-1
|
||||
|
||||
Commands:
|
||||
ao spawn integrator INT-100
|
||||
ao attach int-1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: What Users Configure
|
||||
|
||||
**Required (3 fields per project):**
|
||||
|
||||
1. `path` - Where is the repo?
|
||||
2. `repo` - GitHub owner/repo
|
||||
3. `defaultBranch` - Base branch name
|
||||
|
||||
**Optional:**
|
||||
|
||||
- `sessionPrefix` - Override auto-generated prefix
|
||||
- `name` - Display name
|
||||
|
||||
**That's it! Everything else is automatic.**
|
||||
670
CLAUDE.md
670
CLAUDE.md
|
|
@ -1,644 +1,30 @@
|
|||
# CLAUDE.md
|
||||
|
||||
## What is this project?
|
||||
|
||||
Agent Orchestrator (AO) is a platform for spawning and managing parallel AI coding agents across distributed systems. It runs multiple agents (Claude Code, Codex, Aider, OpenCode) simultaneously — each in an isolated git worktree with its own PR — and provides a single dashboard to supervise them all. Agents autonomously fix CI failures, address review comments, and manage PRs.
|
||||
|
||||
**Org:** ComposioHQ
|
||||
**Repo:** `github.com/ComposioHQ/agent-orchestrator`
|
||||
**License:** MIT
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
pnpm workspace (v9.15.4) with ~30 packages:
|
||||
|
||||
```
|
||||
packages/
|
||||
core/ # Engine: types, config, session manager, lifecycle, plugin registry
|
||||
cli/ # CLI tool (`ao` command) — depends on all plugins
|
||||
web/ # Next.js 15 dashboard (App Router, React 19, Tailwind v4)
|
||||
ao/ # Global CLI wrapper (thin shim around cli)
|
||||
plugins/
|
||||
agent-claude-code/ agent-aider/ agent-codex/ agent-opencode/
|
||||
runtime-tmux/ runtime-process/
|
||||
workspace-worktree/ workspace-clone/
|
||||
tracker-github/ tracker-linear/ tracker-gitlab/
|
||||
scm-github/ scm-gitlab/
|
||||
notifier-desktop/ notifier-slack/ notifier-webhook/
|
||||
notifier-composio/ notifier-openclaw/
|
||||
terminal-iterm2/ terminal-web/
|
||||
integration-tests/ # E2E tests
|
||||
```
|
||||
|
||||
**Build order:** core -> plugins -> cli/web (parallel). `pnpm build` at root handles this.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Language | TypeScript (strict mode, ES2022, Node16 modules) |
|
||||
| Runtime | Node.js 20+ |
|
||||
| Package Manager | pnpm 9.15.4 (`workspace:*` protocol) |
|
||||
| Web | Next.js 15 (App Router) + React 19 |
|
||||
| Styling | Tailwind CSS v4 + CSS custom properties (`@theme` block in `globals.css`) |
|
||||
| Terminal UI | xterm.js 5.3.0 + WebSocket to tmux PTYs |
|
||||
| Validation | Zod |
|
||||
| Testing | Vitest + @testing-library/react |
|
||||
| Linting | ESLint 10 (flat config) + Prettier 3.8 |
|
||||
| CI/CD | GitHub Actions (lint, typecheck, test, release) |
|
||||
| Versioning | Changesets |
|
||||
| Git hooks | Husky + gitleaks (secret scanning) |
|
||||
| Container | OCI via Containerfile (Podman/Docker) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install & build
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Development
|
||||
pnpm dev # Web dashboard (Next.js + 2 WS servers)
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck # All packages
|
||||
pnpm --filter @aoagents/ao-web typecheck # Web only
|
||||
|
||||
# Testing
|
||||
pnpm test # All packages (excludes web)
|
||||
pnpm --filter @aoagents/ao-web test # Web tests
|
||||
pnpm --filter @aoagents/ao-web test:watch # Web watch mode
|
||||
pnpm test:integration # Integration tests
|
||||
|
||||
# Lint & format
|
||||
pnpm lint
|
||||
pnpm lint:fix
|
||||
pnpm format
|
||||
pnpm format:check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin System (8 Slots)
|
||||
|
||||
Every abstraction is a pluggable interface defined in `packages/core/src/types.ts`:
|
||||
|
||||
| Slot | Default | Purpose |
|
||||
|------|---------|---------|
|
||||
| Runtime | tmux | Where agents execute |
|
||||
| Agent | claude-code | Which AI tool to use |
|
||||
| Workspace | worktree | Code isolation (worktree vs clone) |
|
||||
| Tracker | github | Issue tracking (GitHub, Linear, GitLab) |
|
||||
| SCM | github | PR, CI, reviews |
|
||||
| Notifier | desktop | Notification delivery |
|
||||
| Terminal | iterm2 | Human attachment UI |
|
||||
| Lifecycle | core (non-pluggable) | State machine + polling |
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
Sessions have a **canonical lifecycle** (in `lifecycle-state.ts`) with separate `state` and `reason` fields, and a **legacy status** derived from them for display.
|
||||
|
||||
**Canonical session states:** `not_started`, `working`, `idle`, `needs_input`, `stuck`, `detecting`, `done`, `terminated`
|
||||
|
||||
**Terminal reasons:** `manually_killed`, `runtime_lost`, `agent_process_exited`, `probe_failure`, `error_in_process`, `auto_cleanup`, `pr_merged`
|
||||
|
||||
**Legacy status flow (derived via `deriveLegacyStatus`):**
|
||||
```
|
||||
spawning -> working -> pr_open -> ci_failed / review_pending
|
||||
| |
|
||||
changes_requested approved
|
||||
| |
|
||||
+-> mergeable -> merged -> cleanup -> done
|
||||
```
|
||||
|
||||
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735).
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
agent-orchestrator.yaml -> Config Loader (Zod) -> Plugin Registry
|
||||
-> Session Manager -> Lifecycle Manager (polling loop, state machine)
|
||||
-> Events -> Notifiers
|
||||
-> Web API Routes (Next.js) -> SSE (5s interval) + WebSocket (terminal)
|
||||
-> Dashboard (React + xterm.js)
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
No database. Flat files + memory:
|
||||
|
||||
- **Config:** `agent-orchestrator.yaml` (Zod-validated)
|
||||
- **Global config:** `~/.agent-orchestrator/config.yaml` (all registered projects)
|
||||
- **Session metadata:** `~/.agent-orchestrator/{hash}-{projectId}/sessions/{sessionId}` (key-value pairs)
|
||||
- **Worktrees:** `~/.agent-orchestrator/{hash}-{projectId}/worktrees/{sessionId}/`
|
||||
- **Archives:** `~/.agent-orchestrator/{hash}-{projectId}/archive/{sessionId}_{timestamp}`
|
||||
- **Running state:** `~/.agent-orchestrator/running.json` (current ao start PID, port, projects)
|
||||
- **Last-stop state:** `~/.agent-orchestrator/last-stop.json` (sessions killed by ao stop / Ctrl+C, includes `otherProjects` for cross-project sessions — used by ao start to offer session restore)
|
||||
|
||||
Hash = SHA-256 of config directory (first 12 chars). Prevents collision across multiple checkouts.
|
||||
|
||||
**Config resolution:** `loadConfig()` searches up from cwd and finds the nearest `agent-orchestrator.yaml` (typically 1 project). The global config at `~/.agent-orchestrator/config.yaml` contains all registered projects. CLI commands that need cross-project visibility (ao stop, tab completions) fall back to the global config.
|
||||
|
||||
### Prompt Assembly (3 Layers)
|
||||
|
||||
1. Base prompt (system instructions in core)
|
||||
2. Config prompt (project-specific rules from YAML)
|
||||
3. Rules files (optional `.agent-rules.md` from repo)
|
||||
|
||||
## Working Principles
|
||||
|
||||
These behavioral guidelines apply to every agent working on this codebase. They are not optional - they prevent the most common causes of PR rejection and rewrite.
|
||||
|
||||
### Think Before Coding
|
||||
|
||||
Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||
|
||||
- State assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations of a task exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
- When editing `lifecycle-manager.ts` or `session-manager.ts`: state which invariants your change preserves. These files have subtle state dependencies.
|
||||
|
||||
### Simplicity First
|
||||
|
||||
Minimum code that solves the problem. Nothing speculative.
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- Plugin slots are the extension point. Don't add configuration surface when a new plugin is the right answer.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
### Surgical Changes
|
||||
|
||||
Touch only what you must. Clean up only your own mess.
|
||||
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If your changes create orphans (unused imports, dead variables), remove them.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
- Every changed line should trace directly to the task description.
|
||||
|
||||
This is especially critical in:
|
||||
- `types.ts` - changing an interface breaks every plugin. Minimize surface changes.
|
||||
- `globals.css` - tokens are consumed across 50+ components. Don't rename casually.
|
||||
- `lifecycle-manager.ts` - state transitions have implicit dependencies. Document why a transition is safe.
|
||||
|
||||
### Goal-Driven Execution
|
||||
|
||||
Define success criteria. Loop until verified.
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add a new status" -> "Add to enum, update `isTerminalSession`, add to dashboard column mapping, write tests for all three"
|
||||
- "Fix the bug" -> "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" -> "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
|
||||
[Step] -> verify: [check]
|
||||
[Step] -> verify: [check]
|
||||
[Step] -> verify: [check]
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
## CLI Behavior (ao start / ao stop)
|
||||
|
||||
### ao start
|
||||
- Registers in `running.json` (PID, port, projects)
|
||||
- Offers to restore sessions from `last-stop.json` — includes cross-project sessions via `otherProjects` field
|
||||
- `ao start --restore` restores `last-stop.json` without prompting; `ao start --no-restore` skips restore
|
||||
- **Ctrl+C performs full graceful shutdown** (same as ao stop): kills all sessions, writes last-stop state, unregisters from running.json. 10s hard timeout guarantees exit.
|
||||
|
||||
### ao stop
|
||||
- `ao stop` (no args): kills ALL sessions across ALL projects, sends SIGTERM to parent ao start process, stops dashboard, unregisters
|
||||
- `ao stop <project>`: kills only that project's sessions, does NOT kill parent process or dashboard (they serve all projects)
|
||||
- Always loads global config (`~/.agent-orchestrator/config.yaml`) to see all projects — local config only has the cwd project
|
||||
- Records `LastStopState` with `otherProjects` field for cross-project session restore
|
||||
|
||||
### ao update
|
||||
- For package-manager installs, `ao update` pauses a running AO via `ao stop --yes`, runs the global package update, verifies `ao --version`, then restarts with `ao start --restore` (or `--no-restore` if requested)
|
||||
- Failed package-manager updates must report that AO was not updated, include actionable remediation, and restart the previous installation if AO was paused
|
||||
|
||||
### Dashboard sidebar
|
||||
- Sidebar always shows sessions from ALL projects regardless of which project page is active
|
||||
- `useSessionEvents` in Dashboard.tsx is called without project filter — sidebar gets unscoped sessions
|
||||
- Kanban board filters client-side via `projectSessions` memo
|
||||
|
||||
### Key invariants
|
||||
- `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735)
|
||||
- `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here
|
||||
- Tab completions merge local config + global config to show all projects
|
||||
|
||||
## Cross-Platform (Windows) Compatibility
|
||||
|
||||
AO ships on macOS, Linux, **and Windows**. All three are first-class.
|
||||
|
||||
### The Golden Rule
|
||||
|
||||
> **Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helpers don't cover, add it to `packages/core/src/platform.ts` (or one of the targeted helper modules below) — never inline at the call site.**
|
||||
|
||||
The codebase has a deliberate set of cross-platform abstractions. Every platform helper is centrally tested by mocking `process.platform`; inline checks bypass those tests and become silent regressions. Whenever you'd type `process.platform`, stop and check the helper inventory in `docs/CROSS_PLATFORM.md` first.
|
||||
|
||||
### Read `docs/CROSS_PLATFORM.md` before merging if you touch any of:
|
||||
|
||||
- Process spawning, killing, signalling, or process-tree teardown (`child_process`, `process.kill`, runtime plugins)
|
||||
- File paths — comparison, joining, walking, anything OS-specific
|
||||
- Shell commands (`exec`, `execFile`, command strings, redirections, PowerShell-vs-bash)
|
||||
- Network binding, sockets, anything that says `localhost`
|
||||
- Shell-outs to POSIX tools (`tmux`, `lsof`, `pkill`, `which`, coreutils)
|
||||
- Adding any new `if (process.platform === "win32")` check (it should go into `platform.ts` instead — see the Golden Rule)
|
||||
- Runtime / agent / workspace plugin code that runs on both `runtime-tmux` and `runtime-process`
|
||||
- Agent-plugin internals: `setupPathWrapperWorkspace`, `getActivityState`, `formatLaunchCommand`, `isProcessRunning`, `detect()`
|
||||
- The Windows pty-host pipe protocol or registry (`pty-client.ts`, `windows-pty-registry.ts`, `sweepWindowsPtyHosts`)
|
||||
|
||||
### Quick reference: helpers to use instead of raw platform checks
|
||||
|
||||
All importable from `@aoagents/ao-core` unless noted:
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| OS check | `isWindows()` |
|
||||
| Pick runtime | `getDefaultRuntime()` |
|
||||
| Resolve shell (PowerShell vs `/bin/sh`) | `getShell()` |
|
||||
| Kill process + descendants | `killProcessTree(pid, signal?)` |
|
||||
| Find PID listening on a port | `findPidByPort(port)` |
|
||||
| Default env (HOME / TMPDIR / SHELL / PATH / USER) | `getEnvDefaults()` |
|
||||
| Compare paths (case-insensitive on NTFS/APFS) | `pathsEqual()` / `canonicalCompareKey()` from `cli/src/lib/path-equality.ts` |
|
||||
| Escape shell args | `shellEscape()` |
|
||||
| Install agent PATH wrappers (`gh`/`git`) | `setupPathWrapperWorkspace(workspacePath)` |
|
||||
| Build env PATH with `~/.ao/bin` prepended | `buildAgentPath(basePath?)` |
|
||||
| Tail JSONL | `readLastJsonlEntry` / `readLastActivityEntry` |
|
||||
| Activity-state contract helpers | `checkActivityLogState`, `getActivityFallbackState`, `classifyTerminalActivity`, `recordTerminalActivity`, `appendActivityEntry` |
|
||||
| Windows pty-host registry (used by `ao stop`) | `registerWindowsPtyHost`, `getWindowsPtyHosts`, `unregisterWindowsPtyHost`, `clearWindowsPtyHostRegistry` |
|
||||
| Reap orphan pty-hosts on `ao stop` | `sweepWindowsPtyHosts()` from `@aoagents/ao-plugin-runtime-process` |
|
||||
| Talk to a Windows pty-host over its named pipe | `getPipePath`, `connectPtyHost`, `ptyHostSendMessage`, `ptyHostGetOutput`, `ptyHostIsAlive`, `ptyHostKill` from `@aoagents/ao-plugin-runtime-process` |
|
||||
| Validate user-supplied session ID before pipe/shell use | `validateSessionId()` from `@/server/tmux-utils` |
|
||||
| Resolve a session's Windows pipe path | `resolvePipePath()` from `@/server/tmux-utils` |
|
||||
| POSIX-only Ctrl+C signal forwarding | `forwardSignalsToChild()` from `cli/src/lib/shell.ts` (guard with `!isWindows()`) |
|
||||
| Defensive PowerShell sweep of orphan pty-hosts | `stopStaleWindowsPtyHosts(projectDir)` from `web/src/lib/windows-pty-cleanup.ts` |
|
||||
|
||||
`docs/CROSS_PLATFORM.md` has the full helper reference with import paths, the EPERM-vs-ESRCH gotcha when probing processes (with a copyable code snippet), path case-insensitivity rules, PowerShell-vs-bash differences (`& ` call-operator, `$env:VAR`, no `/dev/null`, no `$(cat …)`, `.cmd`/`.bat`/`.exe` shim resolution via `shell: isWindows()`), the IPv6 `localhost` stall on Windows, agent-plugin Windows specifics, the test pattern for mocking `process.platform`, and a 10-point pre-merge checklist. **Run through that checklist for any non-trivial change.**
|
||||
|
||||
### Environment variables to know about
|
||||
|
||||
- `AO_SHELL` — overrides `getShell()` resolution (escape hatch for Git Bash users on Windows). Args inferred from basename: `cmd` → `/c`, `bash`/`sh`/`zsh` → `-c`, anything else → `-Command`.
|
||||
- `AO_BASH_PATH` — used by `script-runner.ts` on Windows to locate bash before falling back to Git Bash auto-detection. WSL bash is excluded (it sees Linux paths from a Windows cwd, breaking script semantics).
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
- **TypeScript strict mode** — no `any` types (`@typescript-eslint/no-explicit-any: error`)
|
||||
- **Consistent type imports** — `import type { Foo }` enforced by ESLint
|
||||
- **Immutable patterns** — spread operator, never mutate in place
|
||||
- **Prefer const** — `no-var`, `prefer-const`
|
||||
- **No eval** — `no-eval`, `no-implied-eval`, `no-new-func`
|
||||
- **Unused vars** — prefix with `_` (`argsIgnorePattern: "^_"`)
|
||||
|
||||
### File Organization
|
||||
|
||||
- Components in flat `components/` directory (no nesting)
|
||||
- Hooks in `hooks/` with `use` prefix
|
||||
- Tests in `__tests__/` subdirectories
|
||||
- No barrel files except `core/src/index.ts`
|
||||
- Max 400 lines per component file
|
||||
|
||||
### Naming
|
||||
|
||||
- PascalCase for components/classes
|
||||
- camelCase for functions/variables
|
||||
- `use*` for hooks, `is*`/`has*` for booleans
|
||||
|
||||
### Imports
|
||||
|
||||
- `@/` alias -> `packages/web/src/`
|
||||
- `@aoagents/ao-core` for core imports
|
||||
- `workspace:*` for cross-package
|
||||
|
||||
### Web / Styling
|
||||
|
||||
- Tailwind utility classes only — **no inline `style=` attributes**
|
||||
- CSS custom properties via `var(--color-*)` from `globals.css` `@theme` block
|
||||
- Dark theme must always be preserved
|
||||
- **No external UI component libraries** (no Radix, shadcn, etc.)
|
||||
- Client components marked `"use client"`; server components for pages
|
||||
- State: React hooks only (no Redux/Zustand)
|
||||
- Real-time updates: SSE via `useSessionEvents` hook (5s interval, do not change)
|
||||
|
||||
### Testing
|
||||
|
||||
- Vitest + @testing-library/react
|
||||
- Test files: `{Module}.test.ts` or `{Component}.test.tsx` in `__tests__/`
|
||||
- Test files for all new components
|
||||
- Relaxed lint in tests: `any` and `console.log` allowed
|
||||
|
||||
### Commits
|
||||
|
||||
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`, `ci:`
|
||||
- Changesets for version management
|
||||
- gitleaks pre-commit hook — never commit secrets
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/core/src/types.ts` | Central type definitions (all 8 plugin interfaces) |
|
||||
| `packages/core/src/session-manager.ts` | Session CRUD + stale runtime reconciliation (persists runtime_lost on dead runtimes) |
|
||||
| `packages/core/src/lifecycle-manager.ts` | State machine + polling loop + reactions |
|
||||
| `packages/core/src/lifecycle-state.ts` | Canonical lifecycle → legacy status mapping (deriveLegacyStatus) |
|
||||
| `packages/core/src/config.ts` | YAML config loading with Zod validation |
|
||||
| `packages/core/src/plugin-registry.ts` | Plugin discovery and resolution |
|
||||
| `packages/core/src/index.ts` | Core public API (stable, do not break) |
|
||||
| `packages/web/src/components/Dashboard.tsx` | Main dashboard view |
|
||||
| `packages/web/src/components/SessionDetail.tsx` | Session detail view |
|
||||
| `packages/web/src/components/DirectTerminal.tsx` | xterm.js terminal with WebSocket |
|
||||
| `packages/web/src/components/SessionCard.tsx` | Kanban session card |
|
||||
| `packages/web/src/hooks/useSessionEvents.ts` | SSE consumer hook (project filter optional — sidebar uses unscoped) |
|
||||
| `packages/web/src/lib/types.ts` | Dashboard types |
|
||||
| `packages/web/src/app/globals.css` | Design tokens and base styles (full token definitions) |
|
||||
| `DESIGN.md` | **Design system reference** — design principles, token mapping, component patterns, anti-patterns (read this before writing any web UI) |
|
||||
| `agent-orchestrator.yaml` | Project-level config (user-created) |
|
||||
| `eslint.config.js` | ESLint flat config |
|
||||
| `tsconfig.base.json` | Shared TypeScript base config |
|
||||
| `packages/cli/src/commands/start.ts` | ao start/stop commands + Ctrl+C graceful shutdown |
|
||||
| `packages/cli/src/lib/running-state.ts` | RunningState + LastStopState management (register/unregister, last-stop read/write) |
|
||||
| `packages/web/src/components/ProjectSidebar.tsx` | Sidebar — always shows all projects' sessions |
|
||||
|
||||
## Skills
|
||||
|
||||
The `skills/` directory contains reusable workflow documents for common tasks. Load them before starting work:
|
||||
|
||||
| Skill | When to load |
|
||||
|-------|-------------|
|
||||
| [`skills/bug-triage/SKILL.md`](skills/bug-triage/SKILL.md) | Triage a bug report — investigate, search duplicates, file GitHub issues, push fix PRs |
|
||||
| [`skills/agent-orchestrator/SKILL.md`](skills/agent-orchestrator/SKILL.md) | Architecture and conventions for working on this codebase |
|
||||
| [`skills/release-notes/ao-weekly-release/SKILL.md`](skills/release-notes/ao-weekly-release/SKILL.md) | Generate weekly release notes from git history |
|
||||
| [`skills/social-media/SKILL.md`](skills/social-media/SKILL.md) | Social media post generation |
|
||||
|
||||
See [`skills/README.md`](skills/README.md) for how to install skills into other coding agents (Cursor, Copilot, Codex, etc.).
|
||||
|
||||
## Plugin Standards
|
||||
|
||||
### Package Layout
|
||||
|
||||
```
|
||||
packages/plugins/{slot}-{name}/
|
||||
├── package.json # @aoagents/ao-plugin-{slot}-{name}
|
||||
├── tsconfig.json # extends ../../../tsconfig.base.json
|
||||
├── src/
|
||||
│ ├── index.ts # manifest + create + detect (default export)
|
||||
│ └── __tests__/ # vitest tests
|
||||
```
|
||||
|
||||
### Naming
|
||||
|
||||
- Package: `@aoagents/ao-plugin-{slot}-{name}` (lowercase, hyphenated)
|
||||
- `manifest.name` must match the `{name}` suffix (e.g. package `...-runtime-tmux` -> name: `"tmux"`)
|
||||
- `manifest.slot` must use `as const` to preserve the literal type
|
||||
|
||||
### Export Contract
|
||||
|
||||
Every plugin default-exports a `PluginModule<T>`:
|
||||
|
||||
```typescript
|
||||
import type { PluginModule, Runtime } from "@aoagents/ao-core";
|
||||
|
||||
export const manifest = {
|
||||
name: "tmux",
|
||||
slot: "runtime" as const,
|
||||
description: "tmux session runtime",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(config?: Record<string, unknown>): Runtime {
|
||||
// Validate config here, not in individual methods
|
||||
// Use closure to capture validated config
|
||||
return { ... };
|
||||
}
|
||||
|
||||
// Optional: check if binary/dependency is available on system
|
||||
export function detect(): boolean { ... }
|
||||
|
||||
export default { manifest, create, detect } satisfies PluginModule<Runtime>;
|
||||
```
|
||||
|
||||
### Config Handling
|
||||
|
||||
- Plugin-level config comes via `create(config)` from the YAML notifier/tracker blocks
|
||||
- Project-level config (e.g. `agentConfig`, `trackerConfig`) is passed to individual methods
|
||||
- Validate in `create()`, store via closure — don't re-validate per call
|
||||
- Warn (don't throw) for missing optional config during plugin load
|
||||
- Throw with descriptive message when a required config is missing at method call time
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Wrap errors with `cause` for debugging: `throw new Error("msg", { cause: err })`
|
||||
- Return `null` for "not found" (e.g. tracker issue lookup), throw for unexpected errors
|
||||
- Never silently swallow errors
|
||||
- Use `shellEscape()` from core for all command arguments (prevent injection)
|
||||
|
||||
### Interface Implementation
|
||||
|
||||
- All I/O methods return `Promise<T>` (async-first)
|
||||
- Plugins are loosely coupled — communicate through Session object and Lifecycle Manager, never call other plugins directly
|
||||
- Implement `destroy()` / cleanup with best-effort semantics
|
||||
|
||||
### Core Utilities Available to Plugins
|
||||
|
||||
```typescript
|
||||
import {
|
||||
shellEscape, // Safe command argument escaping
|
||||
validateUrl, // Webhook URL validation
|
||||
readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL)
|
||||
readLastActivityEntry, // Read last AO activity JSONL entry
|
||||
checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL
|
||||
getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay
|
||||
recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append)
|
||||
classifyTerminalActivity, // Classify terminal output via detectActivity
|
||||
appendActivityEntry, // Low-level JSONL append
|
||||
setupPathWrapperWorkspace, // Install ~/.ao/bin wrappers + .ao/AGENTS.md
|
||||
buildAgentPath, // Prepend ~/.ao/bin to PATH
|
||||
normalizeAgentPermissionMode, // Normalize permission mode strings
|
||||
DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold
|
||||
DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window
|
||||
ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock
|
||||
PREFERRED_GH_PATH, // /usr/local/bin/gh
|
||||
CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants
|
||||
type Session, type ProjectConfig, type RuntimeHandle,
|
||||
} from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
- Vitest in `src/__tests__/index.test.ts`
|
||||
- Mock external CLIs, file I/O, HTTP calls
|
||||
- Test manifest values, `create()` return shape, all public methods, and error paths
|
||||
- Use `beforeEach` to reset mocks
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- Hardcoded secrets -> use `process.env`, throw if missing
|
||||
- Shell injection -> use `shellEscape()` for all arguments
|
||||
- Large file reads -> use streaming or `readLastJsonlEntry()`
|
||||
- Config validation in methods -> validate once in `create()`, closure the rest
|
||||
|
||||
### Agent Plugin Implementation Standards
|
||||
|
||||
All agent plugins (claude-code, codex, aider, opencode, etc.) must implement the full `Agent` interface. The dashboard depends on these methods for PR tracking, cost display, and session resume.
|
||||
|
||||
**Required methods (all agents):**
|
||||
|
||||
| Method | Purpose | Return `null` OK? |
|
||||
|--------|---------|-------------------|
|
||||
| `getLaunchCommand` | Shell command to start the agent | No |
|
||||
| `getEnvironment` | Env vars for agent process (must include `~/.ao/bin` in PATH) | No |
|
||||
| `detectActivity` | Terminal output classification (deprecated, but required) | No |
|
||||
| `getActivityState` | JSONL/API-based activity detection (min 3 states: active/ready/idle) | Yes (if no data) |
|
||||
| `isProcessRunning` | Check process alive via tmux TTY or PID | No |
|
||||
| `getSessionInfo` | Extract summary, cost, session ID from agent's data | Yes (if agent has no introspection) |
|
||||
|
||||
**Optional methods (implement when the agent supports it):**
|
||||
|
||||
| Method | Purpose | When to skip |
|
||||
|--------|---------|-------------|
|
||||
| `getRestoreCommand` | Resume a previous session | Agent has no resume capability (return `null`) |
|
||||
| `setupWorkspaceHooks` | Install metadata-update hooks (PATH wrappers or agent-native) | Never — required for dashboard PR tracking |
|
||||
| `postLaunchSetup` | Post-launch config (re-ensure hooks, resolve binary) | Only if no post-launch work needed |
|
||||
| `recordActivity` | Write terminal-derived activity to JSONL for `getActivityState` | Agent has native JSONL with full state coverage (Claude Code). Codex implements it as a safety net for when its native JSONL is missing/unparseable. |
|
||||
|
||||
**Metadata hooks are critical.** Without `setupWorkspaceHooks`, PRs created by agents won't appear in the dashboard. Two patterns exist:
|
||||
- **Agent-native hooks** (Claude Code): PostToolUse hooks in `.claude/settings.json`
|
||||
- **PATH wrappers** (Codex, Aider, OpenCode): `~/.ao/bin/gh` and `~/.ao/bin/git` intercept commands. Call `setupPathWrapperWorkspace(workspacePath)` — it installs wrappers to `~/.ao/bin/` and writes session context to `.ao/AGENTS.md` (gitignored, does not modify tracked files).
|
||||
|
||||
**Environment requirements:**
|
||||
- All agents must set `AO_SESSION_ID` and optionally `AO_ISSUE_ID`
|
||||
- All agents using PATH wrappers must prepend `~/.ao/bin` to PATH
|
||||
- Use `normalizeAgentPermissionMode` from `@aoagents/ao-core` (not a local duplicate)
|
||||
|
||||
**Activity detection architecture:**
|
||||
|
||||
`getActivityState` is the most critical method in the agent plugin. The dashboard, lifecycle manager, and stuck-detection all depend on it returning correct states. **Every agent plugin must produce all 6 states over its lifetime:**
|
||||
|
||||
```
|
||||
spawning → active ↔ ready → idle → exited
|
||||
↘ waiting_input / blocked ↗
|
||||
```
|
||||
|
||||
| State | Meaning | When |
|
||||
|-------|---------|------|
|
||||
| `active` | Agent is working right now | Activity within last 30s |
|
||||
| `ready` | Agent finished recently, may resume | 30s–5min since last activity |
|
||||
| `idle` | Agent has been quiet for a while | >5min since last activity |
|
||||
| `waiting_input` | Agent is blocked on user approval | Permission prompt visible |
|
||||
| `blocked` | Agent hit an error it can't recover from | Error state detected |
|
||||
| `exited` | Process is dead | `isProcessRunning` returns false |
|
||||
|
||||
**The `getActivityState` contract — implement exactly this cascade:**
|
||||
|
||||
```typescript
|
||||
async getActivityState(session, readyThresholdMs?): Promise<ActivityDetection | null> {
|
||||
// 1. PROCESS CHECK — always first
|
||||
if (!running) return { state: "exited", timestamp };
|
||||
|
||||
// 2. ACTIONABLE STATES — check for waiting_input/blocked
|
||||
// Source: native JSONL (Claude Code, Codex) OR AO activity JSONL (others)
|
||||
// These are the only states checkActivityLogState() surfaces.
|
||||
// If found, return immediately.
|
||||
|
||||
// 3. NATIVE SIGNAL — agent-specific API for timestamp (preferred)
|
||||
// Source: agent's session list API, native JSONL timestamps, etc.
|
||||
// Classify by age: active (<30s) / ready (30s–threshold) / idle (>threshold)
|
||||
|
||||
// 4. JSONL ENTRY FALLBACK — always implement this
|
||||
// Source: getActivityFallbackState(activityResult, activeWindowMs, threshold)
|
||||
// Uses the entry's detected state + entry.ts for age-based decay.
|
||||
// Decay only demotes (active→ready→idle), never promotes.
|
||||
// This is the SAFETY NET when the native signal is unavailable.
|
||||
// Without this, getActivityState returns null and the dashboard shows
|
||||
// no activity for the entire session lifetime.
|
||||
|
||||
// 5. Return null only if there is genuinely no data at all.
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 is mandatory.** If you skip the JSONL entry fallback, `getActivityState` will return `null` whenever the native API fails (binary not in PATH, API changed, session not found, timeout). The dashboard will show no activity state and stuck-detection breaks. This was a real bug in the OpenCode plugin — `findOpenCodeSession` returned null due to a session creation issue, and without the fallback, the entire active/ready/idle flow was dead. Use `getActivityFallbackState()` from core — it handles age-based decay and staleness caps correctly.
|
||||
|
||||
**Two activity detection patterns exist:**
|
||||
|
||||
| Pattern | Used by | How it works |
|
||||
|---------|---------|-------------|
|
||||
| **Native JSONL** | Claude Code, Codex | Agent writes its own JSONL with rich state (`permission_request`, `tool_call`, `error`, etc.). `getActivityState` reads the last entry and maps it to activity states. |
|
||||
| **AO Activity JSONL** | Aider, OpenCode, new agents | Agent implements `recordActivity`. Lifecycle manager calls it each poll cycle with terminal output. It calls `classifyTerminalActivity()` → `appendActivityEntry()` to write to `{workspacePath}/.ao/activity.jsonl`. `getActivityState` reads from this file. |
|
||||
|
||||
**For agents using AO Activity JSONL (the common case for new plugins):**
|
||||
|
||||
1. Implement `recordActivity` — delegate to the shared `recordTerminalActivity()`:
|
||||
```typescript
|
||||
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) =>
|
||||
this.detectActivity(output),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`recordTerminalActivity` handles classification, deduplication (20s window for non-actionable states), and appending. You don't need to implement dedup yourself.
|
||||
|
||||
2. Implement `detectActivity` with patterns specific to the agent's terminal output:
|
||||
```typescript
|
||||
detectActivity(terminalOutput: string): ActivityState {
|
||||
// Match the ACTUAL prompts/patterns the agent emits.
|
||||
// Test with real terminal output — don't guess patterns.
|
||||
// Return: "idle" | "active" | "waiting_input" | "blocked"
|
||||
}
|
||||
```
|
||||
|
||||
3. In `getActivityState`, use `checkActivityLogState()` for waiting_input/blocked, then fall back to `getActivityFallbackState()`:
|
||||
```typescript
|
||||
// checkActivityLogState returns non-null ONLY for waiting_input/blocked.
|
||||
// active/idle/ready intentionally return null — use the fallback for those.
|
||||
const activityResult = await readLastActivityEntry(session.workspacePath);
|
||||
const activityState = checkActivityLogState(activityResult);
|
||||
if (activityState) return activityState;
|
||||
|
||||
// ... try native signal first (session list API, git commits, etc.) ...
|
||||
|
||||
// JSONL entry fallback (REQUIRED — do not skip)
|
||||
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
||||
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
|
||||
if (fallback) return fallback;
|
||||
```
|
||||
|
||||
`getActivityFallbackState` uses the entry's detected state with age-based decay (active→ready→idle) and respects the entry state as a ceiling (never promotes idle to active). Stale waiting_input/blocked entries (>5min) decay to idle.
|
||||
|
||||
**Required tests for `getActivityState` — all agent plugins must have these:**
|
||||
|
||||
1. Returns `exited` when process is not running
|
||||
2. Returns `waiting_input` from JSONL when agent is at a permission prompt
|
||||
3. Returns `blocked` from JSONL when agent hit an error
|
||||
4. Returns `active` from native signal when agent was recently active
|
||||
5. Returns `active` from JSONL entry fallback when native signal fails (fresh entry)
|
||||
6. Returns `idle` from JSONL entry fallback when native signal fails (old entry with age decay)
|
||||
7. Returns `null` when both native signal and JSONL are unavailable
|
||||
|
||||
**`isProcessRunning` must:**
|
||||
- Support tmux runtime (TTY-based `ps` lookup with process name regex)
|
||||
- Support process runtime (PID signal-0 check with EPERM handling)
|
||||
- Match BOTH the node wrapper name AND the actual binary name (some agents install as `.agentname` with a dot prefix — the regex must handle this)
|
||||
- Return `false` (not `null`) on error
|
||||
|
||||
## Constraints
|
||||
|
||||
- C-01: No new UI component libraries
|
||||
- C-02: No inline styles in new/modified code
|
||||
- C-04: Component files max 400 lines
|
||||
- C-05: Dark theme preserved (no redesign)
|
||||
- C-06: Next.js App Router only
|
||||
- C-07: No animation libraries
|
||||
- C-12: Test files for all new components
|
||||
- C-13: pnpm `workspace:*` protocol for cross-package deps
|
||||
- C-14: SSE 5s interval unchanged
|
||||
Read and follow [`AGENTS.md`](AGENTS.md) for repository layout, commands, coding conventions, and hard rules.
|
||||
|
||||
## App state lives under `~/.ao` only
|
||||
|
||||
All app state, the daemon's data dir, `running.json`, worktrees, and the Electron
|
||||
supervisor's `userData` (Chromium cache, cookies, local/session storage, crash
|
||||
dumps), must resolve under `~/.ao` (overridable via `AO_DATA_DIR`/`AO_RUN_FILE`).
|
||||
Never write to or read from `~/Library/Application Support` or any other OS-default
|
||||
app-data location. `frontend/src/main.ts` pins Electron's `userData` to
|
||||
`~/.ao/electron`; do not remove that override. See the hard rule in `AGENTS.md`.
|
||||
|
||||
## Design System
|
||||
|
||||
Always read [`DESIGN.md`](DESIGN.md) before making any visual or UI decision —
|
||||
**start with the "clone agent-orchestrator verbatim" banner at the top**, which
|
||||
governs the current look.
|
||||
|
||||
The renderer **clones the agent-orchestrator web app verbatim**
|
||||
(`~/Projects/agent-orchestrator/packages/web/src`) in looks and design, with a
|
||||
refined-blue accent and the terminal keeping its own palette. This **supersedes the
|
||||
older "match emdash" framing** in DESIGN.md (per explicit user decision 2026-06-10).
|
||||
Build new UI from shadcn primitives (`components/ui/*`) where a component fits. Do not
|
||||
deviate without explicit user approval. In QA/review, flag any renderer code that
|
||||
diverges from **agent-orchestrator** — do **not** re-flag emdash mismatches.
|
||||
|
||||
When showing or demoing frontend changes, run `ao preview [url]` from inside the
|
||||
session so the change renders in the desktop browser panel (the inspector rail's
|
||||
Browser tab); do not just describe it.
|
||||
|
|
|
|||
361
CONTRIBUTING.md
361
CONTRIBUTING.md
|
|
@ -1,361 +0,0 @@
|
|||
# Contributing to Agent Orchestrator
|
||||
|
||||
Thanks for your interest in contributing. This guide covers how to report bugs, submit PRs, and build new plugins.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Setup and first build](#development-setup)
|
||||
- [Plugin development](#building-a-plugin)
|
||||
- [Code conventions](#code-conventions)
|
||||
- [PR process](#pull-request-process)
|
||||
|
||||
---
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Open an issue at [github.com/ComposioHQ/agent-orchestrator/issues](https://github.com/ComposioHQ/agent-orchestrator/issues).
|
||||
|
||||
Include:
|
||||
|
||||
- `ao --version` output
|
||||
- OS and Node.js version (`node --version`)
|
||||
- Steps to reproduce
|
||||
- What you expected vs. what happened
|
||||
- Relevant output from `ao doctor`
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
**Prerequisites**: Node.js 20+, pnpm 9.15+, Git 2.25+, gh CLI
|
||||
|
||||
- **Unix (macOS/Linux)**: also install `tmux` — it is the default runtime.
|
||||
- **Windows**: tmux is **not** required. The default runtime on Windows is `process` (ConPTY via `node-pty`), and PowerShell is the default shell. See [docs/CROSS_PLATFORM.md](docs/CROSS_PLATFORM.md) for what's different on Windows when contributing.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ComposioHQ/agent-orchestrator.git
|
||||
cd agent-orchestrator
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Build order matters — `@aoagents/ao-core` must be built before the CLI, web, or plugins can run. `pnpm build` at the root handles this automatically.
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
pnpm test # all packages
|
||||
pnpm --filter @aoagents/ao-core test # core only
|
||||
pnpm --filter @aoagents/ao-core test -- --watch # watch mode
|
||||
pnpm test:integration # integration tests
|
||||
```
|
||||
|
||||
### Running the dashboard locally
|
||||
|
||||
```bash
|
||||
cp agent-orchestrator.yaml.example agent-orchestrator.yaml
|
||||
# edit agent-orchestrator.yaml for your setup
|
||||
pnpm --filter @aoagents/ao-web dev
|
||||
```
|
||||
|
||||
### Refreshing a local AO install
|
||||
|
||||
If your local `ao` launcher or built packages seem stale, refresh the install from a clean `main` checkout:
|
||||
|
||||
```bash
|
||||
git switch main
|
||||
git status --short --branch # confirm the install repo is clean
|
||||
ao update
|
||||
```
|
||||
|
||||
`ao update` fast-forwards the local install repo, reinstalls dependencies, clean-rebuilds `@aoagents/ao-core`, `@aoagents/ao-cli`, and `@aoagents/ao-web`, refreshes the global launcher with `npm link`, and finishes with CLI smoke tests. Use `ao update --skip-smoke` when you only need the rebuild step, or `ao update --smoke-only` when validating an existing install.
|
||||
|
||||
## Release Architecture (maintainers only)
|
||||
|
||||
AO uses a **two-stage release pipeline**. This public repo handles version bumps, git tags, and GitHub releases. npm publishing runs on a private server (AO cron job) that polls GitHub releases and publishes when a new tag is ahead of the current npm version. Org compliance forbids npm publish credentials in public repositories, so `NPM_TOKEN` never enters this repo.
|
||||
|
||||
### Where things happen
|
||||
|
||||
| Stage | Where | Responsibility |
|
||||
| ------------------------ | ------------------------------ | ------------------------------------------------------------------------ |
|
||||
| Versioning + GitHub release | This repo (public, CI) | Changesets version bumps, git tags, `gh release create` |
|
||||
| npm publish | Private server (AO cron) | Detects new GitHub releases → builds → `pnpm changeset publish` |
|
||||
|
||||
The flow on every release:
|
||||
|
||||
```
|
||||
This repo (public CI) Private server (AO cron)
|
||||
────────────────────── ─────────────────────────
|
||||
release.yml: Polls gh release list
|
||||
changeset version Detects new vX.Y.Z tag
|
||||
push vX.Y.Z tag Compare to npm @latest/@nightly
|
||||
gh release create vX.Y.Z If behind → checkout tag → build → publish
|
||||
|
||||
canary.yml: Same cron, detects prereleases
|
||||
changeset version --snapshot Publishes with --tag nightly
|
||||
commit snapshot bump + tag
|
||||
gh release create --prerelease
|
||||
```
|
||||
|
||||
Each release pushes a single umbrella `vX.Y.Z` git tag pointing at the version-bump commit. We deliberately do **not** run `pnpm changeset tag`, which would emit one tag per publishable package (~27) every release — fine for stable's monthly cadence, noisy on the nightly cadence (~7 000 tags/year). The npm publisher only consumes the umbrella tag, so the per-package tags add no value.
|
||||
|
||||
### Secrets
|
||||
|
||||
This repo requires **no additional secrets** beyond the automatic `GITHUB_TOKEN`. `NPM_TOKEN` lives only on the private server.
|
||||
|
||||
### How releases are cut
|
||||
|
||||
- **Stable**: merge the "chore: version packages" PR opened by `changesets/action`. `release.yml` tags the bumped packages and creates a `vX.Y.Z` GitHub release. The AO cron detects the new release and publishes to npm `@latest`.
|
||||
- **Nightly**: `canary.yml` runs on cron (23:30 IST Fri–Tue) or via `workflow_dispatch`. It snapshots versions to `X.Y.Z-nightly-<sha>` format (e.g., `0.6.1-nightly-7c46dc92`), tags, and creates a prerelease GitHub release. The AO cron detects the new prerelease and publishes to npm `@nightly`.
|
||||
|
||||
There is no path from this repo that calls `npm publish` directly.
|
||||
|
||||
### Idempotency
|
||||
|
||||
`release.yml` is idempotent: each step (tag push, GitHub release creation) is gated on whether that piece of state already exists, so a re-run after a partial failure picks up only the missing steps.
|
||||
|
||||
The AO cron is also idempotent — `pnpm changeset publish` skips packages whose current version is already on the registry, so re-running after a partial publish is safe.
|
||||
|
||||
### Recovery
|
||||
|
||||
If `release.yml` fails after the GitHub release was created, **re-run the failed workflow**: the state-detection step will see that the tag and release already exist and skip those steps.
|
||||
|
||||
If the AO cron fails to publish, it will retry on the next poll cycle (every 15 minutes). No manual intervention needed for transient failures. For persistent issues, check the cron logs on the private server.
|
||||
|
||||
## Testing your changes
|
||||
|
||||
### Latest main at any time
|
||||
|
||||
```bash
|
||||
npm install -g @aoagents/ao@nightly
|
||||
```
|
||||
|
||||
The nightly cron publishes from `main` daily at 23:30 IST (Fri–Tue). The bake window (Wed–Thu) pauses scheduled nightlies; release captains can re-cut a nightly via `workflow_dispatch` if a fix lands during bake.
|
||||
|
||||
---
|
||||
|
||||
## Building a Plugin
|
||||
|
||||
The plugin system is the primary extension point. You can add support for new agents, runtimes, issue trackers, and notification channels without modifying core code.
|
||||
|
||||
### 1. Understand the interface
|
||||
|
||||
All plugin interfaces are in [`packages/core/src/types.ts`](packages/core/src/types.ts). Pick the slot that matches what you want to build:
|
||||
|
||||
| Slot | Interface | Example use case |
|
||||
| ----------- | ----------- | ------------------------------------ |
|
||||
| `runtime` | `Runtime` | Run agents in Docker, SSH, cloud VMs |
|
||||
| `agent` | `Agent` | Adapt a new AI coding tool |
|
||||
| `workspace` | `Workspace` | Different code isolation strategies |
|
||||
| `tracker` | `Tracker` | Jira, Asana, or custom issue systems |
|
||||
| `scm` | `SCM` | GitLab, Bitbucket support |
|
||||
| `notifier` | `Notifier` | Email, Discord, custom webhooks |
|
||||
| `terminal` | `Terminal` | Different terminal UI integrations |
|
||||
|
||||
### 2. Create the package
|
||||
|
||||
```bash
|
||||
mkdir -p packages/plugins/runtime-myplugin/src
|
||||
cd packages/plugins/runtime-myplugin
|
||||
```
|
||||
|
||||
`package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@aoagents/ao-runtime-myplugin",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aoagents/ao-core": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tsconfig.json` — copy from an existing plugin like `packages/plugins/runtime-tmux/`.
|
||||
|
||||
### 3. Implement the interface
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
import type { PluginModule, Runtime } from "@aoagents/ao-core";
|
||||
|
||||
export const manifest = {
|
||||
name: "myplugin",
|
||||
slot: "runtime" as const,
|
||||
description: "My custom runtime",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(): Runtime {
|
||||
return {
|
||||
name: "myplugin",
|
||||
async create(config) {
|
||||
/* start session */
|
||||
},
|
||||
async destroy(sessionName) {
|
||||
/* tear down */
|
||||
},
|
||||
async send(sessionName, text) {
|
||||
/* send input */
|
||||
},
|
||||
async isRunning(sessionName) {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Runtime>;
|
||||
```
|
||||
|
||||
### 4. Register the plugin
|
||||
|
||||
Add it to the CLI's dependencies in `packages/cli/package.json`:
|
||||
|
||||
```json
|
||||
"@aoagents/ao-runtime-myplugin": "workspace:*"
|
||||
```
|
||||
|
||||
Then register it in `packages/core/src/plugin-registry.ts` inside `loadBuiltins()`.
|
||||
|
||||
### 5. Add tests
|
||||
|
||||
```typescript
|
||||
// src/index.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { create } from "./index.js";
|
||||
|
||||
describe("myplugin runtime", () => {
|
||||
it("reports not running for unknown session", async () => {
|
||||
const runtime = create();
|
||||
expect(await runtime.isRunning("unknown-session")).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Build and test
|
||||
|
||||
```bash
|
||||
pnpm --filter @aoagents/ao-runtime-myplugin build
|
||||
pnpm --filter @aoagents/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
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full reference. The short version:
|
||||
|
||||
### Behavioral Guidelines
|
||||
|
||||
Beyond syntax and style, follow these principles:
|
||||
|
||||
- **State assumptions explicitly** - if a task is ambiguous, present interpretations rather than guessing.
|
||||
- **Minimum viable change** - no speculative features, no unused abstractions, no formatting changes outside your diff.
|
||||
- **Every changed line traces to the task** - if you can't explain why a line changed, revert it.
|
||||
- **Write a failing test first** - for bug fixes, reproduce the bug in a test before implementing the fix.
|
||||
- **Don't refactor unrelated code** - mention dead code you spot, don't delete it.
|
||||
|
||||
These match the "Working Principles" section in CLAUDE.md. AI agents working on this repo are instructed to follow these same rules.
|
||||
|
||||
**TypeScript**
|
||||
|
||||
- ESM modules, `.js` extensions on local imports
|
||||
- `node:` prefix for builtins
|
||||
- No `any` — use `unknown` + type guards
|
||||
- Strict mode, semicolons, double quotes, 2-space indent
|
||||
|
||||
**Shell commands**
|
||||
|
||||
- Always `execFile`, never `exec`
|
||||
- Always pass args as an array, never interpolate into strings
|
||||
- Always add timeouts
|
||||
|
||||
**Tests**
|
||||
|
||||
- Unit tests alongside source in `src/__tests__/`
|
||||
- Mock plugins in tests — don't call real tmux, GitHub, or external services
|
||||
- Test the interface contract, not internal implementation details
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Fork and branch** from `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b feat/your-feature
|
||||
```
|
||||
|
||||
2. **Make your changes** — keep PRs focused on one thing.
|
||||
|
||||
3. **Build, test, lint**:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm test
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
4. **Commit** with [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat: add kubernetes runtime plugin
|
||||
fix: handle missing LINEAR_API_KEY gracefully
|
||||
docs: add plugin development guide
|
||||
chore: update vitest to v2
|
||||
```
|
||||
|
||||
5. **Push and open a PR**. In the PR description:
|
||||
- What changed and why
|
||||
- How to test it
|
||||
- Link to the issue it closes (e.g., `Closes #123`)
|
||||
|
||||
6. **Address review comments** — update the branch and push. Reply to comments when done.
|
||||
|
||||
### What gets reviewed
|
||||
|
||||
- Does the change work as described?
|
||||
- Are there tests?
|
||||
- Does it follow the TypeScript and shell conventions in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)?
|
||||
- For new features: is it documented?
|
||||
|
||||
### CI checks
|
||||
|
||||
All PRs must pass:
|
||||
|
||||
- `pnpm build` — no TypeScript errors
|
||||
- `pnpm test` — all tests green
|
||||
- `pnpm lint` — no lint errors
|
||||
- Secret scanning — no leaked credentials
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
399
DESIGN.md
399
DESIGN.md
|
|
@ -1,181 +1,274 @@
|
|||
# Design System — Agent Orchestrator
|
||||
# Design System — ReverbCode
|
||||
|
||||
> **This document supersedes the previous "Warm Terminal" system.** AO's design
|
||||
> language is **Mission Control**: a calm, high-signal control room for
|
||||
> supervising a fleet of autonomous agents. The earlier warm-neutral direction
|
||||
> (Geist Sans, amber/orange orchestrator CTA, brown-tinted surfaces) is retired.
|
||||
> This file is the single source of truth — there is no second package-level
|
||||
> `DESIGN.md`. Origin: the dashboard design-language exploration in
|
||||
> [`docs/design/dashboard-language.md`](docs/design/dashboard-language.md) and
|
||||
> its canonical mockups ([`kanban.html`](docs/design/mockups/kanban.html),
|
||||
> [`session.html`](docs/design/mockups/session.html)).
|
||||
> Source of truth for the ReverbCode desktop UI (Electron + React 19 + Tailwind v4
|
||||
>
|
||||
> - Radix/shadcn + xterm, in `frontend/src/renderer`). Read this before any visual
|
||||
> or UI change. Created by `/design-consultation` on 2026-06-09.
|
||||
|
||||
## ⚠️ Design direction — clone agent-orchestrator verbatim (SUPERSEDES emdash · 2026-06-10)
|
||||
|
||||
By explicit user decision (2026-06-10), the renderer **clones the
|
||||
agent-orchestrator web app verbatim** in looks and design. This **supersedes the
|
||||
"match emdash" direction** documented in _Aesthetic Direction_ and the palette
|
||||
sections below — where they conflict, **agent-orchestrator wins**. Do not re-flag
|
||||
"this doesn't match emdash" in QA/review; flag divergence from **agent-orchestrator**.
|
||||
|
||||
- **Reference (the user's own app):** `~/Projects/agent-orchestrator/packages/web/src`
|
||||
— `app/globals.css`, `app/mc-board.css`, `app/mc-sidebar.css`,
|
||||
`components/{ProjectSidebar,Dashboard,SessionCard,SessionDetailHeader,SessionInspector,StatusBadge}.tsx`.
|
||||
- **Palette (live in `frontend/src/renderer/styles.css` `:root`):** `--bg #0a0b0d`,
|
||||
`--bg-1 #15171b`, `--fg #f4f5f7`, `--fg-muted #9ba1aa`, `--fg-passive #646a73`,
|
||||
hairline white-alpha borders, accent `--accent #4d8dff`; status: working=orange
|
||||
`#f59f4c`, needs-you=amber `#e8c14a`, mergeable=green `#74b98a`, fail=red `#ef6b6b`.
|
||||
The sidebar rail is the cooler `#08090b`.
|
||||
- **Cloned surfaces:** the four-column gradient kanban board, the `ProjectSidebar`
|
||||
(brand + project disclosure + nested session rows + Settings menu footer), the
|
||||
session topbar (Kanban back button + identity + breathing `StatusBadge` pill), and
|
||||
the shared `DashboardTopbar`/`DashboardSubhead` chrome (Coding/Reviews tabs · "N
|
||||
working" pill · subhead) reused across board/review/PR/settings.
|
||||
- **Build with shadcn primitives** where a component fits (`components/ui/*`:
|
||||
dropdown-menu, select, card, table, tooltip, …); agent-orchestrator's own
|
||||
hand-rolled CSS components are structure/behaviour reference only.
|
||||
- The one carried-over divergence still holds: the **accent is refined blue**, and
|
||||
the **terminal keeps its own palette**. Everything else tracks agent-orchestrator.
|
||||
- **Approved divergence (2026-06-10):** on macOS, a titlebar cluster (sidebar toggle +
|
||||
back/forward history arrows, `TitlebarNav`) sits beside the traffic lights,
|
||||
VS Code-style — the web reference has no window chrome, so no analogue exists.
|
||||
- **Approved divergence (2026-06-10):** the session inspector rail is fully
|
||||
collapsible, built on the shadcn resizable primitive (`pnpm dlx shadcn add
|
||||
resizable`, react-resizable-panels v4 `collapsible` panel + imperative API,
|
||||
user-requested). The panel animates to 0% via a flex-grow transition while the
|
||||
content keeps a stable min-width (yyork-style, no mid-animation reflow). Toggled
|
||||
by a `PanelRight` icon button in the session topbar and ⌘⇧B; open state + split
|
||||
width persist. The AO reference keeps the rail always visible.
|
||||
- **Approved divergence (2026-06-12):** the shell topbar spans the full window
|
||||
width and the sidebar is pinned below it (`top-14`), so the sidebar's right
|
||||
border stops at the header instead of cutting through the macOS traffic-light
|
||||
strip (user-requested). The AO reference keeps a full-height sidebar with the
|
||||
header beside it. On macOS the header always pads past the lights + TitlebarNav
|
||||
cluster (`.is-under-titlebar-nav`, 180px).
|
||||
|
||||
## Product Context
|
||||
- **What this is:** A web dashboard for supervising fleets of parallel AI coding agents. Each agent gets its own git worktree, branch, and PR. The dashboard is the operator's single pane of glass.
|
||||
- **Who it's for:** Developers running 10–30+ agents in parallel. It must stay calm and glanceable with 20+ agents running.
|
||||
- **Project type:** Next.js 15 (App Router) + React 19 + Tailwind v4. A kanban fleet board (home) and a per-session detail view.
|
||||
|
||||
## Concept & Identity
|
||||
- **What this is:** ReverbCode is an Electron desktop app for supervising many parallel
|
||||
AI coding-agent sessions, backed by a Go daemon (`backend/`). The `ao` CLI is the
|
||||
thin client over the same daemon.
|
||||
- **Who it's for:** professional software engineers running multiple coding agents at
|
||||
once who need to delegate, watch, intervene, and ship PRs.
|
||||
- **Space/peers:** agent orchestration / parallel-agent desktop tools. Closest peers:
|
||||
**emdash** (the primary design reference), **PostHog Code**, Conductor.
|
||||
- **Project type:** dark-mode-primary desktop app; terminal-dense; keyboard-driven;
|
||||
runs all day.
|
||||
- **The one memorable thing:** leverage and speed — "I'm more in control here than
|
||||
babysitting N terminal tabs myself."
|
||||
|
||||
**A calm, high-signal control room.** Linear-grade restraint, dense but humane.
|
||||
State is glanceable, not noisy.
|
||||
### Product flow (what the UI must serve)
|
||||
|
||||
**The blue/orange split.** The mascot is the Claude Code character recolored
|
||||
**blue** — the *conductor*. This drives a deliberate two-color semantic split:
|
||||
ReverbCode is **orchestrator-led**, which is the one thing that differs from emdash
|
||||
(a flat list of independent sessions). Grounded in the daemon
|
||||
(`backend/internal/session_manager/manager.go`, `docs/architecture.md`):
|
||||
|
||||
- **Blue = the orchestrator (AO itself / "you").** Brand, the single solid-fill
|
||||
primary CTA (the **Orchestrator** button), active selection, focus, links.
|
||||
- **Orange = the agents being conducted.** The per-agent identity and the
|
||||
**`working`** status — the one "an agent is alive right now" signal (a gently
|
||||
breathing dot, the terminal cursor).
|
||||
- A **Project** is a registered git repo.
|
||||
- Per project there is **one active Orchestrator** session plus **N Worker** sessions.
|
||||
Both are the same underlying "session" (durable facts: `activity_state`,
|
||||
`is_terminated`, PR facts); they differ only by `Kind` (`KindOrchestrator` vs the
|
||||
default worker). A project may run the orchestrator on a different agent than its workers.
|
||||
- The **Orchestrator is the human-facing coordinator**: you talk to it; it spawns
|
||||
workers (`ao spawn`), messages them (`ao send`), tracks progress, and synthesizes
|
||||
results. It avoids implementing unless necessary.
|
||||
- A **Worker is a normal agent session** — nothing special-cased. It runs one focused
|
||||
task in an isolated git worktree + branch, with the agent CLI in a terminal as the
|
||||
conversation, producing a diff → commit/push → PR. It escalates to the orchestrator
|
||||
only for true blockers or cross-session coordination.
|
||||
- The daemon **observes** runtime + PR/CI/review facts and **derives** display status
|
||||
at read time: `working`, `needs_input`, `ci_failed`, `changes_requested`,
|
||||
`mergeable`, `approved`, `review_pending`, `pr_open`, `idle`, `terminated`, `merged`.
|
||||
Never store display status; keep session facts small.
|
||||
|
||||
Blue does not *replace* orange; they mean different things. The board reads as a
|
||||
blue conductor surrounded by orange agents.
|
||||
## Aesthetic Direction
|
||||
|
||||
## Color discipline
|
||||
> **Superseded (2026-06-10):** see the _Design direction — clone agent-orchestrator
|
||||
> verbatim_ banner at the top. The emdash framing below is retained for history; the
|
||||
> live look tracks agent-orchestrator (same flat near-black / hairline family, so most
|
||||
> of this still reads true).
|
||||
|
||||
**Color = meaning. Most states get none.** The UI is grayscale by default;
|
||||
color is rationed so it always signals something.
|
||||
|
||||
| Token | Hex | Use |
|
||||
|-------|-----|-----|
|
||||
| Blue | `#4d8dff` | orchestrator / you — primary action, selection, focus, links (the *only* solid-fill button) |
|
||||
| Orange | `#f59f4c` | a working agent (status dot + terminal cursor) |
|
||||
| Amber | `#e8c14a` | needs-your-input / attention (incl. unresolved review comments, changes requested) |
|
||||
| Red | `#ef6b6b` | failing / stuck (CI failed, crashed, conflicts) |
|
||||
| Green | `#74b98a` | mergeable / passed / resolved |
|
||||
| Neutral grays | — | everything healthy & passive: in-review, idle, done, metadata |
|
||||
|
||||
Diff add/remove green & red are permitted in their literal context (the Changes view).
|
||||
|
||||
### Surfaces & lines (dark, cool neutral)
|
||||
|
||||
The product is **dark-only mission control**. The dark theme is authoritative.
|
||||
|
||||
| Token (literal) | Value | Maps to semantic token |
|
||||
|-----------------|-------|------------------------|
|
||||
| `--bg` | `#0a0b0d` | `--color-bg-base` (app base) |
|
||||
| `--bg-side` | `#08090b` | `--color-bg-sidebar` |
|
||||
| `--card` | `#15171b` | `--color-bg-surface` / `--color-bg-card` — **the only bordered surface** |
|
||||
| `--card-hover` | `#191b20` | `--color-bg-elevated` / `-elevated-hover` |
|
||||
| `--col` | `#0e0f12` | `--color-column-bg` (kanban trough) |
|
||||
| `--term` | `#0c0d10` | xterm background (terminal-themes.ts) |
|
||||
| `--line` | `rgba(255,255,255,0.06)` | `--color-border-subtle` / `-default` |
|
||||
| `--line-2` | `rgba(255,255,255,0.10)` | `--color-border-strong` |
|
||||
| `--t1 … --t4` | `#f4f5f7` `#9ba1aa` `#646a73` `#444951` | `--color-text-primary/secondary/tertiary/muted` |
|
||||
|
||||
These literals live at the top of the `.dark` block in
|
||||
`packages/web/src/app/globals.css`; the existing `--color-*` semantic tokens
|
||||
**alias** them, so all consuming CSS keeps working. **Don't rename the semantic
|
||||
tokens** — add/alias and migrate.
|
||||
- **Direction:** match **emdash** exactly — flat, near-black, hairline-bordered,
|
||||
utilitarian. Industrial control surface, calm chrome, the terminal as the center of gravity.
|
||||
- **Decoration level:** minimal. Type + 1px hairlines do all the work. No gradients,
|
||||
glow, blobs, or emoji.
|
||||
- **Mood:** low-glare, dense, keyboard-native; signal-over-noise.
|
||||
- **Reference:** [emdash](https://github.com/generalaction/emdash) (primary, visual +
|
||||
structural), [PostHog Code](https://github.com/PostHog/code) (secondary). Tokens
|
||||
below were extracted from emdash's `src/renderer/index.css`.
|
||||
- **Deliberate tradeoff:** to _be_ emdash, we use the **system font stack** (not a
|
||||
custom typeface) and emdash's neutral palette. We diverge in exactly one place: the
|
||||
accent is ReverbCode's **refined blue**, not emdash's jade green. The terminal keeps
|
||||
green (it is the agent CLI).
|
||||
|
||||
## Typography
|
||||
|
||||
Self-hosted via `next/font/local` (`packages/web/src/fonts/`). **No external font CDN.**
|
||||
System fonts only, like emdash — no custom/Google fonts, zero font payload.
|
||||
|
||||
- **UI = Schibsted Grotesk** (`--font-sans`). The product voice. Used for all
|
||||
chrome: titles, labels, buttons, body. A distinctive grotesk — not Inter/system.
|
||||
- **Machine = JetBrains Mono** (`--font-mono`). Branches, IDs, PR numbers, costs,
|
||||
timestamps, terminal — anything the machine emits.
|
||||
- **Numerals:** `tabular-nums` wherever numbers appear (counts, costs, tokens).
|
||||
- **Never render chrome in mono.** The sans/mono split is itself a design device:
|
||||
product voice vs. machine voice.
|
||||
- **UI / body / display:** `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Helvetica Neue", sans-serif` (San Francisco
|
||||
on macOS).
|
||||
- **Mono / terminal / code / eyebrow labels:** `Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace`.
|
||||
- **Eyebrow labels** (section titles, dialog titles, the rail "PROJECTS" header):
|
||||
mono, **uppercase**, `letter-spacing: .12–.14em`, `--foreground-passive`.
|
||||
- **Scale:** 14px base UI / sidebar (`text-sm`, weight 400) · 12px secondary + labels
|
||||
(`text-xs`) · 13px code/mono/terminal · 11px tiny · 10px micro + badges · 9px sidebar
|
||||
badge label. Buttons are `font-normal` (400), not bold.
|
||||
|
||||
(Geist Sans is removed. JetBrains Mono is no longer used for display headlines.)
|
||||
## Color
|
||||
|
||||
## Status as one system
|
||||
emdash's flat Radix-neutral near-black ramp carries the whole interface; color is rare
|
||||
and meaningful. Values are sRGB approximations of emdash's `color(display-p3 …)` tokens.
|
||||
|
||||
A single semantic spectrum maps the canonical lifecycle to a `{tone, label}`
|
||||
pair and is used **everywhere** — kanban card badge, sidebar dot, session topbar
|
||||
pill. It lives in [`lib/status-spec.ts`](packages/web/src/lib/status-spec.ts)
|
||||
(`getStatusSpec`) and renders through
|
||||
[`StatusBadge`](packages/web/src/components/StatusBadge.tsx).
|
||||
### Dark (primary)
|
||||
|
||||
| Tone | Color | Meaning |
|
||||
|------|-------|---------|
|
||||
| `working` | orange (breathing) | an agent is alive right now |
|
||||
| `input` | amber | needs your input |
|
||||
| `changes` | amber | changes requested |
|
||||
| `fail` | red | CI failed / stuck / crashed / conflicts |
|
||||
| `review` | neutral | in review / waiting on a reviewer |
|
||||
| `ready` / `merged` | green | mergeable / landed |
|
||||
| `neutral` | gray | idle / done / terminated |
|
||||
| Role | Hex |
|
||||
| ------------------------------------ | --------------- |
|
||||
| `--bg` canvas | `#111111` |
|
||||
| `--bg-1` surface | `#191919` |
|
||||
| `--bg-2` raised / hover / active row | `#222222` |
|
||||
| `--bg-3` | `#2a2a2a` |
|
||||
| `--fg` text | `#eeeeee` |
|
||||
| `--fg-muted` | `#b4b4b4` |
|
||||
| `--fg-passive` | `#6e6e6e` |
|
||||
| `--border` hairline | `#3a3a3a` |
|
||||
| `--border-1` | `#484848` |
|
||||
| **`--accent` (blue)** | **`#5b9dff`** |
|
||||
| `--needs-you` / in-progress (amber) | `#ffcc4a` |
|
||||
| `--success` / mergeable (green) | `#6cb16c` |
|
||||
| terminal green | `#7bd88f` |
|
||||
| `--error` (red) | `#d4544f` |
|
||||
| text selection | `#3f8ef7` @ 35% |
|
||||
| terminal bg | `#161616` |
|
||||
|
||||
Tone is refined from the (tested) attention-level bucket so a card's badge never
|
||||
disagrees with the column it sits in.
|
||||
### Light (supported, not primary)
|
||||
|
||||
## Layout patterns
|
||||
| Role | Hex |
|
||||
| ------------------------- | --------------------------------- |
|
||||
| canvas / surface / raised | `#fcfcfc` / `#ffffff` / `#ededee` |
|
||||
| text / muted / passive | `#1a1a1a` / `#666666` / `#9a9a9a` |
|
||||
| border | `#e3e3e5` |
|
||||
| accent (blue) | `#2563eb` |
|
||||
| amber / green / red | `#9a6b00` / `#1a7f37` / `#c0392b` |
|
||||
|
||||
### Fleet board (home) — `kanban.html`
|
||||
- **Lead with the fleet, not the terminal.** Answers "what are all my agents doing?" at a glance.
|
||||
- **Frameless columns:** lifecycle columns **Working → Needs you → In review →
|
||||
Ready to merge** are borderless tinted troughs with a faint *per-column*
|
||||
semantic top-glow. The **card is the only bordered surface** — no box-in-box.
|
||||
- **Compact cards:** status badge + id, task title (2-line clamp), branch, a thin
|
||||
footer. Done/Terminated collapses at the bottom.
|
||||
- The sidebar always shows **all projects'** sessions; the board filters
|
||||
client-side. The SSE refresh interval is **5s** (unchanged — C-14).
|
||||
### Accent rules
|
||||
|
||||
### Session detail — `session.html`
|
||||
- **Framed terminal** as a real surface (header + viewport), flush to sidebar/topbar.
|
||||
It is a **live xterm.js/PTY** — we do *not* style its content; we only set the
|
||||
frame and the xterm.js `theme` object (background `--term`, orange cursor, blue
|
||||
selection, a 16-color ANSI palette tied to the tokens — see `terminal-themes.ts`).
|
||||
Claude Code's own input lives inside the terminal; there is no separate composer.
|
||||
- **Pluggable inspector rail** (a registered-view slot):
|
||||
[`SessionInspector`](packages/web/src/components/SessionInspector.tsx) with views
|
||||
**Summary · Changes · Browser**; adding more (Logs, Cost…) is just another entry.
|
||||
- *Summary* is ordered by supervision value: **Pull request → Review comments →
|
||||
Activity → Overview** (the PR card bundles PR + review comments).
|
||||
- *Review comments* surface a soft-blue **Address** action (`askAgentToFix`) that
|
||||
hands the comment — with its `file:line` — to the agent session to fix.
|
||||
- *Browser* is reserved for a web-preview / Playwright plugin.
|
||||
- **Topbar:** `‹ Kanban` (back) · title + inline branch · **status pill** ·
|
||||
notifications · **Kill** (trash) · **Orchestrator** (blue primary, org-chart icon).
|
||||
- **Blue** = the live edge only: primary buttons, the active/selected session, focus
|
||||
rings. Never decorative.
|
||||
- **Amber** = an agent needs you (blocked / `needs_input` / `review_pending`).
|
||||
- **Green** = `mergeable`/success and terminal/agent CLI text.
|
||||
- **Red** = `ci_failed` / destructive.
|
||||
- These map 1:1 to the daemon's derived statuses.
|
||||
|
||||
## Iconography & motion
|
||||
- **Line icons only** (Lucide-style, ~1.6px stroke, `currentColor`, inline SVG). **No emoji.**
|
||||
- **Motion is minimal & purposeful:** a slow CSS-only "breathe" pulse on the
|
||||
working dot / terminal cursor (`@keyframes breathe`, 2.4s). No animation
|
||||
libraries (C-07). All motion respects `prefers-reduced-motion: reduce`.
|
||||
### Status indicator (no text badges)
|
||||
|
||||
## Web Implementation Rules
|
||||
- **Tokens over raw values.** Use the `--color-*` semantic tokens (or the literal
|
||||
`--bg/--card/--t1…` palette) from `globals.css`. No hardcoded hex/rgba in components.
|
||||
- **No inline `style=`** for theme values (C-02). Tailwind utilities with
|
||||
`var(--token)`, or a named class in `globals.css`.
|
||||
- **No external UI kits** (Radix, shadcn, Headless UI, …) (C-01).
|
||||
- **Tailwind vs CSS classes:** Tailwind for one-off layout/spacing; add a class in
|
||||
`globals.css` when a pattern is theme-sensitive, uses pseudo-elements/gradients,
|
||||
or repeats 3+ times.
|
||||
- **App Router only** (C-06). Component files ≤ 400 lines (C-04). Test files for
|
||||
new/changed components (C-12).
|
||||
- **Dark theme is always preserved** (C-05). Light-mode tokens still exist for the
|
||||
theme toggle but mission control is designed and tuned for dark.
|
||||
Session status is a single ~14px glyph in one fixed slot, never a text pill/badge:
|
||||
|
||||
## Accessibility
|
||||
- **Focus indicators:** `outline: 2px solid var(--color-accent); outline-offset: 2px` on `:focus-visible`. Never `outline: none` without a visible replacement.
|
||||
- **Reduced motion:** `@media (prefers-reduced-motion: reduce)` disables animations/transitions. Non-negotiable.
|
||||
- **Color independence:** never encode meaning with color alone. Status badges always pair a colored dot with a text label.
|
||||
- **Contrast:** body text ≥ 4.5:1; UI/borders/icons ≥ 3:1. The text ramp `--t1…--t3` is for primary→labels on the `--bg`/`--card` surfaces; `--t4` is for faint/disabled only.
|
||||
- **Keyboard nav:** all interactive elements reachable via Tab; Escape closes popovers; logical order.
|
||||
- **ARIA labels** on all icon-only buttons.
|
||||
- **Working / active** → an animated spinner (accent).
|
||||
- **Has an open PR** → a PR icon, tinted by PR state: mergeable/approved green,
|
||||
`ci_failed` red, review/`changes_requested` amber, plain `pr_open` muted.
|
||||
- **Otherwise** → a filled dot: `needs_input` amber (pulsing), idle/done muted gray.
|
||||
|
||||
## Constraints
|
||||
- C-01: No new UI component libraries
|
||||
- C-02: No inline styles in new/modified code
|
||||
- C-04: Component files max 400 lines
|
||||
- C-05: Dark theme preserved
|
||||
- C-06: Next.js App Router only
|
||||
- C-07: No animation libraries (CSS-only motion)
|
||||
- C-12: Test files for new/changed components
|
||||
- C-14: SSE 5s interval unchanged
|
||||
Precedence: **working spinner > PR icon > dot**. Implemented as `StatusGlyph` in
|
||||
`components/SideRail.tsx`; used in the orchestrator's Workers list. (Worker rows in the
|
||||
left rail stay name-only — no glyph.)
|
||||
|
||||
## Spacing
|
||||
|
||||
- **Base unit:** 4px (Tailwind scale: 1=4, 1.5=6, 2=8, 3=12, 4=16, 5=20, 6=24).
|
||||
- **Density:** compact / desktop-tight.
|
||||
- **Control + row height:** `h-8` = 32px default; `h-7` = 28px small; `h-6` = 24px xs.
|
||||
- Inputs `px-2.5 py-1`; buttons `px-2.5`, gap 1–1.5.
|
||||
|
||||
## Layout
|
||||
|
||||
- **Approach:** fixed three-pane app shell, opens into the workbench (no marketing/dashboard home).
|
||||
- **Panes:** `[ rail 240px ] [ center 1fr ] [ side rail 316px ]`.
|
||||
- **Rail (240px), top → bottom:**
|
||||
1. **Orchestrator anchor** — pinned, single, visually distinct (blue 2px left bar,
|
||||
`--bg-2` fill, hub/`waypoints` icon, name "Orchestrator", a `5 agents · 2 need you`
|
||||
mono summary). This is ReverbCode's one addition over emdash. Default landing view.
|
||||
2. `PROJECTS` eyebrow label + a `+`.
|
||||
3. Project rows (folder icon + name) with nested **worker rows beneath**. Each project
|
||||
row has a hover-revealed **`+`** that opens the New-worker modal pre-scoped to that
|
||||
project (distinct from the `PROJECTS` header `+`, which registers a repo).
|
||||
4. **Footer:** `Search ⌘K`, `Settings ⌘,`. (No Library.)
|
||||
5. **Account** row pinned at the very bottom.
|
||||
- **Worker rows are name-only.** Just the session name, truncated. Status, branch, diff,
|
||||
and PR live in the panes and topbar, never in the row. Selection = `--bg-2` fill + a
|
||||
2px blue left bar. (emdash itself shows a faint trailing timestamp; we omit it by choice.)
|
||||
- **Center = the conversation.** Orchestrator → its coordination terminal (delegate here;
|
||||
composer reads "tell the orchestrator what to build"). Worker → the agent CLI terminal
|
||||
(tabbed per agent, e.g. `claude-code (1)`), with a composer (model selector, worktree
|
||||
path, `Accept edits`). The terminal **is** the conversation; no separate chat surface.
|
||||
- **Side rail (316px):** orchestrator → a quiet **Workers** list (name + project + derived
|
||||
status). Worker → the **Git review rail**: `Changed N` → All files / Discard all / Stage
|
||||
all → file rows (`+adds −dels`, stage toggle) → `Commit message` + `Description` →
|
||||
**Commit & Push** (primary blue) → branch + `Create PR`.
|
||||
- **Border radius:** `sm` 4px (scrollbar) · `md` 6px (buttons, inputs, toggles) ·
|
||||
`lg` 8px (rows, cards, panels) · `xl` 12px (modals) · `full` (badges/pills/dots).
|
||||
- **Icons:** **lucide** only. No emoji.
|
||||
|
||||
### Topbar
|
||||
|
||||
- **Left (both):** `project / session` breadcrumb + pin; for the orchestrator, a hub icon
|
||||
- `Orchestrator`.
|
||||
- **Right — worker session:** a **PR/CI status pill** that is the action
|
||||
(`PR #156 · mergeable` green / `CI failed` red / `review requested` amber /
|
||||
`Open PR` when none) → **Changes / Files / Terminal** view toggles → **⋯ session menu**
|
||||
(rename, restart, kill, claim PR — the `ao session …` commands).
|
||||
- **Right — orchestrator:** **+ New worker** → Terminal toggle → **⋯ menu**. No diff toggles.
|
||||
|
||||
### Spawn-worker modal (mirrors emdash's Create Task)
|
||||
|
||||
You mostly let the orchestrator spawn workers from its conversation; the manual paths
|
||||
(the topbar `+ New worker`, a project row's hover `+`, or `ao spawn`) open a modal that
|
||||
mirrors emdash exactly. Launching from a project row pre-fills the Project field:
|
||||
|
||||
- Centered dialog, **12px radius**, `max-w` ~512px, `bg` canvas, `ring-1` at 10% fg,
|
||||
fade + zoom-95 enter.
|
||||
- **Header:** eyebrow mono-uppercase title `New worker` + `×` close.
|
||||
- **Body** (`gap` 15–16px): a **borderless large name field** (18px, auto-focus, slug
|
||||
rule "letters, numbers, hyphens") → **Project** selector → **Agent** selector
|
||||
(claude-code / codex / opencode / …) → a **"Based on"** bordered card with a segmented
|
||||
control `Branch · Issue · Pull Request` revealing a combobox → a **Prompt / Workspace**
|
||||
tab where Prompt is the worker's initial task (textarea).
|
||||
- **Footer:** right-aligned single primary **`Spawn worker`** (blue) with a `⌘↵` keycap,
|
||||
disabled until valid.
|
||||
|
||||
## Motion
|
||||
|
||||
- **Approach:** minimal-functional. The one expressive exception: a status dot/spinner
|
||||
pulse on active/working sessions (opacity breathe) so "alive" is glanceable. Never
|
||||
animate text or layout.
|
||||
- **Easing:** enter `ease-out`, exit `ease-in`, move `ease-in-out`.
|
||||
- **Duration:** micro 80ms · short 160ms · medium 240ms · status pulse 1.8s loop ·
|
||||
modal enter ~150ms fade+zoom-95.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The renderer (`frontend/src/renderer/styles.css`) currently uses **Inter** and a
|
||||
grayscale-blue theme. Migrate to this system: drop the Inter `font-family`, adopt the
|
||||
system stack, and replace the token values with the emdash neutral ramp + blue accent above.
|
||||
- Keep tokens as CSS custom properties under `:root` (dark) and `:root[data-theme="light"]`.
|
||||
- A faithful HTML reference of all of the above (both views + topbar + spawn modal,
|
||||
light/dark) is saved under
|
||||
`~/.gstack/projects/aoagents-agent-orchestrator/designs/design-system-20260609/`.
|
||||
|
||||
## Decisions Log
|
||||
| Date | Decision | Rationale |
|
||||
|------|----------|-----------|
|
||||
| 2026-05-27 | **Mission Control supersedes Warm Terminal** | A single source of truth. The product is a calm control room for a fleet of agents; cool restraint + rationed color reads better at 20+ agents than warm decoration. |
|
||||
| 2026-05-27 | Blue = orchestrator/you, orange = working agent | The mascot is the blue conductor; orange is the Claude Code lineage. Two colors, two meanings — the product metaphor, visualized. |
|
||||
| 2026-05-27 | Schibsted Grotesk (UI) + JetBrains Mono (machine), self-hosted | A distinctive grotesk for the product voice; mono reserved for machine data. Self-hosted via `next/font/local` — no external font CDN. |
|
||||
| 2026-05-27 | One status system (`getStatusSpec` + `StatusBadge`) | Kanban badge, sidebar dot, and topbar pill all render from one spectrum so status never disagrees with itself. |
|
||||
| 2026-05-27 | The card is the only bordered surface | Frameless tinted columns with per-column glow; cards are flat `--card` with a hairline ring. No box-in-box nesting. |
|
||||
|
||||
| Date | Decision | Rationale |
|
||||
| ---------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 2026-06-09 | Match emdash's visual language exactly | User direction; emdash is the demonstrated reference for this app's UI. |
|
||||
| 2026-06-09 | System font, not a custom typeface (e.g. Geist) | emdash uses the system stack; fidelity + native feel + zero font payload chosen over brand type. |
|
||||
| 2026-06-09 | Refined **blue** accent, not emdash's jade green | User's explicit pick; blue for primary/active/focus, terminal stays green. |
|
||||
| 2026-06-09 | Single global **Orchestrator** anchor, orchestrator-first default view | The one real difference from emdash; orchestrator is the human-facing coordinator you delegate to. |
|
||||
| 2026-06-09 | **Name-only** worker rows | User direction; status/branch/diff live in panes + topbar, not the row. |
|
||||
| 2026-06-09 | Removed **Library** from the rail footer | User direction; footer is Search + Settings only. |
|
||||
| 2026-06-09 | Topbar right = PR/CI pill + view toggles + ⋯ menu (worker) | Surfaces the actionable PR/CI state from the daemon; emdash/PostHog Code precedent. |
|
||||
| 2026-06-09 | Spawn modal mirrors emdash's Create Task | Consistency with the reference; mapped to `ao spawn` params. |
|
||||
|
|
|
|||
21
LICENSE
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Composio, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
387
README.md
387
README.md
|
|
@ -1,244 +1,221 @@
|
|||
<h1 align="center">Agent Orchestrator — The Orchestration Layer for Parallel AI Agents</h1>
|
||||
# ReverbCode
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ComposioHQ/agent-orchestrator">
|
||||
<img width="800" alt="Agent Orchestrator banner" src="docs/assets/agent_orchestrator_banner.png">
|
||||
</a>
|
||||
</p>
|
||||
The orchestration layer for parallel AI coding agents. ReverbCode is a
|
||||
Go-backed daemon that supervises many coding-agent sessions at once, each in
|
||||
its own `git worktree`, and routes the feedback they need (CI failures, review
|
||||
comments, merge conflicts) back to the right agent automatically. It ships with
|
||||
an `ao` CLI and an Electron supervisor that both drive the same daemon over
|
||||
loopback.
|
||||
|
||||
<div align="center">
|
||||
The Go module and packages remain `agent-orchestrator`; "ReverbCode" is the
|
||||
public name.
|
||||
|
||||
Spawn parallel AI coding agents, each in its own git worktree. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise from one dashboard.
|
||||
See [`docs/architecture.md`](docs/architecture.md) for the backend mental model
|
||||
and [`AGENTS.md`](AGENTS.md) for the contributor / worker contract. For current
|
||||
progress (what's shipped vs. in flight) see [`docs/STATUS.md`](docs/STATUS.md).
|
||||
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/stargazers)
|
||||
[](https://www.npmjs.com/package/@aoagents/ao)
|
||||
[](LICENSE)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/pulls?q=is%3Amerged)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/releases/tag/metrics-v1)
|
||||
[](https://discord.gg/UZv7JjxbwG)
|
||||
## What it does
|
||||
|
||||
</div>
|
||||
- **Agent-agnostic.** A 23-adapter platform under
|
||||
`backend/internal/adapters/agent/` (`claude-code`, `codex`, `cursor`,
|
||||
`opencode`, `aider`, `amp`, `goose`, `copilot`, `grok`, `qwen`, `kimi`,
|
||||
`crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`,
|
||||
and more), registered through a shared registry with common
|
||||
activity-dispatch / hook utilities. Worker and orchestrator defaults are set
|
||||
per project.
|
||||
- **Isolated workspaces.** Worker and orchestrator sessions spawn into their own
|
||||
`git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched
|
||||
inside a `zellij` runtime adapter (`backend/internal/adapters/runtime/`) so
|
||||
every session has its own attachable terminal.
|
||||
- **Live PR observation.** The provider-neutral SCM observer
|
||||
(`backend/internal/observe/scm/`) polls each session's PR with ETag guards and
|
||||
semantic diffing, tracking CI/check runs and review threads, and feeds those
|
||||
facts into the lifecycle manager, which sends the owning agent nudges for CI
|
||||
failures, review feedback, and merge conflicts. GitHub is the implemented
|
||||
provider today.
|
||||
- **Durable facts, derived status.** The SQLite store
|
||||
(`backend/internal/storage/sqlite/`) persists a small set of session facts
|
||||
plus PR/check/comment rows; display status is computed at read time, never
|
||||
stored. DB triggers append every user-visible change to `change_log`, and a
|
||||
CDC poller/broadcaster (`backend/internal/cdc/`) feeds in-process subscribers
|
||||
and an SSE replay endpoint.
|
||||
- **Loopback-only daemon.** The HTTP daemon (`backend/internal/httpd`) controls
|
||||
projects, sessions, orchestrators, and hook callbacks over `127.0.0.1` with no
|
||||
auth, CORS, or TLS by design.
|
||||
- **Lifecycle manager + reaper** (`backend/internal/lifecycle/`,
|
||||
`backend/internal/observe/reaper/`) reduce runtime/activity/PR observations
|
||||
into the durable session state and reclaim dead sessions.
|
||||
|
||||
---
|
||||
## How it works
|
||||
|
||||
Agent Orchestrator manages fleets of AI coding agents working in parallel on your codebase. Each agent gets its own git worktree, its own branch, and its own PR. When CI fails, the agent fixes it. When reviewers leave comments, the agent addresses them. You only get pulled in when human judgment is needed.
|
||||
1. Register a local git repo as a project (`ao project add`).
|
||||
2. Spawn a worker session (`ao spawn`), or an orchestrator that fans work out
|
||||
across sessions. Each session gets its own `git worktree` and a `zellij`
|
||||
pane.
|
||||
3. The agent develops, tests, and opens a PR from inside its worktree.
|
||||
4. The SCM observer watches that PR and routes feedback back to the agent: a CI
|
||||
failure, a requested change, or a merge conflict becomes a nudge to the agent
|
||||
that owns the PR.
|
||||
5. You inspect, attach a terminal, and merge from the CLI or the Electron app;
|
||||
human attention is needed only where the loop can't resolve on its own.
|
||||
|
||||
**Agent-agnostic** (Claude Code, Codex, Aider) · **Runtime-agnostic** (tmux, ConPTY/process, Docker) · **Tracker-agnostic** (GitHub, Linear)
|
||||
## Extensibility
|
||||
|
||||
<div align="center">
|
||||
The backend is organized around inbound/outbound port contracts
|
||||
(`backend/internal/ports/`) with swappable adapters under
|
||||
`backend/internal/adapters/`:
|
||||
|
||||
## See it in action
|
||||
| Port | Implemented adapters |
|
||||
| --------- | --------------------------------------------- |
|
||||
| Agent | 23 harnesses (see above) |
|
||||
| Runtime | `zellij` |
|
||||
| Workspace | `git worktree` |
|
||||
| SCM | GitHub |
|
||||
| Tracker | GitHub (adapter present; no runtime loop yet) |
|
||||
| Reviewer | `claude-code` |
|
||||
| Notifier | port defined; no shipped adapter yet |
|
||||
|
||||
<a href="https://x.com/agent_wrapper/status/2026329204405723180">
|
||||
<img src="docs/assets/demo-video-tweet.png" alt="Agent Orchestrator demo — AI agents building their own orchestrator" width="560">
|
||||
</a>
|
||||
<br><br>
|
||||
<a href="https://x.com/agent_wrapper/status/2026329204405723180"><img src="docs/assets/btn-watch-demo.png" alt="Watch the Demo on X" height="48"></a>
|
||||
<br><br><br>
|
||||
<a href="https://x.com/agent_wrapper/status/2025986105485733945">
|
||||
<img src="docs/assets/article-tweet.png" alt="The Self-Improving AI System That Built Itself" width="560">
|
||||
</a>
|
||||
<br><br>
|
||||
<a href="https://x.com/agent_wrapper/status/2025986105485733945"><img src="docs/assets/btn-read-article.png" alt="Read the Full Article on X" height="48"></a>
|
||||
See [`docs/STATUS.md`](docs/STATUS.md) for which lanes are live at runtime.
|
||||
|
||||
</div>
|
||||
## Quick start
|
||||
|
||||
## Quick Start
|
||||
|
||||
> **Prerequisites:** [Node.js 20.18.3+](https://nodejs.org), [Git 2.25+](https://git-scm.com), [`gh` CLI](https://cli.github.com), and:
|
||||
> - **macOS / Linux:** [tmux](https://github.com/tmux/tmux/wiki/Installing) — install via `brew install tmux` or `sudo apt install tmux`.
|
||||
> - **Windows:** PowerShell 7+ recommended. tmux is **not** required — AO uses native ConPTY via the `runtime-process` plugin (the default on Windows). Set `AO_SHELL=bash` if you have Git Bash and prefer it.
|
||||
|
||||
### Install
|
||||
Requirements: Go 1.25+, [`zellij`](https://zellij.dev/) on `PATH` for the
|
||||
runtime adapter, and `gh` (or `GITHUB_TOKEN`) if you want the SCM observer to
|
||||
authenticate against GitHub. The SQLite driver is the pure-Go
|
||||
`modernc.org/sqlite` — no system SQLite library is required.
|
||||
|
||||
```bash
|
||||
npm install -g @aoagents/ao
|
||||
cd backend
|
||||
go build -o /tmp/ao ./cmd/ao
|
||||
|
||||
# Start the daemon and wait for /readyz.
|
||||
/tmp/ao start
|
||||
|
||||
# Register a local git repo as a project. The id defaults to the lowercased
|
||||
# base of --path; pass --id explicitly when the directory name doesn't match.
|
||||
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo \
|
||||
--worker-agent codex --orchestrator-agent codex
|
||||
|
||||
# Spawn a worker session running the project's worker agent.
|
||||
/tmp/ao spawn --project your-repo --prompt "Refactor the auth module"
|
||||
|
||||
# Inspect what's running.
|
||||
/tmp/ao status
|
||||
/tmp/ao session ls
|
||||
```
|
||||
|
||||
> **Nightly builds** (latest `main`, daily Fri–Tue): `npm install -g @aoagents/ao@nightly`
|
||||
> Back to stable: `npm install -g @aoagents/ao@latest`
|
||||
### Electron app (dev)
|
||||
|
||||
<details>
|
||||
<summary>Permission denied? Install from source?</summary>
|
||||
|
||||
If `npm install -g` fails with EACCES, prefix with `sudo` or [fix your npm permissions](https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally).
|
||||
|
||||
To install from source (for contributors):
|
||||
The desktop supervisor lives under `frontend/` and is started separately:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ComposioHQ/agent-orchestrator.git
|
||||
cd agent-orchestrator && bash scripts/setup.sh
|
||||
```
|
||||
</details>
|
||||
|
||||
### Zsh Completion
|
||||
|
||||
Generate the completion file from the installed CLI:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.zsh/completions
|
||||
ao completion zsh > ~/.zsh/completions/_ao
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # electron-forge start
|
||||
```
|
||||
|
||||
Then make sure the directory is on your `fpath` before `compinit` runs:
|
||||
Heads-up: `npm run dev` does **not** start the daemon for you. Start it first
|
||||
(`ao start`, see above) — the renderer attaches to the running daemon over
|
||||
loopback (`127.0.0.1:3001` by default, the `AO_PORT` from the table below).
|
||||
Without a daemon the app opens but shows its daemon-not-ready state.
|
||||
|
||||
```zsh
|
||||
fpath=(~/.zsh/completions $fpath)
|
||||
autoload -Uz compinit
|
||||
compinit
|
||||
```
|
||||
For renderer-only UI work without the Electron shell, use
|
||||
`npm run dev:web` (Vite in a regular browser).
|
||||
|
||||
For Oh My Zsh, install the same generated file into a custom plugin directory and add `ao` to your plugin list:
|
||||
## CLI surface
|
||||
|
||||
```bash
|
||||
mkdir -p "${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/ao"
|
||||
ao completion zsh > "${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/ao/_ao"
|
||||
```
|
||||
The CLI is intentionally thin: every product command resolves to a daemon HTTP
|
||||
route. Run `ao <command> --help` for the authoritative flag shape; the table
|
||||
below groups what's on `main` today.
|
||||
|
||||
If you are contributing from a source checkout, you can also symlink the repo copy at [`completions/_ao`](completions/_ao).
|
||||
| Lane | Command | Purpose |
|
||||
| ------------ | ------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| Daemon | `ao start` | Start the daemon in the background and wait for `/readyz`. |
|
||||
| Daemon | `ao stop` | Graceful shutdown via loopback `POST /shutdown`. |
|
||||
| Daemon | `ao status` | Report PID/port/health/readiness from `running.json`. |
|
||||
| Daemon | `ao daemon` | Hidden internal entrypoint used by `ao start`. |
|
||||
| Project | `ao project add` | Register a local git repo as a project. |
|
||||
| Project | `ao project ls` | List registered projects. |
|
||||
| Project | `ao project get <id>` | Fetch one project. |
|
||||
| Project | `ao project set-config <id>` | Update per-project config. |
|
||||
| Project | `ao project rm <id>` | Remove a project. |
|
||||
| Session | `ao spawn` | Spawn a worker session in a registered project. |
|
||||
| Session | `ao session ls` | List sessions (filter by project, include terminated). |
|
||||
| Session | `ao session get <id>` | Fetch one session. |
|
||||
| Session | `ao session kill <id>` | Terminate a session. |
|
||||
| Session | `ao session rename <id> <name>` | Rename a session. |
|
||||
| Session | `ao session restore <id>` | Relaunch a terminated session. |
|
||||
| Session | `ao session cleanup` | Reclaim eligible workspaces for terminated sessions. |
|
||||
| Session | `ao session claim-pr <session> <pr>` | Attach an existing PR to a session. |
|
||||
| Orchestrator | `ao orchestrator ls` | List orchestrator sessions. |
|
||||
| Messaging | `ao send` | Send a message to a running agent session. |
|
||||
| Preview | `ao preview [url]` | Open a URL (or the workspace `index.html`) in the session's desktop browser panel. |
|
||||
| Utility | `ao doctor` | Local health checks (config, data dir, DB, `git`, `zellij`). |
|
||||
| Utility | `ao completion <shell>` | Generate bash/zsh/fish/powershell completions. |
|
||||
| Utility | `ao version` | Print build metadata. |
|
||||
| Internal | `ao hooks <agent> <event>` | Hidden adapter hook callback. |
|
||||
|
||||
### Start
|
||||
|
||||
Point it at any repo — it clones, configures, and launches the dashboard in one command:
|
||||
|
||||
```bash
|
||||
ao start https://github.com/your-org/your-repo
|
||||
```
|
||||
|
||||
Or from inside an existing local repo:
|
||||
|
||||
```bash
|
||||
cd ~/your-project && ao start
|
||||
```
|
||||
|
||||
That's it. The dashboard opens at `http://localhost:3000` and the orchestrator agent starts managing your project.
|
||||
|
||||
### Add more projects
|
||||
|
||||
```bash
|
||||
ao start ~/path/to/another-repo
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **You start** — `ao start` launches the dashboard and an orchestrator agent
|
||||
2. **Orchestrator spawns workers** — each issue gets its own agent in an isolated git worktree
|
||||
3. **Agents work autonomously** — they read code, write tests, create PRs
|
||||
4. **Reactions handle feedback** — CI failures and review comments are automatically routed back to the agent
|
||||
5. **You review and merge** — you only get pulled in when human judgment is needed
|
||||
|
||||
The orchestrator agent uses the [AO CLI](docs/CLI.md) internally to manage sessions. You don't need to learn or use the CLI — the dashboard and orchestrator handle everything.
|
||||
See [`docs/cli/`](docs/cli/) for the daemon-control intent and command shape.
|
||||
|
||||
## Configuration
|
||||
|
||||
`ao start` auto-generates `agent-orchestrator.yaml` with sensible defaults. You can edit it afterwards to customize behavior:
|
||||
All configuration is env-driven; the daemon takes no config file. The bind
|
||||
host is hard-coded to `127.0.0.1` — the daemon has no auth, CORS, or TLS, and
|
||||
exposing it beyond loopback would be a security regression.
|
||||
|
||||
```yaml
|
||||
# agent-orchestrator.yaml
|
||||
$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json
|
||||
# Runtime data is auto-derived under ~/.agent-orchestrator/{hash}-{projectId}/
|
||||
port: 3000
|
||||
| Var | Default | Purpose |
|
||||
| --------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `AO_PORT` | `3001` | Bind port; daemon fails fast if taken. |
|
||||
| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout (Go duration). |
|
||||
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful-shutdown hard cap. |
|
||||
| `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path. |
|
||||
| `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, managed state. |
|
||||
| `AO_AGENT` | `claude-code` | Compatibility agent adapter id validated at daemon startup. |
|
||||
| `AO_SESSION_ID` | _(unset)_ | Set inside spawned sessions; read by `ao send` and `ao hooks`. |
|
||||
| `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. |
|
||||
|
||||
defaults:
|
||||
runtime: tmux # default on macOS / Linux; on Windows the default is `process` (ConPTY)
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
notifiers: [desktop]
|
||||
|
||||
projects:
|
||||
my-app:
|
||||
repo: owner/my-app
|
||||
path: ~/my-app
|
||||
defaultBranch: main
|
||||
sessionPrefix: app
|
||||
|
||||
reactions:
|
||||
ci-failed:
|
||||
auto: true
|
||||
action: send-to-agent
|
||||
retries: 2
|
||||
changes-requested:
|
||||
auto: true
|
||||
action: send-to-agent
|
||||
escalateAfter: 30m
|
||||
approved-and-green:
|
||||
auto: false # flip to true for auto-merge
|
||||
action: notify
|
||||
```
|
||||
|
||||
CI fails → agent gets the logs and fixes it. Reviewer requests changes → agent addresses them. PR approved with green CI → you get a notification to merge.
|
||||
|
||||
Keep the `$schema` line so editors can autocomplete and validate against [`schema/config.schema.json`](schema/config.schema.json).
|
||||
|
||||
See [`agent-orchestrator.yaml.example`](agent-orchestrator.yaml.example) for the full reference, or run `ao config-help` for the complete schema.
|
||||
|
||||
## Remote Access
|
||||
|
||||
AO keeps your Mac awake while running, so you can access the dashboard remotely (e.g., via Tailscale from your phone) without the machine going to sleep.
|
||||
|
||||
**How it works:** On macOS, AO automatically holds an idle-sleep prevention assertion using `caffeinate`. When AO exits, the assertion is released.
|
||||
|
||||
```yaml
|
||||
# agent-orchestrator.yaml
|
||||
$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json
|
||||
power:
|
||||
preventIdleSleep: true # Default on macOS; no-op on Linux and Windows
|
||||
```
|
||||
|
||||
Set to `false` if you want to allow idle sleep while AO runs.
|
||||
|
||||
**Lid-close limitation:** macOS enforces lid-close sleep at the hardware level — no userspace assertion can override it. If you need remote access while traveling with the lid closed, use [clamshell mode](https://support.apple.com/en-us/102505) (external power + display + input device).
|
||||
|
||||
**Linux / Windows:** AO does not currently hold a wake assertion on these platforms. On Linux, idle-sleep behaviour is governed by your desktop environment / `systemd-logind`; configure that directly. On Windows, set the OS power plan if remote access matters while idle.
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
Seven plugin slots. Lifecycle stays in core.
|
||||
|
||||
| Slot | Default | Alternatives |
|
||||
| --------- | ----------- | ------------------------ |
|
||||
| Runtime | tmux (macOS/Linux) / process (Windows) | process, docker |
|
||||
| Agent | claude-code | codex, aider, cursor, opencode, kimicode |
|
||||
| Workspace | worktree | clone |
|
||||
| Tracker | github | linear, gitlab |
|
||||
| SCM | github | gitlab |
|
||||
| Notifier | desktop | slack, discord, composio, webhook, openclaw |
|
||||
| Terminal | iterm2 | web |
|
||||
|
||||
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.
|
||||
|
||||
## Why Agent Orchestrator?
|
||||
|
||||
Running one AI agent in a terminal is easy. Running 30 across different issues, branches, and PRs is a coordination problem.
|
||||
|
||||
**Without orchestration**, you manually: create branches, start agents, check if they're stuck, read CI failures, forward review comments, track which PRs are ready to merge, clean up when done.
|
||||
|
||||
**With Agent Orchestrator**, you: `ao start` and walk away. The system handles isolation, feedback routing, and status tracking. You review PRs and make decisions — the rest is automated.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | What it covers |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ |
|
||||
| [Setup Guide](SETUP.md) | Detailed installation, configuration, and troubleshooting |
|
||||
| [CLI Reference](docs/CLI.md) | All `ao` commands (mostly used by the orchestrator agent) |
|
||||
| [Examples](examples/) | Config templates (GitHub, Linear, multi-project, auto-merge) |
|
||||
| [Development Guide](docs/DEVELOPMENT.md) | Architecture, conventions, plugin pattern |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute, build plugins, PR process |
|
||||
|
||||
## Development
|
||||
Health check:
|
||||
|
||||
```bash
|
||||
pnpm install && pnpm build # Install and build all packages
|
||||
pnpm test # Run tests (3,288 test cases)
|
||||
pnpm dev # Start web dashboard dev server
|
||||
curl localhost:3001/healthz
|
||||
curl localhost:3001/readyz
|
||||
```
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for code conventions and architecture details.
|
||||
## Architecture
|
||||
|
||||
The daemon is a long-running supervisor. Adapters observe external facts (PR
|
||||
state, agent activity, runtime liveness); the lifecycle manager reduces those
|
||||
into a small set of durable session facts (`activity_state`, `is_terminated`,
|
||||
PR rows). Display status is _derived_ from those facts at read time — it is
|
||||
never stored. SQLite triggers append every user-visible change to `change_log`,
|
||||
and the CDC poller broadcasts those events to in-process subscribers and an
|
||||
SSE stream.
|
||||
|
||||
Full mental model and load-bearing rules: [`docs/architecture.md`](docs/architecture.md).
|
||||
Package-by-package ownership: [`docs/backend-code-structure.md`](docs/backend-code-structure.md).
|
||||
|
||||
## Testing
|
||||
|
||||
The local gate is the backend Go build and race-enabled test suite:
|
||||
|
||||
```bash
|
||||
cd backend && go build ./... && go test -race ./...
|
||||
```
|
||||
|
||||
GitHub Actions is the authoritative pre-merge gate; mirror its commands here
|
||||
when in doubt. See [`AGENTS.md`](AGENTS.md) for the regen workflow when
|
||||
touching the daemon API surface (`npm run sqlc`, `npm run api`).
|
||||
|
||||
## Status and roadmap
|
||||
|
||||
Progress tracking lives in [`docs/STATUS.md`](docs/STATUS.md): what is shipped
|
||||
on `main` today, what is still in flight, and the linked
|
||||
[`rewrite`](https://github.com/aoagents/agent-orchestrator/milestone/1)
|
||||
milestone on GitHub.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome. The plugin system makes it straightforward to add support for new agents, runtimes, trackers, and notification channels. Every plugin is an implementation of a TypeScript interface — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [Development Guide](docs/DEVELOPMENT.md) for the pattern.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Repo layout and the worker contract live in [`AGENTS.md`](AGENTS.md). Keep
|
||||
changes surgical, follow the package boundaries documented in
|
||||
[`docs/backend-code-structure.md`](docs/backend-code-structure.md), and prefer
|
||||
adding daemon HTTP routes over leaking storage / runtime into the CLI.
|
||||
|
|
|
|||
228
SECURITY.md
228
SECURITY.md
|
|
@ -1,228 +0,0 @@
|
|||
# Security Policy
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via email to security@composio.dev.
|
||||
|
||||
You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message.
|
||||
|
||||
Please include the following information:
|
||||
|
||||
- Type of issue (e.g., secret leak, code injection, authentication bypass)
|
||||
- Full paths of source file(s) related to the issue
|
||||
- Location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue
|
||||
|
||||
## Security Audit History
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### OpenClaw Notifier Token (Resolved)
|
||||
|
||||
**Status**: Removed from codebase
|
||||
**Severity**: Medium
|
||||
**Date**: 2026-02-15
|
||||
**Commit**: 0393ab70a83e090883895d2168aa39a76f997ec8
|
||||
|
||||
An OpenClaw notifier token (`1af5c4f...872` - redacted) was accidentally committed in `agent-orchestrator.yaml` and later removed. This token was:
|
||||
|
||||
- Used for local development/testing only
|
||||
- Never used in production
|
||||
- Removed in subsequent commits
|
||||
- Still present in git history
|
||||
|
||||
**Action Required**: If this token is still in use, it should be rotated immediately.
|
||||
|
||||
**Lesson**: All tokens and API keys must use environment variables. The `agent-orchestrator.yaml` file is now in `.gitignore` to prevent future accidental commits.
|
||||
|
||||
## Security Measures
|
||||
|
||||
### Automated Secret Scanning
|
||||
|
||||
This repository uses [Gitleaks](https://github.com/gitleaks/gitleaks) to prevent accidental commits of secrets:
|
||||
|
||||
1. **Pre-commit Hook** — Scans staged files before every commit
|
||||
2. **CI Pipeline** — Scans full git history on every push/PR
|
||||
3. **Scheduled Scans** — Weekly scans to catch new vulnerability patterns
|
||||
|
||||
### Dependency Security
|
||||
|
||||
- **Dependency Review** — GitHub Action scans PRs for vulnerable dependencies
|
||||
- **npm audit** — Runs in CI to detect known vulnerabilities in dependencies
|
||||
- **Automated Updates** — Dependabot (or similar) for security patches
|
||||
|
||||
## Best Practices for Developers
|
||||
|
||||
### Never Commit Secrets
|
||||
|
||||
❌ **Bad** — Hardcoded secret:
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
slack:
|
||||
webhook: https://hooks.slack.com/services/T123/B456/abc123
|
||||
```
|
||||
|
||||
✅ **Good** — Environment variable:
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
slack:
|
||||
webhook: ${SLACK_WEBHOOK_URL}
|
||||
```
|
||||
|
||||
### Use Environment Variables
|
||||
|
||||
Store all secrets in environment variables:
|
||||
|
||||
```bash
|
||||
# .env.local (ignored by git)
|
||||
LINEAR_API_KEY=lin_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
|
||||
```
|
||||
|
||||
Then reference in config:
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
slack:
|
||||
webhook: ${SLACK_WEBHOOK_URL}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
Use consistent environment variable names:
|
||||
|
||||
- `*_API_KEY` — API keys (e.g., `LINEAR_API_KEY`)
|
||||
- `*_TOKEN` — Authentication tokens (e.g., `GITHUB_TOKEN`)
|
||||
- `*_SECRET` — Secret keys (e.g., `JWT_SECRET`)
|
||||
- `*_URL` — URLs that may contain credentials (e.g., `DATABASE_URL`)
|
||||
|
||||
### Example Config Files
|
||||
|
||||
When creating example config files:
|
||||
|
||||
1. Use placeholder values: `your-api-key-here`, `your-token-here`
|
||||
2. Use environment variable references: `${ENV_VAR}`
|
||||
3. Never copy real credentials, even "temporarily"
|
||||
4. Document which environment variables are required
|
||||
|
||||
### Files to Never Commit
|
||||
|
||||
The `.gitignore` excludes these patterns:
|
||||
|
||||
- `.env`, `.env.local`, `.env.*.local`
|
||||
- `*.key`, `*.pem`, `*.p12`, `*.pfx`
|
||||
- `secrets.yaml`, `credentials.json`
|
||||
- `agent-orchestrator.yaml` (local config)
|
||||
|
||||
### Checking for Secrets Locally
|
||||
|
||||
Before committing:
|
||||
|
||||
```bash
|
||||
# Scan current files
|
||||
gitleaks detect --no-git
|
||||
|
||||
# Scan staged files (automatic in pre-commit hook)
|
||||
gitleaks protect --staged
|
||||
|
||||
# Scan full git history
|
||||
gitleaks detect
|
||||
```
|
||||
|
||||
### What to Do If You Commit a Secret
|
||||
|
||||
If you accidentally commit a secret:
|
||||
|
||||
1. **Rotate the secret immediately** — Assume it's compromised
|
||||
2. **Remove from git history** — Use `git filter-repo` or similar (dangerous!)
|
||||
3. **Update `.gitleaks.toml`** — Add pattern to prevent similar leaks
|
||||
4. **Report internally** — Document in SECURITY.md
|
||||
|
||||
**Never** just delete the file and commit — the secret remains in git history!
|
||||
|
||||
### Code Review
|
||||
|
||||
When reviewing PRs:
|
||||
|
||||
- ✅ Check for hardcoded tokens, passwords, API keys
|
||||
- ✅ Verify environment variables are documented but not hardcoded
|
||||
- ✅ Ensure example configs use placeholders
|
||||
- ✅ Confirm CI security check passed
|
||||
|
||||
## Best Practices for Users
|
||||
|
||||
### Secure Configuration
|
||||
|
||||
When setting up Agent Orchestrator:
|
||||
|
||||
1. **Copy example config**: `cp agent-orchestrator.yaml.example agent-orchestrator.yaml`
|
||||
2. **Add real secrets**: Edit `agent-orchestrator.yaml` with your actual tokens
|
||||
3. **Never commit local config**: It's in `.gitignore` — keep it there!
|
||||
4. **Use secret management**: Consider 1Password, AWS Secrets Manager, etc.
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Agent Orchestrator may require these secrets:
|
||||
|
||||
| Service | Environment Variable | Where to Get |
|
||||
| --------- | -------------------- | ---------------------------------------- |
|
||||
| GitHub | `GITHUB_TOKEN` | https://github.com/settings/tokens |
|
||||
| Linear | `LINEAR_API_KEY` | https://linear.app/settings/api |
|
||||
| Slack | `SLACK_WEBHOOK_URL` | https://api.slack.com/messaging/webhooks |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` | https://console.anthropic.com/ |
|
||||
|
||||
### Setting Environment Variables
|
||||
|
||||
**macOS/Linux**:
|
||||
|
||||
```bash
|
||||
# In ~/.zshrc or ~/.bashrc
|
||||
export GITHUB_TOKEN="ghp_xxxxx"
|
||||
export LINEAR_API_KEY="lin_api_xxxxx"
|
||||
```
|
||||
|
||||
**Or use `.env.local`**:
|
||||
|
||||
```bash
|
||||
# In your project directory
|
||||
echo 'GITHUB_TOKEN=ghp_xxxxx' >> .env.local
|
||||
echo 'LINEAR_API_KEY=lin_api_xxxxx' >> .env.local
|
||||
```
|
||||
|
||||
### Protecting Your Secrets
|
||||
|
||||
- ✅ Use strong, unique tokens for each service
|
||||
- ✅ Rotate tokens regularly (every 90 days)
|
||||
- ✅ Use minimal permissions (read-only when possible)
|
||||
- ✅ Store in a password manager
|
||||
- ❌ Never share tokens in chat, email, or screenshots
|
||||
- ❌ Never commit to git (public or private repos)
|
||||
- ❌ Never hardcode in shell scripts
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.1.x | :white_check_mark: |
|
||||
|
||||
Security updates are provided for the latest version only.
|
||||
|
||||
## Security Tools
|
||||
|
||||
This project uses:
|
||||
|
||||
- [Gitleaks](https://github.com/gitleaks/gitleaks) — Secret scanning
|
||||
- [GitHub Dependency Review](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review) — Dependency vulnerability scanning
|
||||
- [npm audit](https://docs.npmjs.com/cli/v8/commands/npm-audit) — Dependency vulnerability detection
|
||||
- [Husky](https://typicode.github.io/husky/) — Git hooks for pre-commit validation
|
||||
|
||||
## License
|
||||
|
||||
This security policy is part of the Agent Orchestrator project and is licensed under the MIT License.
|
||||
859
SETUP.md
859
SETUP.md
|
|
@ -1,859 +0,0 @@
|
|||
# Agent Orchestrator Setup Guide
|
||||
|
||||
Comprehensive guide to installing, configuring, and troubleshooting Agent Orchestrator.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required
|
||||
|
||||
- **Node.js 20+** - Runtime for the orchestrator and CLI
|
||||
|
||||
```bash
|
||||
node --version # Should be v20.0.0 or higher
|
||||
```
|
||||
|
||||
- **Git 2.25+** - For repository management and worktrees
|
||||
|
||||
```bash
|
||||
git --version
|
||||
```
|
||||
|
||||
- **Terminal runtime** — varies by OS:
|
||||
|
||||
**On macOS / Linux:** `tmux` is required (it's the default runtime).
|
||||
|
||||
```bash
|
||||
tmux -V
|
||||
|
||||
# Install on macOS
|
||||
brew install tmux
|
||||
|
||||
# Install on Ubuntu/Debian
|
||||
sudo apt install tmux
|
||||
|
||||
# Install on Fedora/RHEL
|
||||
sudo dnf install tmux
|
||||
```
|
||||
|
||||
**On Windows:** tmux is **not** required. AO uses native ConPTY via the `runtime-process` plugin (the default on Windows). PowerShell 7+ is recommended; if you have Git Bash and prefer bash semantics for shell-out commands, set `AO_SHELL=bash` in your environment. WSL is not required.
|
||||
|
||||
- **GitHub CLI** (for GitHub integration) - Required for PR creation, issue management
|
||||
|
||||
```bash
|
||||
gh --version
|
||||
|
||||
# Install on macOS
|
||||
brew install gh
|
||||
|
||||
# Install on Linux
|
||||
# See: https://github.com/cli/cli/blob/trunk/docs/install_linux.md
|
||||
```
|
||||
|
||||
### Optional
|
||||
|
||||
- **Linear API Key** - If using Linear for issue tracking
|
||||
- Get it from: https://linear.app/settings/api
|
||||
- Set environment variable: `export LINEAR_API_KEY="lin_api_..."`
|
||||
|
||||
- **Slack Webhook** - If using Slack notifications
|
||||
- Create incoming webhook: https://api.slack.com/messaging/webhooks
|
||||
- Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."`
|
||||
|
||||
- **Public dashboard URL** - If running AO behind a reverse proxy (e.g. inside a remote dev container, on a VPS fronted by Caddy/nginx/Traefik)
|
||||
- Set `AO_PUBLIC_URL` to the externally-reachable URL of the dashboard
|
||||
- All console output, `ao open` browser launches, and orchestrator-prompt session links use this URL instead of `http://localhost:<port>`
|
||||
- Example: `export AO_PUBLIC_URL="https://ao.example.com"`
|
||||
- When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`.
|
||||
- **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side.
|
||||
|
||||
## Installation
|
||||
|
||||
### Install via npm (recommended)
|
||||
|
||||
```bash
|
||||
npm install -g @aoagents/ao
|
||||
|
||||
# Verify
|
||||
ao --version
|
||||
```
|
||||
|
||||
This installs the `ao` CLI globally along with all default plugins and the web dashboard.
|
||||
|
||||
**Permission denied (EACCES)?** This is common on macOS. Three options:
|
||||
|
||||
```bash
|
||||
# Option 1: Use sudo
|
||||
sudo npm install -g @aoagents/ao
|
||||
|
||||
# Option 2: Use npx (no global install needed)
|
||||
npx @aoagents/ao start
|
||||
|
||||
# Option 3: Fix npm permissions permanently (recommended)
|
||||
mkdir -p ~/.npm-global
|
||||
npm config set prefix '~/.npm-global'
|
||||
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
npm install -g @aoagents/ao
|
||||
```
|
||||
|
||||
### Build from Source (for contributors)
|
||||
|
||||
If you want to develop or contribute to Agent Orchestrator:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/ComposioHQ/agent-orchestrator
|
||||
cd agent-orchestrator
|
||||
|
||||
# Run the setup script (installs deps, builds, links CLI)
|
||||
bash scripts/setup.sh
|
||||
|
||||
# Verify
|
||||
ao --version
|
||||
```
|
||||
|
||||
The setup script handles pnpm installation, dependency resolution, building all packages, and linking the `ao` command globally (with automatic permission handling on macOS).
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
### `ao start` — the only command you need
|
||||
|
||||
`ao start` handles everything: auto-detecting your project, generating config, and launching the dashboard + orchestrator. There are three ways to use it:
|
||||
|
||||
**From a URL (fastest for any repo):**
|
||||
|
||||
```bash
|
||||
ao start https://github.com/your-org/your-repo
|
||||
```
|
||||
|
||||
This clones the repo, auto-detects language/framework/branch, generates `agent-orchestrator.yaml`, and starts everything. Supports GitHub, GitLab, and Bitbucket (HTTPS and SSH):
|
||||
|
||||
```bash
|
||||
ao start https://github.com/owner/repo
|
||||
ao start https://gitlab.com/org/project
|
||||
ao start git@github.com:owner/repo.git
|
||||
```
|
||||
|
||||
**From a local repo (zero prompts):**
|
||||
|
||||
```bash
|
||||
cd ~/your-project
|
||||
ao start
|
||||
```
|
||||
|
||||
Auto-detects git remote, default branch, language, and available agent runtimes. Generates config and starts.
|
||||
|
||||
**Adding more projects:**
|
||||
|
||||
```bash
|
||||
ao start ~/path/to/another-repo
|
||||
```
|
||||
|
||||
If a config already exists, the new project is appended. If not, one is created first.
|
||||
|
||||
### What `ao start` detects automatically
|
||||
|
||||
- **Git remote** — parses `owner/repo` from origin
|
||||
- **Default branch** — checks symbolic-ref, GitHub API, then common names (main/master)
|
||||
- **Project type** — language, framework, test runner, package manager
|
||||
- **Agent runtime** — which AI agents are installed (Claude Code, Codex, Aider, OpenCode)
|
||||
- **Free port** — if configured port is busy, auto-finds the next available
|
||||
- **tmux** — warns if not installed (skipped on Windows; AO uses ConPTY there and tmux is not required)
|
||||
- **GitHub CLI** — checks `gh auth status`
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
If you prefer to write the config by hand:
|
||||
|
||||
```bash
|
||||
cp agent-orchestrator.yaml.example agent-orchestrator.yaml
|
||||
nano agent-orchestrator.yaml
|
||||
```
|
||||
|
||||
Or start from an example:
|
||||
|
||||
```bash
|
||||
cp examples/simple-github.yaml agent-orchestrator.yaml
|
||||
nano agent-orchestrator.yaml
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Minimal Configuration
|
||||
|
||||
The absolute minimum needed (everything else has sensible defaults):
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
my-app:
|
||||
repo: owner/my-app
|
||||
path: ~/my-app
|
||||
defaultBranch: main
|
||||
```
|
||||
|
||||
`ao start` generates this automatically — you only need to write it manually if you want full control.
|
||||
|
||||
### Full Configuration Schema
|
||||
|
||||
See [agent-orchestrator.yaml.example](./agent-orchestrator.yaml.example) for a fully commented example with all options.
|
||||
|
||||
### Plugin Slots
|
||||
|
||||
Agent Orchestrator has 8 plugin slots. All are swappable:
|
||||
|
||||
| Slot | Purpose | Default | Alternatives |
|
||||
| ------------- | -------------------- | ------------- | ----------------------------------------------- |
|
||||
| **Runtime** | How sessions run | `tmux` (macOS/Linux) / `process` (Windows; ConPTY via node-pty) | `process`, `docker`, `kubernetes`, `ssh`, `e2b` |
|
||||
| **Agent** | AI coding assistant | `claude-code` | `codex`, `aider`, `goose`, custom |
|
||||
| **Workspace** | Workspace isolation | `worktree` | `clone`, `copy` |
|
||||
| **Tracker** | Issue tracking | `github` | `linear`, `jira`, custom |
|
||||
| **SCM** | Source control | `github` | GitLab, Bitbucket (future) |
|
||||
| **Notifier** | Notifications | `desktop` | `slack`, `discord`, `webhook`, `email` |
|
||||
| **Terminal** | Terminal integration | `iterm2` | `web`, custom |
|
||||
| **Lifecycle** | Session lifecycle | (core) | Non-pluggable |
|
||||
|
||||
### Reactions
|
||||
|
||||
Reactions are auto-responses to events. Configure how the orchestrator handles common scenarios:
|
||||
|
||||
#### CI Failed
|
||||
|
||||
```yaml
|
||||
reactions:
|
||||
ci-failed:
|
||||
auto: true # Enable auto-handling
|
||||
action: send-to-agent # Send failure logs to agent
|
||||
retries: 2 # Retry up to 2 times
|
||||
escalateAfter: 2 # Notify human after 2 failures
|
||||
```
|
||||
|
||||
#### Changes Requested (Review Comments)
|
||||
|
||||
```yaml
|
||||
reactions:
|
||||
changes-requested:
|
||||
auto: true
|
||||
action: send-to-agent
|
||||
escalateAfter: 30m # Notify human if not resolved in 30 minutes
|
||||
```
|
||||
|
||||
#### Approved and Green (Auto-merge)
|
||||
|
||||
```yaml
|
||||
reactions:
|
||||
approved-and-green:
|
||||
auto: true # Enable auto-merge
|
||||
action: auto-merge # Merge when approved + CI passes
|
||||
priority: action # Notification priority
|
||||
```
|
||||
|
||||
**Warning:** Only enable auto-merge if you trust your CI pipeline and agents!
|
||||
|
||||
#### Agent Stuck
|
||||
|
||||
```yaml
|
||||
reactions:
|
||||
agent-stuck:
|
||||
threshold: 10m # Consider stuck after 10 minutes of inactivity
|
||||
action: notify
|
||||
priority: urgent
|
||||
```
|
||||
|
||||
### Notification Routing
|
||||
|
||||
Route notifications by priority:
|
||||
|
||||
```yaml
|
||||
notificationRouting:
|
||||
urgent: [desktop, slack] # Agent stuck, needs input, errored
|
||||
action: [desktop, slack] # PR ready to merge
|
||||
warning: [slack] # Auto-fix failed
|
||||
info: [slack] # Summary, all done
|
||||
```
|
||||
|
||||
### Agent Rules
|
||||
|
||||
Inline rules included in every agent prompt:
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
my-app:
|
||||
agentRules: |
|
||||
Always run tests before pushing.
|
||||
Use conventional commits (feat:, fix:, chore:).
|
||||
Link issue numbers in commit messages.
|
||||
```
|
||||
|
||||
Or reference an external file:
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
my-app:
|
||||
agentRulesFile: .agent-rules.md
|
||||
```
|
||||
|
||||
### Per-Project Overrides
|
||||
|
||||
Override defaults per project:
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
frontend:
|
||||
runtime: tmux # default on macOS/Linux; on Windows use `process`
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
|
||||
backend:
|
||||
runtime: docker # Use Docker for backend
|
||||
agent: codex # Use Codex instead of Claude
|
||||
```
|
||||
|
||||
## Integration Guides
|
||||
|
||||
### GitHub Issues
|
||||
|
||||
**Authentication:**
|
||||
|
||||
```bash
|
||||
gh auth login
|
||||
```
|
||||
|
||||
**Required scopes:**
|
||||
|
||||
- `repo` - Full repository access
|
||||
- `read:org` - Read organization membership (for team mentions)
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
gh auth status
|
||||
```
|
||||
|
||||
### Linear
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Get your API key: https://linear.app/settings/api
|
||||
2. Add to environment:
|
||||
|
||||
```bash
|
||||
echo 'export LINEAR_API_KEY="lin_api_..."' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
3. Find your team ID:
|
||||
- Go to https://linear.app/settings/api
|
||||
- Click "Create new key" or use existing key
|
||||
- Team ID is visible in your Linear workspace URL or via API
|
||||
|
||||
4. Configure in `agent-orchestrator.yaml`:
|
||||
```yaml
|
||||
projects:
|
||||
my-app:
|
||||
tracker:
|
||||
plugin: linear
|
||||
teamId: "your-team-id"
|
||||
```
|
||||
|
||||
**Branch names:** On `ao spawn <issue>` with the Linear tracker, AO **prefers** Linear’s branch name (same as **Copy git branch name**, API field `branchName`). If that value is missing, it **falls back** to the previous convention: `feat/<ISSUE-ID>` (e.g. `feat/INT-123`). To change how Linear generates `branchName`, use **Linear → Settings → Integrations → GitHub → Branch format**.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
echo $LINEAR_API_KEY # Should print your key
|
||||
```
|
||||
|
||||
### Slack
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Create incoming webhook: https://api.slack.com/messaging/webhooks
|
||||
2. Add to environment:
|
||||
|
||||
```bash
|
||||
echo 'export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
3. Configure in `agent-orchestrator.yaml`:
|
||||
```yaml
|
||||
notifiers:
|
||||
slack:
|
||||
plugin: slack
|
||||
webhook: ${SLACK_WEBHOOK_URL}
|
||||
channel: "#agent-updates"
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
# Send test message
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{"text":"Agent Orchestrator test"}' \
|
||||
$SLACK_WEBHOOK_URL
|
||||
```
|
||||
|
||||
### Custom Trackers
|
||||
|
||||
To add a custom tracker (Jira, Asana, etc.), create a plugin:
|
||||
|
||||
1. See plugin examples in `packages/plugins/tracker-*/`
|
||||
2. Implement the `Tracker` interface from `@aoagents/ao-core`
|
||||
3. Register your plugin in the config
|
||||
|
||||
See [Development Guide](./docs/DEVELOPMENT.md) for plugin development guidelines.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Run `ao doctor`
|
||||
|
||||
Use the built-in doctor before debugging a broken install by hand:
|
||||
|
||||
```bash
|
||||
ao doctor
|
||||
ao doctor --fix
|
||||
```
|
||||
|
||||
`ao doctor` reports deterministic PASS/WARN/FAIL checks for PATH and launcher resolution, required binaries, terminal-runtime health (tmux on Unix; PowerShell / `runtime-process` on Windows), GitHub CLI health, stale AO temp files, config support directories, and core build/runtime sanity. It runs and is supported on Windows. `--fix` only applies safe fixes such as creating missing AO support directories, refreshing the local launcher link, and removing stale AO temp files.
|
||||
|
||||
### Run `ao update`
|
||||
|
||||
When you installed AO from this repository and want to refresh that local install:
|
||||
|
||||
```bash
|
||||
git switch main
|
||||
ao update
|
||||
```
|
||||
|
||||
`ao update` is intentionally conservative: it requires a clean working tree on `main`, fast-forwards from `origin/main`, reinstalls dependencies, clean-rebuilds the critical core/CLI/web packages, refreshes the launcher with `npm link`, and runs CLI smoke tests. Works on macOS, Linux, and Windows (Windows uses the bundled `ao-update.ps1` script automatically). Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
|
||||
### "No agent-orchestrator.yaml found"
|
||||
|
||||
**Problem:** The orchestrator can't find your config file.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# ao start auto-creates the config if none exists
|
||||
ao start
|
||||
|
||||
# Or copy an example and edit manually
|
||||
cp examples/simple-github.yaml agent-orchestrator.yaml
|
||||
```
|
||||
|
||||
### "tmux not found"
|
||||
|
||||
**Problem:** tmux is not installed (required for the tmux runtime — the default on macOS and Linux).
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install tmux
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install tmux
|
||||
|
||||
# Fedora/RHEL
|
||||
sudo dnf install tmux
|
||||
```
|
||||
|
||||
**On Windows:** this error should not appear in normal use. If it does, your config has `runtime: tmux` set explicitly. Switch to `runtime: process` (or remove the override — `process` is the Windows default), and AO will use ConPTY natively without tmux.
|
||||
|
||||
### "gh auth failed"
|
||||
|
||||
**Problem:** GitHub CLI is not authenticated.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
gh auth login
|
||||
|
||||
# Select:
|
||||
# - GitHub.com (not Enterprise)
|
||||
# - HTTPS (recommended)
|
||||
# - Authenticate with browser
|
||||
# - Include repo scope
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
gh auth status
|
||||
```
|
||||
|
||||
### "LINEAR_API_KEY not found"
|
||||
|
||||
**Problem:** Linear API key is not set in environment.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Get your key from: https://linear.app/settings/api
|
||||
|
||||
# Add to shell profile
|
||||
echo 'export LINEAR_API_KEY="lin_api_..."' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
|
||||
# Verify
|
||||
echo $LINEAR_API_KEY
|
||||
```
|
||||
|
||||
### "Port already in use"
|
||||
|
||||
**Problem:** Another service is using the dashboard port (default 3000).
|
||||
|
||||
**Note:** `ao start` automatically finds the next free port if the configured port is busy. You'll see a message like "Port 3000 is busy — using 3001 instead." If you still need to fix it manually:
|
||||
|
||||
```bash
|
||||
# Option 1: Change port in agent-orchestrator.yaml
|
||||
port: 3001
|
||||
|
||||
# Option 2: Find and kill the process using the port
|
||||
lsof -ti:3000 | xargs kill
|
||||
```
|
||||
|
||||
### "Workspace creation failed"
|
||||
|
||||
**Problem:** Orchestrator can't create worktrees or clones.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# AO stores runtime data under ~/.agent-orchestrator/
|
||||
ls -la ~/.agent-orchestrator
|
||||
|
||||
# Create the base directory if missing
|
||||
mkdir -p ~/.agent-orchestrator
|
||||
|
||||
# Check disk space
|
||||
df -h
|
||||
```
|
||||
|
||||
### "Session not found"
|
||||
|
||||
**Problem:** Session ID doesn't exist or was already destroyed.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# List active sessions
|
||||
ao session ls
|
||||
|
||||
# Check status dashboard
|
||||
ao status
|
||||
```
|
||||
|
||||
### "Agent not responding"
|
||||
|
||||
**Problem:** Agent session is stuck or frozen.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check session status
|
||||
ao status
|
||||
|
||||
# Attach to session to investigate
|
||||
ao open <session-name>
|
||||
|
||||
# Send message to agent
|
||||
ao send <session-name> "Please report your current status"
|
||||
|
||||
# Kill and respawn if necessary
|
||||
ao session kill <session-name>
|
||||
ao spawn <issue-id>
|
||||
```
|
||||
|
||||
### "Permission denied" when spawning
|
||||
|
||||
**Problem:** Agent doesn't have permissions for git operations.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check SSH keys are added
|
||||
ssh -T git@github.com
|
||||
|
||||
# Add SSH key if needed
|
||||
ssh-add ~/.ssh/id_ed25519
|
||||
|
||||
# Or use HTTPS and authenticate gh CLI
|
||||
gh auth login
|
||||
```
|
||||
|
||||
### "YAML parse error"
|
||||
|
||||
**Problem:** Syntax error in `agent-orchestrator.yaml`.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Validate YAML syntax online: https://www.yamllint.com/
|
||||
|
||||
# Common issues:
|
||||
# - Incorrect indentation (use 2 spaces, not tabs)
|
||||
# - Missing quotes around strings with special characters
|
||||
# - Typo in field names
|
||||
```
|
||||
|
||||
### "Node version too old"
|
||||
|
||||
**Problem:** Node.js version is below 20.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check version
|
||||
node --version
|
||||
|
||||
# Upgrade with nvm (recommended)
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
nvm alias default 20
|
||||
|
||||
# Or download from: https://nodejs.org/
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Multi-Project Setup
|
||||
|
||||
Manage multiple repositories:
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
frontend:
|
||||
repo: org/frontend
|
||||
path: ~/frontend
|
||||
sessionPrefix: fe
|
||||
|
||||
backend:
|
||||
repo: org/backend
|
||||
path: ~/backend
|
||||
sessionPrefix: api
|
||||
|
||||
docs:
|
||||
repo: org/docs
|
||||
path: ~/docs
|
||||
sessionPrefix: doc
|
||||
```
|
||||
|
||||
See [examples/multi-project.yaml](./examples/multi-project.yaml) for full example.
|
||||
|
||||
### Custom Plugin Development
|
||||
|
||||
Create custom plugins for:
|
||||
|
||||
- Different runtimes (Docker, Kubernetes, SSH, cloud VMs)
|
||||
- Different agents (custom AI assistants)
|
||||
- Different trackers (Jira, Asana, custom systems)
|
||||
- Different notifiers (email, webhooks, custom integrations)
|
||||
|
||||
See [Development Guide](./docs/DEVELOPMENT.md) for plugin development guidelines.
|
||||
|
||||
### Docker Runtime
|
||||
|
||||
Run agents in Docker containers:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
runtime: docker
|
||||
|
||||
# Plugin will use official images or build from Dockerfile
|
||||
```
|
||||
|
||||
### Kubernetes Runtime
|
||||
|
||||
Run agents in Kubernetes pods:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
runtime: kubernetes
|
||||
|
||||
# Requires kubectl configured with cluster access
|
||||
```
|
||||
|
||||
### Custom Notifiers
|
||||
|
||||
Send notifications to custom webhooks:
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
webhook:
|
||||
plugin: webhook
|
||||
url: https://your-service.com/webhook
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer ${WEBHOOK_TOKEN}"
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### What's a session?
|
||||
|
||||
A session is an isolated workspace where an agent works on a single issue. Each session has:
|
||||
|
||||
- Its own git worktree or clone
|
||||
- Its own runtime session — a tmux session on macOS/Linux, a ConPTY pty-host process on Windows (or a Docker container, etc.)
|
||||
- Its own metadata (branch, PR, status)
|
||||
- Its own event log
|
||||
|
||||
Sessions are ephemeral — they're created for an issue and destroyed when merged.
|
||||
|
||||
### What's a worktree vs clone?
|
||||
|
||||
**Worktree** (default):
|
||||
|
||||
- Shares `.git` directory with main repo
|
||||
- Fast to create (no cloning)
|
||||
- Efficient disk usage
|
||||
- Best for local development
|
||||
|
||||
**Clone**:
|
||||
|
||||
- Full independent repository clone
|
||||
- Slower to create
|
||||
- More disk space
|
||||
- Better for isolation, remote work
|
||||
|
||||
### How do reactions work?
|
||||
|
||||
Reactions are event handlers that run automatically:
|
||||
|
||||
1. Event occurs (CI fails, review comment added, PR approved)
|
||||
2. Orchestrator checks reaction config
|
||||
3. If `auto: true`, performs the action automatically
|
||||
4. If escalation threshold reached, notifies human
|
||||
|
||||
Actions can be:
|
||||
|
||||
- `send-to-agent` - Forward event to agent to handle
|
||||
- `auto-merge` - Merge PR automatically
|
||||
- `notify` - Send notification to human
|
||||
|
||||
### When should I enable auto-merge?
|
||||
|
||||
Enable auto-merge if:
|
||||
|
||||
- ✅ You have comprehensive CI/CD tests
|
||||
- ✅ You require code review approval
|
||||
- ✅ You trust your agents to write correct code
|
||||
- ✅ You want maximum automation
|
||||
|
||||
Don't enable auto-merge if:
|
||||
|
||||
- ❌ You have incomplete test coverage
|
||||
- ❌ You want manual review of every change
|
||||
- ❌ You're still evaluating agent quality
|
||||
- ❌ You work on critical systems (finance, healthcare, etc.)
|
||||
|
||||
Start with `auto: false` and enable after building confidence.
|
||||
|
||||
### How do I add custom agent rules?
|
||||
|
||||
**Inline:**
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
my-app:
|
||||
agentRules: |
|
||||
Always run tests before pushing.
|
||||
Use conventional commits.
|
||||
```
|
||||
|
||||
**External file:**
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
my-app:
|
||||
agentRulesFile: .agent-rules.md
|
||||
```
|
||||
|
||||
Rules are included in every agent prompt for that project.
|
||||
|
||||
### Can I use multiple trackers?
|
||||
|
||||
Yes! Different projects can use different trackers:
|
||||
|
||||
```yaml
|
||||
projects:
|
||||
frontend:
|
||||
tracker:
|
||||
plugin: github
|
||||
|
||||
backend:
|
||||
tracker:
|
||||
plugin: linear
|
||||
teamId: "..."
|
||||
```
|
||||
|
||||
### How do I monitor agent progress?
|
||||
|
||||
Three ways:
|
||||
|
||||
1. **Dashboard** - `ao start` then visit http://localhost:3000 (or your configured `port:`)
|
||||
2. **CLI status** - `ao status` (text-based dashboard)
|
||||
3. **Attach to session** - `ao open <session-name>` (live terminal)
|
||||
|
||||
### What if an agent gets stuck?
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
ao status
|
||||
|
||||
# Send message
|
||||
ao send <session-name> "What's your current status?"
|
||||
|
||||
# Attach to investigate
|
||||
ao open <session-name>
|
||||
|
||||
# Kill and respawn if necessary
|
||||
ao session kill <session-name>
|
||||
ao spawn <issue-id>
|
||||
```
|
||||
|
||||
Agents also send "stuck" notifications automatically after inactivity threshold.
|
||||
|
||||
### How do I clean up old sessions?
|
||||
|
||||
```bash
|
||||
# List all sessions
|
||||
ao session ls
|
||||
|
||||
# Kill specific session
|
||||
ao session kill <session-name>
|
||||
|
||||
# Cleanup script (example)
|
||||
ao session ls --json --include-terminated | jq -r '.data[] | select(.status == "merged") | .id' | xargs -I{} ao session kill {}
|
||||
```
|
||||
|
||||
> **Note:** `ao session ls --json` and `ao status --json` emit `{ data: [...], meta: { hiddenTerminatedCount } }`. By default terminated sessions (`killed`, `terminated`, `done`, `merged`, `errored`, `cleanup`) are hidden — pass `--include-terminated` to include them in `data`.
|
||||
|
||||
### Can I run multiple orchestrators?
|
||||
|
||||
Yes! Each orchestrator instance should have:
|
||||
|
||||
- Different dashboard port (`port`) — e.g., 3000 for project A, 3001 for project B
|
||||
- Different config location or project paths
|
||||
|
||||
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:
|
||||
|
||||
- Separating projects
|
||||
- Different teams
|
||||
- Testing new configs
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Start the orchestrator** — `ao start` (auto-creates config on first run)
|
||||
2. **Spawn an agent** — `ao spawn 123` (project auto-detected from cwd)
|
||||
3. **Monitor progress** — `ao status` or dashboard at http://localhost:3000
|
||||
4. **Read [Development Guide](./docs/DEVELOPMENT.md)** — Code conventions and architecture
|
||||
5. **Explore examples** — See [examples/](./examples/) for more configs
|
||||
6. **Join the community** — Report issues, share configs, contribute plugins
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Open an issue at: https://github.com/ComposioHQ/agent-orchestrator/issues
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
# Troubleshooting
|
||||
|
||||
## DirectTerminal: posix_spawnp failed error
|
||||
|
||||
**Symptom**: Terminal in browser shows "Connected" but blank. WebSocket logs show:
|
||||
|
||||
```
|
||||
[DirectTerminal] Failed to spawn PTY: Error: posix_spawnp failed.
|
||||
```
|
||||
|
||||
**Root Cause**: node-pty prebuilt binaries are incompatible with your system.
|
||||
|
||||
**Fix**: Rebuild node-pty from source:
|
||||
|
||||
```bash
|
||||
# From the repository root
|
||||
cd node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty
|
||||
npx node-gyp rebuild
|
||||
```
|
||||
|
||||
**Verification**:
|
||||
|
||||
```bash
|
||||
# Test node-pty works
|
||||
node -e "const pty = require('./node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty'); \
|
||||
const shell = pty.spawn('/bin/zsh', [], {name: 'xterm-256color', cols: 80, rows: 24, \
|
||||
cwd: process.env.HOME, env: process.env}); \
|
||||
shell.onData((d) => console.log('✅ OK')); \
|
||||
setTimeout(() => process.exit(0), 1000);"
|
||||
```
|
||||
|
||||
**When this happens**:
|
||||
|
||||
- After `pnpm install` (uses cached prebuilts)
|
||||
- After copying the repo to a new location
|
||||
- On some macOS configurations with Homebrew Node
|
||||
|
||||
**Permanent fix**: The postinstall hook automatically rebuilds node-pty:
|
||||
|
||||
```bash
|
||||
pnpm install # Automatically rebuilds node-pty via postinstall hook
|
||||
```
|
||||
|
||||
If you need to manually rebuild:
|
||||
|
||||
```bash
|
||||
cd node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty
|
||||
npx node-gyp rebuild
|
||||
```
|
||||
|
||||
## Other Issues
|
||||
|
||||
### Config file not found
|
||||
|
||||
**Symptom**: API returns 500 with "No agent-orchestrator.yaml found"
|
||||
|
||||
**Fix**: Ensure config exists in the directory where you run `ao start`, or symlink it:
|
||||
|
||||
```bash
|
||||
ln -s /path/to/agent-orchestrator.yaml packages/web/agent-orchestrator.yaml
|
||||
```
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
# Agent Orchestrator Configuration
|
||||
# Copy to agent-orchestrator.yaml and customize.
|
||||
$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json
|
||||
|
||||
# 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
|
||||
|
||||
# Terminal server ports (defaults: 14800/14801 — chosen to avoid conflicts with dev tools)
|
||||
# Override when running multiple dashboards to avoid EADDRINUSE
|
||||
# terminalPort: 14800
|
||||
# directTerminalPort: 14801
|
||||
|
||||
# Power management — controls system sleep while AO is running
|
||||
# power:
|
||||
# preventIdleSleep: true # Default on macOS, no-op on Linux
|
||||
# # Keeps Mac awake for remote dashboard access (e.g., via Tailscale)
|
||||
# # Uses caffeinate -i -w <pid> — auto-releases when AO exits
|
||||
# # Note: lid-close sleep is enforced by hardware and cannot be prevented
|
||||
|
||||
# Lifecycle — controls how AO cleans up sessions after their PRs merge
|
||||
# lifecycle:
|
||||
# autoCleanupOnMerge: true # Default. When a PR is detected as merged, tear down
|
||||
# # the tmux session, remove the worktree, and archive
|
||||
# # metadata so `ao status` stays clean. Set false if
|
||||
# # you want merged worktrees preserved for inspection.
|
||||
# mergeCleanupIdleGraceMs: 300000 # Grace window (ms) before forcing cleanup on an agent
|
||||
# # that is still active at merge time. Default 5 min.
|
||||
|
||||
# Default plugins (these are the defaults — you can omit this section)
|
||||
# runtime defaults to 'tmux' on Linux/macOS, 'process' on Windows
|
||||
defaults:
|
||||
# runtime: tmux # tmux (Linux/macOS default) | process (Windows default)
|
||||
agent: claude-code # claude-code | codex | aider | opencode | cursor | kimicode
|
||||
# orchestrator:
|
||||
# agent: claude-code
|
||||
# worker:
|
||||
# agent: codex
|
||||
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:
|
||||
my-app:
|
||||
name: My App
|
||||
repo: org/my-app
|
||||
path: ~/my-app
|
||||
defaultBranch: main
|
||||
sessionPrefix: app
|
||||
|
||||
# Issue tracker (defaults to github issues)
|
||||
# tracker:
|
||||
# plugin: linear
|
||||
# teamId: "your-team-id"
|
||||
|
||||
# SCM webhook acceleration (optional)
|
||||
# scm:
|
||||
# plugin: github
|
||||
# webhook:
|
||||
# path: /api/webhooks/github
|
||||
# secretEnvVar: GITHUB_WEBHOOK_SECRET
|
||||
# signatureHeader: x-hub-signature-256
|
||||
# eventHeader: x-github-event
|
||||
# deliveryHeader: x-github-delivery
|
||||
# maxBodyBytes: 1048576
|
||||
|
||||
# Per-project environment variables forwarded into worker session runtimes.
|
||||
# Useful for scoping per-project tokens (e.g. pinning gh auth via GH_TOKEN).
|
||||
# AO-internal vars (AO_SESSION, AO_PROJECT_ID, etc.) always take precedence.
|
||||
# env:
|
||||
# GH_TOKEN: ghp_xxx
|
||||
|
||||
# Files to symlink into workspaces
|
||||
# symlinks: [.env, .claude]
|
||||
|
||||
# Commands to run after workspace creation
|
||||
# postCreate:
|
||||
# - "pnpm install"
|
||||
|
||||
# Agent-specific config
|
||||
# agentConfig:
|
||||
# permissions: skip # --dangerously-skip-permissions
|
||||
# model: opus
|
||||
|
||||
# Optional role-specific agent overrides
|
||||
# orchestrator:
|
||||
# agent: claude-code
|
||||
# agentConfig:
|
||||
# model: claude-sonnet-4-5
|
||||
# worker:
|
||||
# agent: codex
|
||||
# agentConfig:
|
||||
# model: gpt-5-codex
|
||||
|
||||
# Inline rules included in every agent prompt for this project
|
||||
# agentRules: |
|
||||
# Always run tests before pushing.
|
||||
# Use conventional commits (feat:, fix:, chore:).
|
||||
|
||||
# Path to a rules file (relative to project path)
|
||||
# agentRulesFile: .agent-rules.md
|
||||
|
||||
# Rules for the orchestrator agent (reserved for future use)
|
||||
# orchestratorRules: |
|
||||
# Prefer to batch-spawn related issues together.
|
||||
|
||||
# OpenCode issue session strategy (only for agent: opencode)
|
||||
# opencodeIssueSessionStrategy: reuse # reuse | delete | ignore
|
||||
|
||||
# Per-project reaction overrides
|
||||
# reactions:
|
||||
# approved-and-green:
|
||||
# auto: true # enable auto-merge for this project
|
||||
|
||||
# Notification channels
|
||||
# notifiers:
|
||||
# slack:
|
||||
# plugin: slack
|
||||
# webhook: ${SLACK_WEBHOOK_URL}
|
||||
# channel: "#agent-updates"
|
||||
#
|
||||
# openclaw:
|
||||
# plugin: openclaw
|
||||
# url: http://127.0.0.1:18789/hooks/agent # Use https:// for remote (non-localhost) deployments
|
||||
# token: ${OPENCLAW_HOOKS_TOKEN}
|
||||
# retries: 3
|
||||
# retryDelayMs: 1000
|
||||
# wakeMode: now
|
||||
|
||||
# Notification routing by priority
|
||||
# notificationRouting:
|
||||
# urgent: [desktop, slack] # agent stuck, needs input, errored
|
||||
# action: [desktop, slack] # PR ready to merge
|
||||
# warning: [slack] # auto-fix failed
|
||||
# info: [slack] # summary, all done
|
||||
|
||||
# Reactions — auto-responses to events (these are the defaults)
|
||||
# reactions:
|
||||
# ci-failed:
|
||||
# auto: true
|
||||
# action: send-to-agent
|
||||
# retries: 2
|
||||
# escalateAfter: 2
|
||||
#
|
||||
# changes-requested:
|
||||
# auto: true
|
||||
# action: send-to-agent
|
||||
# escalateAfter: 30m
|
||||
#
|
||||
# approved-and-green:
|
||||
# auto: false # set to true for auto-merge
|
||||
# action: notify
|
||||
# priority: action
|
||||
#
|
||||
# agent-stuck:
|
||||
# threshold: 10m
|
||||
# action: notify
|
||||
# priority: urgent
|
||||
|
|
@ -1,784 +0,0 @@
|
|||
# Architecture Design — Agent Orchestrator
|
||||
|
||||
_Compiled: 2026-02-13_
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
**Push, not pull.** The human never polls. The human never checks a dashboard wondering "what's happening?" The system pushes notifications to the human exactly when their attention is needed — and stays silent otherwise.
|
||||
|
||||
The dashboard is a **drill-down tool** you open after receiving a notification, not something you sit and watch. The **Notifier is the primary interface.**
|
||||
|
||||
### Interaction Model
|
||||
|
||||
```
|
||||
Human spawns 20 agents → walks away → lives their life
|
||||
│
|
||||
┌───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Orchestrator runs autonomously:
|
||||
├── Agents work on issues
|
||||
├── CI fails? → auto-send fix to agent → resolved silently
|
||||
├── Review comments? → auto-send to agent → resolved silently
|
||||
├── Agent stuck? → NOTIFY HUMAN
|
||||
├── Agent needs input? → NOTIFY HUMAN
|
||||
├── PR ready to merge? → NOTIFY HUMAN (or auto-merge if configured)
|
||||
├── Agent errored? → NOTIFY HUMAN
|
||||
└── All done? → NOTIFY HUMAN with summary
|
||||
|
||||
Human only intervenes when notified. Everything else is handled.
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Push, not pull**: Notifications are the primary interface. Dashboard is secondary drill-down.
|
||||
2. **Server-centric**: One central daemon (`ao start`) manages every registered project, and all agents report to it. Each project gets its own orchestrator agent — one orchestrator per project, never a single orchestrator spanning all projects.
|
||||
3. **Plugin everything**: 8 pluggable abstraction slots. Swap any component.
|
||||
4. **Works out of the box**: Default config (tmux + claude-code + worktree + github) requires zero setup beyond `npx agent-orchestrator init`.
|
||||
5. **Silence by default, loud when needed**: Auto-handle routine issues (CI failures, review comments). Only notify the human when their judgment or action is truly required.
|
||||
6. **Runtime agnostic**: tmux is just one way to run agents. Docker, K8s, cloud, SSH, child processes — all through the same interface.
|
||||
|
||||
---
|
||||
|
||||
## Nomenclature
|
||||
|
||||
| Term | Definition | Examples |
|
||||
| ---------------- | ------------------------------------------ | -------------------------------- |
|
||||
| **Orchestrator (daemon)** | The central server process that manages **all** registered projects | `ao start` + the Next.js app |
|
||||
| **Orchestrator agent** | A per-project agent session that spawns and supervises workers — **one per project** | `my-app-orchestrator`, `backend-api-orchestrator` |
|
||||
| **Project** | A configured repository to work on | `my-app`, `backend-api` |
|
||||
| **Session** | A running agent instance working on a task | `my-app-1`, `my-app-2` |
|
||||
| **Runtime** | Where/how the session executes | tmux, docker, k8s, process |
|
||||
| **Agent** | The AI coding tool being used | claude-code, codex, aider |
|
||||
| **Workspace** | Isolated code copy for a session | git worktree, clone, volume |
|
||||
| **Tracker** | Issue/task tracking system | github, linear, jira |
|
||||
| **SCM** | Source code management platform | github, gitlab, bitbucket |
|
||||
| **Notifier** | Communication/alert channel | slack, discord, desktop, webhook |
|
||||
| **Terminal** | Human interaction interface | iterm2, web terminal, none |
|
||||
|
||||
---
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
CLI ───REST───► │ Orchestrator Server │
|
||||
│ (Next.js) │
|
||||
Web ───REST/───► │ │
|
||||
SSE │ ┌────────────┐ ┌────────────────┐ │
|
||||
│ │ Session │ │ Plugin │ │
|
||||
Agents ────────► │ │ Manager │ │ Registry │ │
|
||||
(heartbeat/ │ └──────┬─────┘ └───────┬────────┘ │
|
||||
webhook) │ │ │ │
|
||||
│ ┌──────┴─────┐ ┌───────┴────────┐ │
|
||||
│ │ Lifecycle │ │ Config │ │
|
||||
│ │ Manager │ │ Manager │ │
|
||||
│ └──────┬─────┘ └────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────┴──────────────────────────┐ │
|
||||
│ │ Event Bus │ │
|
||||
│ │ (pub/sub + persistence) │ │
|
||||
│ └──┬──────┬──────┬──────┬────────┘ │
|
||||
└─────┼──────┼──────┼──────┼──────────┘
|
||||
│ │ │ │
|
||||
┌───────┘ │ │ └───────┐
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────┐ ┌────────┐ ┌────────┐ ┌─────────┐
|
||||
│ SSE → │ │Notifier│ │Reaction│ │ Event │
|
||||
│ Web UI │ │Plugins │ │ Engine │ │ Log │
|
||||
└─────────┘ └────────┘ └────────┘ └─────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Agent → Server**: Heartbeats, status updates, "need input" signals
|
||||
2. **Server → Dashboard**: SSE stream of session state changes
|
||||
3. **Server → Notifiers**: Alerts when human attention is needed
|
||||
4. **Server → Agents**: Commands via runtime-specific channels (tmux send-keys, docker exec, HTTP POST, etc.)
|
||||
5. **CLI → Server**: REST API calls for spawn, kill, send, status
|
||||
6. **SCM → Server**: PR state, CI checks, review comments (polled or webhooks)
|
||||
|
||||
---
|
||||
|
||||
## The 8 Plugin Slots
|
||||
|
||||
### 1. Runtime — Where sessions execute
|
||||
|
||||
```typescript
|
||||
interface Runtime {
|
||||
readonly name: string;
|
||||
|
||||
// Lifecycle
|
||||
create(session: SessionConfig): Promise<RuntimeHandle>;
|
||||
destroy(handle: RuntimeHandle): Promise<void>;
|
||||
|
||||
// Communication
|
||||
sendMessage(handle: RuntimeHandle, message: string): Promise<void>;
|
||||
getOutput(handle: RuntimeHandle, lines?: number): Promise<string>;
|
||||
|
||||
// Health
|
||||
isAlive(handle: RuntimeHandle): Promise<boolean>;
|
||||
getMetrics(handle: RuntimeHandle): Promise<RuntimeMetrics>;
|
||||
|
||||
// Optional: interactive access
|
||||
attach?(handle: RuntimeHandle): Promise<AttachInfo>;
|
||||
}
|
||||
```
|
||||
|
||||
| Implementation | How it works | Best for |
|
||||
| ---------------- | ------------------------------ | ------------------------------ |
|
||||
| `tmux` (default) | tmux sessions + send-keys | Local development, interactive |
|
||||
| `process` | Child processes + stdin/stdout | Headless, CI/CD, scripting |
|
||||
| `docker` | Docker containers + exec | Isolation, reproducibility |
|
||||
| `kubernetes` | K8s pods/jobs | Scale, enterprise |
|
||||
| `ssh` | SSH to remote + tmux/process | Remote machines |
|
||||
| `e2b` | E2B SDK (Firecracker microVMs) | Cloud sandboxes |
|
||||
| `fly` | Fly.io Machines API | Cost-effective cloud |
|
||||
| `modal` | Modal Sandboxes | GPU, autoscaling |
|
||||
|
||||
### 2. Agent — AI coding tool
|
||||
|
||||
```typescript
|
||||
interface Agent {
|
||||
readonly name: string;
|
||||
readonly processName: string; // for detection
|
||||
|
||||
// Launch
|
||||
getLaunchCommand(session: SessionConfig, project: ProjectConfig): string;
|
||||
getEnvironment(session: SessionConfig): Record<string, string>;
|
||||
|
||||
// Activity detection
|
||||
detectActivity(session: Session): Promise<ActivityState>;
|
||||
isProcessRunning(runtimeHandle: RuntimeHandle): Promise<boolean>;
|
||||
|
||||
// Introspection
|
||||
introspect(session: Session): Promise<AgentIntrospection | null>;
|
||||
|
||||
// Optional
|
||||
postLaunchSetup?(session: Session): Promise<void>;
|
||||
estimateCost?(session: Session): Promise<CostEstimate>;
|
||||
}
|
||||
```
|
||||
|
||||
| Implementation | Launch command | Activity detection |
|
||||
| ----------------------- | --------------------------------------- | -------------------------- |
|
||||
| `claude-code` (default) | `claude --dangerously-skip-permissions` | JSONL mtime + process tree |
|
||||
| `claude-headless` | `claude -p --output-format stream-json` | stdout parsing |
|
||||
| `codex` | `codex` | Process detection |
|
||||
| `aider` | `aider --no-auto-commits` | Process detection |
|
||||
| `goose` | `goose session` | Process detection |
|
||||
| `custom` | User-defined command | Configurable |
|
||||
|
||||
### 3. Workspace — Code isolation
|
||||
|
||||
```typescript
|
||||
interface Workspace {
|
||||
readonly name: string;
|
||||
|
||||
create(project: ProjectConfig, session: SessionConfig): Promise<WorkspacePath>;
|
||||
destroy(path: WorkspacePath): Promise<void>;
|
||||
list(project: ProjectConfig): Promise<WorkspaceInfo[]>;
|
||||
|
||||
// Optional hooks
|
||||
postCreate?(path: WorkspacePath, project: ProjectConfig): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
| Implementation | How | Tradeoff |
|
||||
| -------------------- | ------------------------ | ---------------------------------------- |
|
||||
| `worktree` (default) | `git worktree add` | Fast, shared objects, requires same repo |
|
||||
| `clone` | `git clone` | Full isolation, slower, more disk |
|
||||
| `copy` | `cp -r` | No git dependency, heaviest |
|
||||
| `volume` | Docker/K8s volume mounts | For container runtimes |
|
||||
|
||||
### 4. Tracker — Issue/task tracking
|
||||
|
||||
```typescript
|
||||
interface Tracker {
|
||||
readonly name: string;
|
||||
|
||||
getIssue(identifier: string): Promise<Issue>;
|
||||
isCompleted(identifier: string): Promise<boolean>;
|
||||
issueUrl(identifier: string): string;
|
||||
branchName(identifier: string): string;
|
||||
generatePrompt(identifier: string, project: ProjectConfig): string;
|
||||
|
||||
// Optional
|
||||
listIssues?(filters?: IssueFilters): Promise<Issue[]>;
|
||||
updateIssue?(identifier: string, update: IssueUpdate): Promise<void>;
|
||||
createIssue?(input: CreateIssueInput): Promise<Issue>;
|
||||
}
|
||||
```
|
||||
|
||||
| Implementation | API | Auth |
|
||||
| ------------------ | ----------- | -------------- |
|
||||
| `github` (default) | `gh` CLI | GitHub token |
|
||||
| `linear` | GraphQL API | Linear API key |
|
||||
| `jira` | REST API | Jira token |
|
||||
| `plain` | Local files | None |
|
||||
|
||||
### 5. SCM — Source code platform (PR, CI, Reviews)
|
||||
|
||||
```typescript
|
||||
interface SCM {
|
||||
readonly name: string;
|
||||
|
||||
// PR lifecycle
|
||||
detectPR(session: Session): Promise<PRInfo | null>;
|
||||
getPRState(pr: PRInfo): Promise<PRState>;
|
||||
createPR(session: Session, title: string, body: string): Promise<PRInfo>;
|
||||
mergePR(pr: PRInfo, method?: MergeMethod): Promise<void>;
|
||||
closePR(pr: PRInfo): Promise<void>;
|
||||
|
||||
// CI tracking
|
||||
getCIChecks(pr: PRInfo): Promise<CICheck[]>;
|
||||
getCISummary(pr: PRInfo): Promise<CIStatus>;
|
||||
|
||||
// Review tracking
|
||||
getReviews(pr: PRInfo): Promise<Review[]>;
|
||||
getReviewDecision(pr: PRInfo): Promise<ReviewDecision>;
|
||||
getPendingComments(pr: PRInfo): Promise<ReviewComment[]>;
|
||||
getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]>;
|
||||
|
||||
// Merge readiness
|
||||
getMergeability(pr: PRInfo): Promise<MergeReadiness>;
|
||||
}
|
||||
```
|
||||
|
||||
| Implementation | API | Features |
|
||||
| ------------------ | ------------------- | -------------------------- |
|
||||
| `github` (default) | `gh` CLI + REST API | Full PR/CI/review support |
|
||||
| `gitlab` | REST API | MR/pipeline/review support |
|
||||
| `bitbucket` | REST API | PR/pipeline support |
|
||||
|
||||
### 6. Notifier — THE PRIMARY INTERFACE
|
||||
|
||||
The notifier is not a nice-to-have — it is the primary way the system communicates with humans. The human walks away after spawning agents. Notifications bring them back only when needed.
|
||||
|
||||
```typescript
|
||||
interface Notifier {
|
||||
readonly name: string;
|
||||
|
||||
// Core: push a notification to the human
|
||||
notify(event: OrchestratorEvent): Promise<void>;
|
||||
|
||||
// Optional: actionable notifications (buttons/links)
|
||||
notifyWithActions?(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void>;
|
||||
|
||||
// Optional: richer communication (post to channel)
|
||||
post?(message: string, context?: NotifyContext): Promise<string | null>;
|
||||
}
|
||||
|
||||
// Notifications can include actions the human can take directly
|
||||
interface NotifyAction {
|
||||
label: string; // "Merge PR", "Open Dashboard", "Kill Session"
|
||||
url?: string; // Deep link to dashboard action
|
||||
callback?: string; // API endpoint to call
|
||||
}
|
||||
```
|
||||
|
||||
| Implementation | Channel | Best for | Actionable? |
|
||||
| ------------------- | ---------------------------- | ------------------- | ----------------------------- |
|
||||
| `desktop` (default) | OS notifications (clickable) | Solo developer | Click → opens dashboard |
|
||||
| `slack` | Slack messages with buttons | Teams | Buttons → merge, review, kill |
|
||||
| `discord` | Discord messages | Communities | Links |
|
||||
| `webhook` | HTTP POST | Custom integrations | Custom |
|
||||
| `email` | Email digest | Async | Links |
|
||||
|
||||
**Multiple notifiers can be active simultaneously.** E.g., desktop for immediate alerts + Slack for team visibility + email for daily digest.
|
||||
|
||||
### 7. Terminal — Human interaction interface
|
||||
|
||||
```typescript
|
||||
interface Terminal {
|
||||
readonly name: string;
|
||||
|
||||
openSession(session: Session): Promise<void>;
|
||||
openAll(sessions: Session[]): Promise<void>;
|
||||
|
||||
// Optional
|
||||
isSessionOpen?(session: Session): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
| Implementation | How | Platform |
|
||||
| ---------------- | ------------------------- | ------------- |
|
||||
| `auto` (default) | Detect best available | Any |
|
||||
| `iterm2` | AppleScript API | macOS |
|
||||
| `web` | xterm.js in browser | Any |
|
||||
| `tmux-attach` | `tmux attach` in terminal | Any with tmux |
|
||||
| `none` | Headless | CI/CD |
|
||||
|
||||
### 8. Lifecycle Manager (Core — not pluggable)
|
||||
|
||||
The Lifecycle Manager is the orchestrator's brain. It:
|
||||
|
||||
- Polls SCM + Agent plugins on configurable intervals
|
||||
- Maintains state machine per session
|
||||
- Emits events on state transitions
|
||||
- Runs configured reactions
|
||||
- Feeds real-time data to dashboard via SSE
|
||||
|
||||
---
|
||||
|
||||
## Session Lifecycle State Machine
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ SPAWNING │
|
||||
└────┬─────┘
|
||||
│ runtime.create() + agent launched
|
||||
▼
|
||||
┌──────────┐
|
||||
┌─────│ WORKING │◄─────────────────────────┐
|
||||
│ └────┬─────┘ │
|
||||
│ │ PR detected │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ PR_OPEN │ │
|
||||
│ └────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌────┴────────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ CI_FAILED│ │ REVIEW_PENDING │ │
|
||||
│ └────┬─────┘ └────┬────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┴──────┐ │
|
||||
│ │ ▼ ▼ │
|
||||
│ │ ┌──────────────┐ ┌──────────┐ │
|
||||
│ │ │CHANGES_REQ'D │ │ APPROVED │ │
|
||||
│ │ └──────┬───────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────┼───────────────┘ │
|
||||
│ │ agent fixes │
|
||||
│ └──────────────────────────┘
|
||||
│
|
||||
│ When approved + CI green + no conflicts:
|
||||
│ ▼
|
||||
│ ┌──────────┐
|
||||
│ │MERGEABLE │──► auto-merge or notify human
|
||||
│ └────┬─────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────┐
|
||||
│ │ MERGED │
|
||||
│ └────┬─────┘
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────┐
|
||||
│ │ CLEANUP │──► destroy workspace + archive metadata
|
||||
│ └──────────┘
|
||||
│
|
||||
│ At any point:
|
||||
│ ┌───────────────┐
|
||||
├────►│ NEEDS_INPUT │──► notify human
|
||||
│ └───────────────┘
|
||||
│ ┌───────────────┐
|
||||
├────►│ STUCK/IDLE │──► notify human after threshold
|
||||
│ └───────────────┘
|
||||
│ ┌───────────────┐
|
||||
├────►│ ERRORED │──► notify human
|
||||
│ └───────────────┘
|
||||
│ ┌───────────────┐
|
||||
└────►│ KILLED │──► cleanup
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Human Attention Optimization
|
||||
|
||||
**The system notifies the human. The human never polls.**
|
||||
|
||||
The orchestrator operates on a simple principle: handle everything you can automatically, and push a notification to the human only when their judgment or action is truly required. The human spawns agents, walks away, and gets notified.
|
||||
|
||||
### Two-Tier Event Handling
|
||||
|
||||
**Tier 1: Auto-handled (human never sees these)**
|
||||
The orchestrator resolves these silently. The human is only notified if auto-resolution fails.
|
||||
|
||||
| Event | Auto-Response | Escalation |
|
||||
| ---------------------- | -------------------------------- | -------------------------------- |
|
||||
| CI failed | Send fix prompt to agent | Notify after 2 failed attempts |
|
||||
| Review comments | Send "address comments" to agent | Notify if unresolved after 30min |
|
||||
| Bugbot/linter comments | Send fix prompt to agent | Notify if unresolved after 30min |
|
||||
| Merge conflicts | Send "rebase" to agent | Notify if unresolved after 15min |
|
||||
|
||||
**Tier 2: Notify human (requires human judgment)**
|
||||
These always push a notification. The human's phone buzzes, Slack pings, etc.
|
||||
|
||||
| Event | Priority | Notification |
|
||||
| -------------------------------------------------------------- | -------- | ----------------------------------------------------- |
|
||||
| **Agent needs input** (permission, question, stuck) | URGENT | "Session X needs your input" + deep link |
|
||||
| **Agent errored** (crashed, unrecoverable) | URGENT | "Session X crashed" + error context |
|
||||
| **PR ready to merge** (approved + CI green) | ACTION | "PR #42 ready to merge" + merge button |
|
||||
| **Agent idle too long** (no PR, no progress) | WARNING | "Session X idle for 15min, may need help" |
|
||||
| **Auto-fix failed** (CI fix failed 2x, comments not addressed) | WARNING | "Session X couldn't resolve CI/review — needs you" |
|
||||
| **All work complete** | INFO | "All 20 sessions done. 18 PRs merged, 2 need review." |
|
||||
|
||||
### Escalation Chains
|
||||
|
||||
Events start at auto-handle and escalate through notification tiers:
|
||||
|
||||
```
|
||||
Event detected
|
||||
│
|
||||
▼
|
||||
Can auto-handle? ──yes──► Auto-respond (send to agent)
|
||||
│ │
|
||||
no Resolved? ──yes──► Done (silent)
|
||||
│ │
|
||||
▼ no (retry N times)
|
||||
NOTIFY HUMAN │
|
||||
│ ▼
|
||||
│ NOTIFY HUMAN
|
||||
│ "Tried to auto-fix, couldn't resolve"
|
||||
▼
|
||||
Human acts via:
|
||||
├── Notification action button (merge, kill, open)
|
||||
├── Dashboard deep link
|
||||
├── CLI command
|
||||
└── Direct tmux attach
|
||||
```
|
||||
|
||||
### Notification Channels (Priority-Based Routing)
|
||||
|
||||
Different priorities route to different channels:
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
routing:
|
||||
urgent: [desktop, slack, sms] # Agent stuck, errored, needs input
|
||||
action: [desktop, slack] # PR ready to merge
|
||||
warning: [slack] # Auto-fix failed, idle too long
|
||||
info: [slack] # Summary, all done
|
||||
```
|
||||
|
||||
### Reactions (configurable auto-responses)
|
||||
|
||||
```yaml
|
||||
# agent-orchestrator.yaml
|
||||
reactions:
|
||||
ci-failed:
|
||||
auto: true
|
||||
action: send-to-agent
|
||||
message: "CI is failing. Run `gh pr checks` to see failures, fix them, and push."
|
||||
retries: 2
|
||||
escalate-after: 2 # notify human after 2 failed auto-fix attempts
|
||||
|
||||
changes-requested:
|
||||
auto: true
|
||||
action: send-to-agent
|
||||
message: "Review comments on your PR. Check with `gh pr view --comments` and address each one."
|
||||
escalate-after: 30m
|
||||
|
||||
bugbot-comments:
|
||||
auto: true
|
||||
action: send-to-agent
|
||||
message: "Automated review comments found. Fix the issues flagged by the bot."
|
||||
escalate-after: 30m
|
||||
|
||||
merge-conflicts:
|
||||
auto: true
|
||||
action: send-to-agent
|
||||
message: "Your branch has merge conflicts. Rebase on the default branch and resolve them."
|
||||
escalate-after: 15m
|
||||
|
||||
approved-and-green:
|
||||
auto: false # require human confirmation by default
|
||||
action: notify
|
||||
priority: action
|
||||
message: "PR is ready to merge"
|
||||
# Set auto: true + action: auto-merge for full automation
|
||||
|
||||
agent-stuck:
|
||||
threshold: 10m
|
||||
action: notify
|
||||
priority: urgent
|
||||
|
||||
agent-needs-input:
|
||||
action: notify
|
||||
priority: urgent
|
||||
|
||||
agent-exited:
|
||||
action: notify
|
||||
priority: urgent
|
||||
|
||||
all-complete:
|
||||
action: notify
|
||||
priority: info
|
||||
message: "All sessions complete"
|
||||
include-summary: true # PRs merged, pending, failed
|
||||
|
||||
agent-idle-no-pr:
|
||||
threshold: 30m # working for 30min with no PR
|
||||
action: notify
|
||||
priority: warning
|
||||
message: "Agent has been working for 30min without creating a PR"
|
||||
```
|
||||
|
||||
### Dashboard (Secondary — Drill-Down Tool)
|
||||
|
||||
The dashboard exists for when you get a notification and need to drill down. It's organized by attention priority:
|
||||
|
||||
- **Red zone** (top): URGENT — sessions needing human input RIGHT NOW
|
||||
- **Orange zone**: ACTION — PRs ready to merge, decisions needed
|
||||
- **Yellow zone**: WARNING — auto-fix failed, agents idle too long
|
||||
- **Green zone**: Sessions working normally (collapsed by default)
|
||||
- **Grey zone**: Completed/merged (collapsed by default)
|
||||
|
||||
Clicking a notification deep-links directly to the relevant session/PR in the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Minimal Config (works out of the box)
|
||||
|
||||
```yaml
|
||||
# agent-orchestrator.yaml
|
||||
projects:
|
||||
my-app:
|
||||
repo: org/repo
|
||||
path: ~/my-app
|
||||
```
|
||||
|
||||
Everything else uses sensible defaults:
|
||||
|
||||
- Runtime: tmux
|
||||
- Agent: claude-code
|
||||
- Workspace: worktree
|
||||
- Tracker: github (inferred from repo)
|
||||
- SCM: github (inferred from repo)
|
||||
- Notifier: desktop
|
||||
- Terminal: auto-detect
|
||||
|
||||
### Full Config
|
||||
|
||||
```yaml
|
||||
# agent-orchestrator.yaml
|
||||
dataDir: ~/.agent-orchestrator # metadata storage
|
||||
worktreeDir: ~/.worktrees # workspace root
|
||||
port: 3000 # web dashboard port
|
||||
|
||||
defaults:
|
||||
runtime: tmux
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
notifiers: [desktop]
|
||||
|
||||
projects:
|
||||
my-app:
|
||||
name: My App
|
||||
repo: org/repo
|
||||
path: ~/my-app
|
||||
defaultBranch: main
|
||||
sessionPrefix: app
|
||||
|
||||
# Override defaults per project
|
||||
agent: claude-code
|
||||
runtime: tmux
|
||||
|
||||
# Issue tracker
|
||||
tracker:
|
||||
plugin: linear
|
||||
teamId: "abc-123"
|
||||
|
||||
# SCM (usually inferred from repo)
|
||||
scm:
|
||||
plugin: github
|
||||
|
||||
# Symlinks to copy into workspaces
|
||||
symlinks: [.env, .claude]
|
||||
|
||||
# Commands to run after workspace creation
|
||||
postCreate:
|
||||
- "pnpm install"
|
||||
- "claude mcp add rube --transport http https://rube.app/mcp"
|
||||
|
||||
# Agent-specific config
|
||||
agentConfig:
|
||||
permissions: skip # --dangerously-skip-permissions
|
||||
model: opus
|
||||
|
||||
# Reaction overrides
|
||||
reactions:
|
||||
approved-and-green:
|
||||
auto: true # enable auto-merge for this project
|
||||
|
||||
# Notification channels
|
||||
notifiers:
|
||||
slack:
|
||||
plugin: slack
|
||||
webhook: ${SLACK_WEBHOOK_URL}
|
||||
channel: "#agent-updates"
|
||||
desktop:
|
||||
plugin: desktop
|
||||
|
||||
# Reaction defaults (can be overridden per project)
|
||||
reactions:
|
||||
ci-failed:
|
||||
auto: true
|
||||
retries: 2
|
||||
escalate-after: 2
|
||||
changes-requested:
|
||||
auto: true
|
||||
escalate-after: 30m
|
||||
approved-and-green:
|
||||
auto: false
|
||||
agent-stuck:
|
||||
threshold: 10m
|
||||
agent-needs-input:
|
||||
priority: high
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Segment | Choice | Why |
|
||||
| ------------------- | --------------------------------------- | ---------------------------------------------------- |
|
||||
| **Core library** | TypeScript | Shared types across all packages |
|
||||
| **Web + API** | Next.js 15 (App Router) | SSR + API routes in one process |
|
||||
| **Styling** | Tailwind CSS | Dark theme, responsive |
|
||||
| **Real-time** | Server-Sent Events | One-way push, auto-reconnect, simpler than WebSocket |
|
||||
| **CLI** | TypeScript + Commander.js | Shares types with core |
|
||||
| **Config** | YAML + Zod validation | Human-readable, type-safe |
|
||||
| **State** | Flat metadata files + Event log (JSONL) | Stateless orchestrator, crash recovery |
|
||||
| **Package manager** | pnpm workspaces | Fast, monorepo-native |
|
||||
| **Distribution** | npm (`npx agent-orchestrator`) | Zero install |
|
||||
|
||||
### Why TypeScript Throughout
|
||||
|
||||
1. **One language** — Plugin authors only need TypeScript/JavaScript
|
||||
2. **Shared types** — No serialization boundaries between core, web, CLI, plugins
|
||||
3. **npm distribution** — `npx agent-orchestrator` works everywhere
|
||||
4. **Next.js** — Web + API server in one process, great DX
|
||||
5. **Largest ecosystem** — More packages on npm than any other registry
|
||||
6. **Performance is fine** — Bottleneck is AI agents, not orchestrator. We shell out to tmux/git/docker anyway.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
agent-orchestrator/
|
||||
├── package.json
|
||||
├── pnpm-workspace.yaml
|
||||
├── tsconfig.base.json
|
||||
├── agent-orchestrator.yaml.example
|
||||
│
|
||||
├── packages/
|
||||
│ ├── core/ # @aoagents/ao-core
|
||||
│ │ └── src/
|
||||
│ │ ├── types.ts # All interfaces + types
|
||||
│ │ ├── config.ts # YAML config loader + Zod validation
|
||||
│ │ ├── session-manager.ts # Session CRUD
|
||||
│ │ ├── lifecycle-manager.ts # State machine + reactions
|
||||
│ │ ├── event-bus.ts # Pub/sub + JSONL persistence
|
||||
│ │ ├── plugin-registry.ts # Plugin discovery + loading
|
||||
│ │ ├── metadata.ts # Flat-file read/write
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── cli/ # @aoagents/ao-cli → `ao` binary
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts # Commander.js setup
|
||||
│ │ └── commands/
|
||||
│ │ ├── init.ts # ao init
|
||||
│ │ ├── status.ts # ao status
|
||||
│ │ ├── spawn.ts # ao spawn <project> [issue]
|
||||
│ │ ├── batch-spawn.ts # ao batch-spawn <project> <issues...>
|
||||
│ │ ├── session.ts # ao session [ls|kill|cleanup]
|
||||
│ │ ├── send.ts # ao send <session> <message>
|
||||
│ │ ├── review-check.ts # ao review-check [project]
|
||||
│ │ ├── dashboard.ts # ao dashboard (starts web)
|
||||
│ │ └── open.ts # ao open [session|all]
|
||||
│ │
|
||||
│ ├── web/ # @aoagents/ao-web
|
||||
│ │ ├── next.config.ts
|
||||
│ │ └── src/
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── layout.tsx
|
||||
│ │ │ ├── page.tsx # Dashboard (attention-prioritized)
|
||||
│ │ │ └── sessions/[id]/
|
||||
│ │ │ └── page.tsx # Session detail
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── sessions/ # CRUD + actions
|
||||
│ │ │ ├── spawn/ # POST spawn
|
||||
│ │ │ ├── events/ # SSE stream
|
||||
│ │ │ └── health/ # Server health
|
||||
│ │ └── components/
|
||||
│ │ ├── SessionCard.tsx
|
||||
│ │ ├── AttentionZone.tsx
|
||||
│ │ ├── PRStatus.tsx
|
||||
│ │ ├── CIBadge.tsx
|
||||
│ │ └── Terminal.tsx # xterm.js
|
||||
│ │
|
||||
│ └── plugins/ # Built-in plugins
|
||||
│ ├── runtime-tmux/
|
||||
│ ├── runtime-process/
|
||||
│ ├── runtime-docker/
|
||||
│ ├── agent-claude-code/
|
||||
│ ├── agent-codex/
|
||||
│ ├── agent-aider/
|
||||
│ ├── workspace-worktree/
|
||||
│ ├── workspace-clone/
|
||||
│ ├── tracker-github/
|
||||
│ ├── tracker-linear/
|
||||
│ ├── scm-github/
|
||||
│ ├── notifier-desktop/
|
||||
│ ├── notifier-slack/
|
||||
│ ├── terminal-iterm2/
|
||||
│ └── terminal-web/
|
||||
│
|
||||
├── artifacts/ # Research + design docs
|
||||
│ ├── competitive-research.md
|
||||
│ └── architecture-design.md
|
||||
│
|
||||
├── scripts/ # Original bash scripts (reference)
|
||||
│
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (Dog-food ready)
|
||||
|
||||
- Monorepo scaffolding
|
||||
- Core types + interfaces
|
||||
- Config loader
|
||||
- Session manager + lifecycle manager + event bus
|
||||
- tmux runtime, claude-code agent, worktree workspace
|
||||
- GitHub SCM (PR/CI/review tracking)
|
||||
- GitHub tracker
|
||||
- Desktop notifier
|
||||
- CLI (init, status, spawn, session, send, dashboard)
|
||||
- Web dashboard with attention-prioritized view
|
||||
- SSE real-time updates
|
||||
- Reaction engine (CI failed, changes requested, agent stuck)
|
||||
|
||||
### Phase 2: Multi-Runtime + More Plugins
|
||||
|
||||
- Process runtime (headless claude -p)
|
||||
- Docker runtime
|
||||
- Codex + Aider agent adapters
|
||||
- Linear + Jira trackers
|
||||
- Slack notifier
|
||||
- Web terminal (xterm.js)
|
||||
|
||||
### Phase 3: Cloud + Scale
|
||||
|
||||
- Kubernetes runtime
|
||||
- E2B / Fly.io runtimes
|
||||
- Cost tracking
|
||||
- Webhook-triggered spawning
|
||||
|
||||
### Phase 4: Team + Enterprise
|
||||
|
||||
- Dashboard auth
|
||||
- Role-based access
|
||||
- Remote session support
|
||||
- Audit log
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
# Competitive Research — Agent Orchestration Tools
|
||||
|
||||
_Compiled: 2026-02-13_
|
||||
|
||||
## Overview
|
||||
|
||||
Research into 16+ projects that orchestrate AI coding agents. The goal: understand abstractions, architectures, and gaps to build the best, most extensible agent orchestrator.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: Direct Competitors (Multi-Agent Orchestrators)
|
||||
|
||||
### Gas Town (Steve Yegge)
|
||||
|
||||
- **GitHub**: https://github.com/steveyegge/gastown
|
||||
- **Stack**: Go 1.23+ (~189K LOC), SQLite3, Git 2.25+, tmux 3.0+
|
||||
- **Stars**: Growing rapidly (released Jan 2026)
|
||||
|
||||
**Architecture — MEOW Stack (Molecular Expression of Work):**
|
||||
|
||||
| Layer | What | How |
|
||||
| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| **Beads** | Atomic work units | JSONL files tracked in Git. IDs like `gt-abc12`. Universal data/control plane. |
|
||||
| **Epics** | Hierarchical collections | Organize beads into tree structures for parallel/sequential execution |
|
||||
| **Molecules** | Workflow graphs | Sequenced beads with dependencies, gates, loops |
|
||||
| **Protomolecules & Formulas** | Reusable templates | TOML format workflow definitions |
|
||||
|
||||
**Agent Roles (7 roles, 2 scopes):**
|
||||
|
||||
| Role | Scope | Purpose |
|
||||
| ------------ | ----- | --------------------------------------------------------- |
|
||||
| **Mayor** | Town | Chief AI coordinator with full workspace context |
|
||||
| **Deacon** | Town | Health daemon running patrol loops |
|
||||
| **Dogs** | Town | Maintenance helpers |
|
||||
| **Crew** | Rig | Named, persistent agents for sustained design/review work |
|
||||
| **Polecats** | Rig | Ephemeral "cattle" workers spawned for specific tasks |
|
||||
| **Refinery** | Rig | Merge queue manager handling conflicts |
|
||||
| **Witness** | Rig | Supervises polecats, unblocks stuck work |
|
||||
|
||||
**Other Abstractions:**
|
||||
|
||||
- **Town** — Workspace directory (`~/gt/`) housing all projects
|
||||
- **Rigs** — Project containers wrapping git repositories
|
||||
- **Hooks** — Git worktree-based persistent storage surviving crashes
|
||||
- **Convoys** — Work-tracking bundles grouping multiple beads for an agent
|
||||
- **GUPP** — Agents must execute work on their hooks; scheduling persists across restarts
|
||||
|
||||
**Runtime Backends:** claude, gemini, codex, cursor, auggie, amp (per-rig config)
|
||||
|
||||
**Communication/Isolation:**
|
||||
|
||||
- Git worktrees for filesystem isolation per agent
|
||||
- Beads/Hooks for coordination (external state, not shared context windows)
|
||||
- GUPP: deterministic handoffs through version control, not LLM-judged phase gates
|
||||
|
||||
**Strengths:** Most architecturally ambitious. Crash recovery via git-backed Beads. Role-based agent hierarchy. Multi-agent support.
|
||||
**Weaknesses:** ~$100/hr token burn, auto-merged failing tests, agents causing unexpected deletions. Go-only ecosystem. No web dashboard. Optimized for autonomous, not human-in-the-loop.
|
||||
|
||||
---
|
||||
|
||||
### Par (Coplane)
|
||||
|
||||
- **GitHub**: https://github.com/coplane/par
|
||||
- **Stack**: Python 3.12+
|
||||
- **Closest to our current approach**
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Sessions**: Single-repo isolated branches via git worktrees + tmux sessions
|
||||
- **Workspaces**: Multi-repo synchronized development contexts
|
||||
- **Control Center**: Unified tmux session with windows for each context
|
||||
- **Labels**: Globally unique, human-readable names
|
||||
|
||||
**Features:**
|
||||
|
||||
- `par start my-feature` — creates worktree + branch + tmux session
|
||||
- `par send <label> "<command>"` — execute commands in specific sessions remotely
|
||||
- `par send all "<command>"` — broadcast to all sessions
|
||||
- `par control-center` — unified navigation
|
||||
- `.par.yaml` — automatic worktree initialization (copy .env, install deps, etc.)
|
||||
- IDE integration via auto-generated `.code-workspace` files
|
||||
|
||||
**Strengths:** Simple, clean CLI. Very similar spirit to our system. Global-first access.
|
||||
**Weaknesses:** Single runtime (tmux only). No web dashboard. No plugin system. No PR/CI tracking. No agent abstraction.
|
||||
|
||||
---
|
||||
|
||||
### CAO — CLI Agent Orchestrator (AWS Labs)
|
||||
|
||||
- **GitHub**: https://github.com/awslabs/cli-agent-orchestrator
|
||||
- **Stack**: Python, tmux, HTTP server (localhost:9889)
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Supervisor + Workers**: Hierarchical model with three coordination patterns:
|
||||
- **Handoff**: Synchronous task transfer
|
||||
- **Assign**: Asynchronous spawning with callback
|
||||
- **Send Message**: Direct communication to agent inboxes
|
||||
- **Session Isolation**: Agents in separate tmux windows with unique `CAO_TERMINAL_ID`
|
||||
- **Flows**: Cron-based scheduled agent execution
|
||||
|
||||
**Supported Agents:** Amazon Q CLI (default), Kiro CLI, Codex CLI, Claude Code
|
||||
|
||||
**Strengths:** Clean supervisor/worker hierarchy. AWS backing.
|
||||
**Weaknesses:** AWS-centric. Limited ecosystem.
|
||||
|
||||
---
|
||||
|
||||
### ccswarm (nwiizo)
|
||||
|
||||
- **GitHub**: https://github.com/nwiizo/ccswarm
|
||||
- **Stack**: Rust (2024 edition), ratatui TUI, Tokio async, OpenTelemetry
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **ProactiveMaster**: Orchestration core with zero shared state (message-passing channels)
|
||||
- **Specialized Agent Pools**: Frontend, Backend, DevOps, QA
|
||||
- **Multi-Provider Layer**: Claude Code, Aider, OpenAI Codex, custom tools
|
||||
- **Session-Persistent Manager**: Claims 93% token reduction
|
||||
|
||||
**Isolation:** Git worktrees per agent. Native PTY sessions (no tmux dependency).
|
||||
|
||||
**Strengths:** Rust performance. No tmux dependency. Good provider abstraction.
|
||||
**Weaknesses:** Partially implemented (orchestrator loop WIP as of v0.4.0).
|
||||
|
||||
---
|
||||
|
||||
### agent-team (nekocode)
|
||||
|
||||
- **GitHub**: https://github.com/nekocode/agent-team
|
||||
- **Stack**: Rust 92.8%, npm distribution
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Agent Client Protocol (ACP)**: Standardized interface across all agents
|
||||
- **Process Isolation**: Each agent in its own process with UDS socket
|
||||
- **Remote Access**: Interact with any agent from any terminal
|
||||
|
||||
**Supported Agents:** 20+: Gemini, Copilot, Claude, Goose, Cline, Blackbox, OpenHands, Qwen, Kimi, and more.
|
||||
|
||||
**Strengths:** Broadest agent support. Clean protocol.
|
||||
**Weaknesses:** Thin orchestration. No lifecycle management. No PR/CI tracking.
|
||||
|
||||
---
|
||||
|
||||
### claude-flow (ruvnet)
|
||||
|
||||
- **GitHub**: https://github.com/ruvnet/claude-flow
|
||||
- **Stack**: TypeScript, Node.js 20+, WebAssembly, SQLite, PostgreSQL
|
||||
- **Claims**: 100K+ monthly active users, 84.8% SWE-Bench solve rate
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Swarm Topologies**: mesh, hierarchical, ring, star configurations
|
||||
- **Queen-Led Hierarchies**: Strategic Queens (planning), Tactical Queens (execution), Adaptive Queens (optimization)
|
||||
- **8 Worker Types**: researcher, coder, analyst, tester, architect, reviewer, optimizer, documenter
|
||||
- **60+ Specialized Agents** across 8 categories
|
||||
- **31+ MCP Tools** across 7 categories
|
||||
- **Shared Memory**: LRU cache with SQLite persistence (WAL mode)
|
||||
- **ReasoningBank**: Pattern storage with trajectory learning
|
||||
- **Consensus Mechanisms**: Raft, Byzantine, Gossip, Weighted, Majority
|
||||
|
||||
**Extension System:**
|
||||
|
||||
- 17 integration hooks (pre-task, post-task, etc.)
|
||||
- Custom workers (12 context-triggered background services)
|
||||
- Plugin SDK with IPFS marketplace distribution
|
||||
- Native MCP integration
|
||||
|
||||
**Strengths:** TypeScript. Feature-rich. MCP native.
|
||||
**Weaknesses:** Claude-only. Overcomplicated. Questionable claims.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Adjacent Tools (Single-Agent or Cloud-First)
|
||||
|
||||
### OpenHands (formerly OpenDevin)
|
||||
|
||||
- **GitHub**: https://github.com/OpenHands/OpenHands (67.8K stars)
|
||||
- **Stack**: Python 75.5%, TypeScript/React 22.3%, Docker, Kubernetes
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Software Agent SDK**: Composable Python library
|
||||
- **Runtime/Sandbox**: Docker-based sandboxed execution environments
|
||||
- **Event Stream Architecture**: Event-driven communication between backend and frontend
|
||||
- **ACI (Agent Computer Interface)**: Standardized tools for agent-computer interaction
|
||||
|
||||
**Runtime Backends:** Docker (default), Kubernetes, E2B (cloud)
|
||||
|
||||
**Deployment Options:** Local CLI, Desktop GUI, Cloud hosting, Enterprise K8s
|
||||
|
||||
**Strengths:** Most mature cloud story. Event-sourced architecture (enables replay/audit). 67K stars.
|
||||
**Weaknesses:** Heavy (Docker required). Not optimized for human-in-the-loop. Single-task runs, not parallel session management.
|
||||
|
||||
---
|
||||
|
||||
### SWE-agent + SWE-ReX (Princeton NLP)
|
||||
|
||||
- **GitHub**: https://github.com/SWE-agent/SWE-agent + https://github.com/SWE-agent/SWE-ReX
|
||||
- **Stack**: Python 94.6%
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **SWEEnv**: Environment manager (thin wrapper around SWE-ReX)
|
||||
- **Agent**: Configured via single YAML file
|
||||
- **ACI (Agent-Computer Interface)**: Custom tools installed in container
|
||||
- **Deployment**: Abstraction over execution targets
|
||||
|
||||
**Runtime Backends (SWE-ReX):**
|
||||
|
||||
- Local Docker containers
|
||||
- Modal (serverless compute)
|
||||
- AWS Fargate (container orchestration)
|
||||
- AWS EC2 (remote machines)
|
||||
- Daytona (WIP)
|
||||
|
||||
Agent code remains the same regardless of deployment target.
|
||||
|
||||
**Strengths:** Cleanest deployment abstraction. Research-backed. Massively parallel (30+ instances).
|
||||
**Weaknesses:** Research-focused, not production orchestrator.
|
||||
|
||||
---
|
||||
|
||||
### Goose (Block/Square)
|
||||
|
||||
- **GitHub**: https://github.com/block/goose
|
||||
- **Stack**: Rust 58.9%, TypeScript 33.0%, Go (temporal scheduler)
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Crate architecture**: goose (core), goose-cli, goose-server, goose-mcp, mcp-client, mcp-core
|
||||
- **Sessions**: Stateful autonomous execution environments
|
||||
- **Recipes**: Task automation workflows
|
||||
- **Extensions**: MCP-based capability providers (1,700+ available)
|
||||
- **Custom Distributions**: Preconfigured providers, extensions, and branding
|
||||
|
||||
**Strengths:** Rust core. MCP-native. 1,700+ extensions. Professional engineering.
|
||||
**Weaknesses:** Single-agent tool. No multi-agent orchestration.
|
||||
|
||||
---
|
||||
|
||||
### Cline
|
||||
|
||||
- **GitHub**: https://github.com/cline/cline
|
||||
- **Stack**: TypeScript, Node.js, esbuild
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Sequential Decision Loop**: Analysis → Planning → Execution → Monitoring → Iteration
|
||||
- **Checkpoint System**: Workspace snapshots at each step for compare/restore
|
||||
- **Context Attachments**: @file, @folder, @url, @problems
|
||||
|
||||
**Strengths:** Great human-in-the-loop UX. Checkpoint/restore. Multi-provider.
|
||||
**Weaknesses:** VS Code only. Single-agent.
|
||||
|
||||
---
|
||||
|
||||
### Multi-Agent Coding System (Danau5tin)
|
||||
|
||||
- **GitHub**: https://github.com/Danau5tin/multi-agent-coding-system
|
||||
- **Stack**: Python, LiteLLM/OpenRouter, Docker
|
||||
- **Reached #13 on Stanford's TerminalBench**
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Orchestrator Agent**: Strategic coordinator; never touches code
|
||||
- **Explorer Agent**: Read-only investigation specialist
|
||||
- **Coder Agent**: Implementation specialist with write access
|
||||
- **Context Store**: Persistent knowledge layer across interactions
|
||||
- **Knowledge Artifacts**: Discrete, reusable context items
|
||||
|
||||
**Communication:** XML tags with YAML parameters for task creation/delegation.
|
||||
|
||||
**Key Innovation:** "Front-loading precision" — over-providing context vs. rapid iteration.
|
||||
|
||||
**Strengths:** Clean role separation. Context Store innovation.
|
||||
**Weaknesses:** Small project. Not production-ready.
|
||||
|
||||
---
|
||||
|
||||
### CCPM (Automaze)
|
||||
|
||||
- **GitHub**: https://github.com/automazeio/ccpm
|
||||
- **Stack**: Python, GitHub REST API, Claude Code
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **5-Phase Workflow**: Brainstorm → Document → Plan → Decompose → Execute
|
||||
- **GitHub Issues as Database**: Issues store specs, comments provide audit trail
|
||||
- **Epic Worktrees**: Each epic spawns a dedicated worktree
|
||||
- **Parallel Agent Execution**: Tasks marked `parallel: true` run concurrently
|
||||
|
||||
---
|
||||
|
||||
### AI-Agents-Orchestrator (hoangsonww)
|
||||
|
||||
- **GitHub**: https://github.com/hoangsonww/AI-Agents-Orchestrator
|
||||
- **Stack**: Python (Flask + Socket.IO), Vue 3 + Vite, Docker/Kubernetes
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Workflow Presets**: Default (Codex→Gemini→Claude), Quick, Thorough, Review-Only, Document
|
||||
- **AI Adapters**: Standardized interfaces per agent tool
|
||||
- **Session Manager**: Context across workflow steps
|
||||
- **Vue Dashboard**: Real-time Socket.IO with Monaco editor
|
||||
|
||||
---
|
||||
|
||||
### wshobson/agents
|
||||
|
||||
- **GitHub**: https://github.com/wshobson/agents
|
||||
- **Stack**: Claude Code plugin ecosystem
|
||||
|
||||
**Key Abstractions:**
|
||||
|
||||
- **Plugins**: 73 plugins, 112 agents, 146 skills, 79 tools
|
||||
- **Progressive Disclosure Skills**: 3-tier knowledge
|
||||
- **16 Workflow Orchestrators**: review, debug, feature, fullstack, research, security, migration
|
||||
- **4-Tier Model Strategy**: Opus (critical) → Inherit → Sonnet → Haiku
|
||||
- **Conductor Plugin**: Context → Spec & Plan → Implement
|
||||
|
||||
---
|
||||
|
||||
## Runtime Backend Research
|
||||
|
||||
### Cloud Sandbox Platforms
|
||||
|
||||
| Platform | Startup Time | Isolation | API Style | Cost |
|
||||
| ------------------- | ------------ | -------------------- | -------------------- | ----------- |
|
||||
| **Docker (local)** | ~1-5s | Container namespace | Docker CLI/API | Free |
|
||||
| **E2B** | ~200-400ms | Firecracker microVMs | Python/JS SDK | Pay-per-use |
|
||||
| **Daytona** | ~27-90ms | OCI containers | Python/TS SDK + REST | Open source |
|
||||
| **Modal Sandboxes** | Sub-second | gVisor containers | Python SDK | $0.03/hr |
|
||||
| **Fly.io Machines** | ~200ms-1s | Firecracker microVMs | REST API | $0.02/hr |
|
||||
|
||||
### Agent-Sandbox Connection Patterns (per LangChain)
|
||||
|
||||
**Pattern 1: Agent IN Sandbox**
|
||||
|
||||
- Agent runs inside the container/VM
|
||||
- Communicates outward via HTTP/WebSocket
|
||||
- Pro: Direct filesystem access, mirrors local dev
|
||||
- Con: API keys inside sandbox
|
||||
|
||||
**Pattern 2: Sandbox AS Tool**
|
||||
|
||||
- Agent runs on orchestrator/server
|
||||
- Calls sandbox via SDK/API for code execution
|
||||
- Pro: API keys secure, parallel execution
|
||||
- Con: Network latency per call
|
||||
|
||||
### Communication Protocols
|
||||
|
||||
| Protocol | Use Case | Used By |
|
||||
| -------------------- | ----------------------- | -------------------------------- |
|
||||
| **REST API** | Request/response | OpenHands, Fly.io, Daytona |
|
||||
| **WebSocket** | Bidirectional streaming | OpenHands, Claude Agent SDK |
|
||||
| **stdio/subprocess** | Child process | Claude Agent SDK, Codex CLI, MCP |
|
||||
| **tmux send-keys** | Terminal injection | Our orchestrator, Par, CAO |
|
||||
| **SSE** | Server → client push | MCP remote transport |
|
||||
|
||||
### Heartbeat / Health Detection
|
||||
|
||||
| Pattern | Description | Used By |
|
||||
| ------------------------ | ------------------------------------ | --------------------------- |
|
||||
| **WebSocket ping/pong** | Periodic heartbeats | OpenHands |
|
||||
| **Process polling** | Check PID alive | Claude Agent SDK |
|
||||
| **tmux capture-pane** | Scrape terminal output | Our `claude-session-status` |
|
||||
| **File-based signaling** | Status to shared filesystem | Our metadata files |
|
||||
| **HTTP health endpoint** | `/health` or `/status` | OpenHands server |
|
||||
| **JSONL mtime** | Check session file modification time | Our `claude-status` |
|
||||
|
||||
---
|
||||
|
||||
## Key Findings & Gaps
|
||||
|
||||
### What Everyone Does
|
||||
|
||||
1. **Git worktrees** = standard isolation primitive
|
||||
2. **tmux** = dominant session manager for local
|
||||
3. **External state > context windows** (Beads, Context Store, GitHub Issues)
|
||||
4. **MCP** = emerging extension protocol
|
||||
|
||||
### What Nobody Does Well (Our Opportunity)
|
||||
|
||||
1. **Multiple runtime backends** (tmux + Docker + cloud) with same interface
|
||||
2. **Multiple agent support** with proper abstraction
|
||||
3. **Human-in-the-loop optimization** (our core differentiator — everyone else optimizes for autonomous)
|
||||
4. **Works out of the box** with zero setup
|
||||
5. **Truly extensible plugin architecture** for all concerns
|
||||
6. **Beautiful web dashboard** with real-time PR/CI/review tracking
|
||||
7. **Full PR lifecycle management** (CI checks, review comments, merge readiness, auto-reactions)
|
||||
|
||||
### Best Ideas to Steal
|
||||
|
||||
- **Gas Town**: Git-backed state (Beads), role-based agents, crash recovery
|
||||
- **OpenHands**: Event-sourced architecture, Docker/K8s runtime abstraction
|
||||
- **SWE-ReX**: Clean deployment backend interface (`swe-rex[modal]`, `swe-rex[fargate]`)
|
||||
- **Par**: Simple `.par.yaml` config, global labels, broadcast to all
|
||||
- **Goose**: MCP-based extensions, Rust crate architecture
|
||||
- **Cline**: Checkpoint/restore system
|
||||
- **Multi-Agent Coder**: Context Store, front-loading precision
|
||||
- **agent-team**: Agent Client Protocol for 20+ agents
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Gas Town](https://github.com/steveyegge/gastown)
|
||||
- [Gas Town Architecture Analysis](https://reading.torqsoftware.com/notes/software/ai-ml/agentic-coding/2026-01-15-gas-town-multi-agent-orchestration-framework/)
|
||||
- [Gas Town: Two Kinds of Multi-Agent](https://paddo.dev/blog/gastown-two-kinds-of-multi-agent/)
|
||||
- [Par](https://github.com/coplane/par)
|
||||
- [CAO](https://github.com/awslabs/cli-agent-orchestrator)
|
||||
- [ccswarm](https://github.com/nwiizo/ccswarm)
|
||||
- [agent-team](https://github.com/nekocode/agent-team)
|
||||
- [claude-flow](https://github.com/ruvnet/claude-flow)
|
||||
- [OpenHands](https://github.com/OpenHands/OpenHands)
|
||||
- [SWE-agent](https://github.com/SWE-agent/SWE-agent)
|
||||
- [SWE-ReX](https://github.com/SWE-agent/SWE-ReX)
|
||||
- [Goose](https://github.com/block/goose)
|
||||
- [Cline](https://github.com/cline/cline)
|
||||
- [Multi-Agent Coding System](https://github.com/Danau5tin/multi-agent-coding-system)
|
||||
- [CCPM](https://github.com/automazeio/ccpm)
|
||||
- [AI-Agents-Orchestrator](https://github.com/hoangsonww/AI-Agents-Orchestrator)
|
||||
- [wshobson/agents](https://github.com/wshobson/agents)
|
||||
- [LangChain: Two Agent-Sandbox Patterns](https://blog.langchain.com/the-two-patterns-by-which-agents-connect-sandboxes/)
|
||||
- [Modal: Top Code Sandbox Products](https://modal.com/blog/top-code-agent-sandbox-products)
|
||||
- [Rise of Coding Agent Orchestrators](https://www.aviator.co/blog/the-rise-of-coding-agent-orchestrators/)
|
||||
- [E2B](https://e2b.dev/)
|
||||
- [Daytona](https://www.daytona.io/)
|
||||
- [Fly.io AI](https://fly.io/ai)
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
# Implementation Plan — Parallel Agent Work Breakdown
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Phase 0: Foundation │ (sequential, orchestrator does this)
|
||||
│ │
|
||||
│ 1. Monorepo scaffold │
|
||||
│ 2. types.ts │
|
||||
│ 3. config.ts + Zod │
|
||||
│ 4. plugin-registry │
|
||||
│ 5. All package.json │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
All Phase 1 agents work against the interfaces defined in types.ts
|
||||
│
|
||||
┌──────────┬──────────┬───┴────┬──────────┬──────────┬──────────┐
|
||||
▼ ▼ ▼ ▼ ▼ ▼ ▼
|
||||
Agent 1 Agent 2 Agent 3 Agent 4 Agent 5 Agent 6 Agent 7
|
||||
Core Runtime Agent SCM + CLI Web Notifier
|
||||
Services Plugins Plugins Tracker Dashboard + Terminal
|
||||
│ │
|
||||
│ All plugins are independent of │
|
||||
│ each other — pure interface impls │
|
||||
│ │
|
||||
└──────── CLI + Web depend on core services ─────────┘
|
||||
(can code against interfaces, wire up later)
|
||||
```
|
||||
|
||||
## Phase 0: Foundation (Sequential — Orchestrator Does This)
|
||||
|
||||
**Must be done first. Everything else depends on it.**
|
||||
|
||||
Creates the monorepo scaffold and ALL type definitions. After this, every agent has:
|
||||
|
||||
- A package to work in (with package.json, tsconfig)
|
||||
- All interfaces defined (they just implement them)
|
||||
- No ambiguity about what to build
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. `pnpm-workspace.yaml` + root `package.json` + `tsconfig.base.json`
|
||||
2. `packages/core/src/types.ts` — ALL interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Session, Event, Config, etc.)
|
||||
3. `packages/core/src/config.ts` — Zod schemas for YAML config validation
|
||||
4. `packages/core/src/plugin-registry.ts` — Plugin discovery + loading skeleton
|
||||
5. `packages/core/package.json` + `tsconfig.json`
|
||||
6. All plugin package scaffolds (package.json + tsconfig + src/index.ts stub)
|
||||
7. `packages/cli/package.json` + `packages/web/package.json` scaffolds
|
||||
8. `agent-orchestrator.yaml.example`
|
||||
|
||||
**Estimated effort**: Medium. ~500-800 lines of types + config.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Parallel Implementation (7 Agents)
|
||||
|
||||
### Agent 1: Core Services
|
||||
|
||||
**Package**: `packages/core/src/`
|
||||
**Branch**: `feat/core-services`
|
||||
**Depends on**: Phase 0 types
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| File | What | Reference Script |
|
||||
| ---------------------- | ---------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `metadata.ts` | Flat-file metadata read/write (key=value) | Metadata parsing in all session managers |
|
||||
| `event-bus.ts` | In-process pub/sub + JSONL persistence | New (inspired by OpenHands event stream) |
|
||||
| `tmux.ts` | tmux command wrappers (list, new, send-keys, capture-pane, kill) | All scripts that call tmux |
|
||||
| `session-manager.ts` | Session CRUD: spawn, list, kill, cleanup, send message | `claude-ao-session` |
|
||||
| `lifecycle-manager.ts` | State machine per session + reaction engine | `claude-review-check` + `claude-session-status` |
|
||||
|
||||
**Key complexity**: session-manager.ts orchestrates Runtime + Agent + Workspace plugins together. lifecycle-manager.ts runs the polling loop and triggers reactions.
|
||||
|
||||
**Estimated effort**: Large (~1000-1500 lines)
|
||||
|
||||
---
|
||||
|
||||
### Agent 2: Runtime + Workspace Plugins
|
||||
|
||||
**Packages**: `packages/plugins/runtime-tmux/`, `runtime-process/`, `workspace-worktree/`, `workspace-clone/`
|
||||
**Branch**: `feat/runtime-workspace-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
| -------------------- | ------------------------------------------------------------------ | ---------------------------------- |
|
||||
| `runtime-tmux` | Create/destroy tmux sessions, send-keys, capture-pane, alive check | `claude-ao-session` new/kill |
|
||||
| `runtime-process` | Spawn child processes, stdin/stdout, signal handling | New (for headless `claude -p`) |
|
||||
| `workspace-worktree` | `git worktree add/remove/list`, branch naming, symlinks | `claude-ao-session` worktree logic |
|
||||
| `workspace-clone` | `git clone`, cleanup | New (for Docker/cloud runtimes) |
|
||||
|
||||
**Key complexity**: runtime-tmux must handle send-keys with proper escaping, busy detection, and the wait-for-idle pattern from `send-to-session`.
|
||||
|
||||
**Estimated effort**: Medium (~600-800 lines)
|
||||
|
||||
---
|
||||
|
||||
### Agent 3: Agent Plugins
|
||||
|
||||
**Packages**: `packages/plugins/agent-claude-code/`, `agent-codex/`, `agent-aider/`
|
||||
**Branch**: `feat/agent-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
| ------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `agent-claude-code` | Launch cmd, JSONL activity detection, process tree walk, introspection | `claude-status`, `get-claude-session-info`, `claude-session-status` |
|
||||
| `agent-codex` | Launch cmd, process detection | New |
|
||||
| `agent-aider` | Launch cmd, process detection | New |
|
||||
|
||||
**Key complexity**: `agent-claude-code` has the richest activity detection — reading JSONL session files, extracting summaries, walking process trees from tmux pane PID to find `claude` process, detecting working/idle/stuck/blocked states.
|
||||
|
||||
**Estimated effort**: Medium (~500-700 lines)
|
||||
|
||||
---
|
||||
|
||||
### Agent 4: SCM + Tracker Plugins
|
||||
|
||||
**Packages**: `packages/plugins/scm-github/`, `tracker-github/`, `tracker-linear/`
|
||||
**Branch**: `feat/scm-tracker-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
| ---------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `scm-github` | PR detection, CI checks, review comments, automated comments, merge readiness, merge | `claude-review-check`, `claude-bugbot-fix`, dashboard PR fetching |
|
||||
| `tracker-github` | Issue fetch, completion check, branch naming, prompt generation | `claude-splitly-session` (GitHub Issues) |
|
||||
| `tracker-linear` | Issue fetch via GraphQL, completion check, branch naming | `claude-ao-session` + `claude-integrator-session` Linear checks |
|
||||
|
||||
**Key complexity**: `scm-github` is the largest — it covers PR state, CI checks (gh pr checks), review decision (gh pr view), inline review comments (gh api), automated bot comments (cursor[bot], bugbot), and merge readiness.
|
||||
|
||||
**Estimated effort**: Large (~800-1000 lines)
|
||||
|
||||
---
|
||||
|
||||
### Agent 5: CLI
|
||||
|
||||
**Package**: `packages/cli/`
|
||||
**Branch**: `feat/cli`
|
||||
**Depends on**: Phase 0 types + core interfaces (codes against interfaces, wires up when core is ready)
|
||||
**Partially blocked by**: Agent 1 (core services) for runtime testing
|
||||
|
||||
| Command | What | Reference Script |
|
||||
| -------------------------------------- | ---------------------------------------------------- | ----------------------------------- |
|
||||
| `ao init` | Interactive setup wizard → `agent-orchestrator.yaml` | New |
|
||||
| `ao status` | Colored terminal table of all sessions | `claude-status` |
|
||||
| `ao spawn <project> [issue]` | Spawn single session | `claude-spawn` |
|
||||
| `ao batch-spawn <project> <issues...>` | Batch spawn with dedup | `claude-batch-spawn` |
|
||||
| `ao session ls\|kill\|cleanup` | Session management | `claude-ao-session` ls/kill/cleanup |
|
||||
| `ao send <session> <message>` | Smart message delivery | `send-to-session` |
|
||||
| `ao review-check [project]` | Trigger PR review fixes | `claude-review-check` |
|
||||
| `ao dashboard` | Start web server | `claude-dashboard` |
|
||||
| `ao open [session\|all]` | Open terminal tabs | `claude-open-all`, `open-iterm-tab` |
|
||||
|
||||
**Key complexity**: `ao status` needs rich terminal output (colors, columns, live data). `ao batch-spawn` needs duplicate detection.
|
||||
|
||||
**Can start immediately** by coding against core interfaces. Wire up real implementations when Agent 1 finishes.
|
||||
|
||||
**Estimated effort**: Large (~800-1200 lines)
|
||||
|
||||
---
|
||||
|
||||
### Agent 6: Web Dashboard
|
||||
|
||||
**Package**: `packages/web/`
|
||||
**Branch**: `feat/web-dashboard`
|
||||
**Depends on**: Phase 0 types + core interfaces
|
||||
**Partially blocked by**: Agent 1 (core services) for API routes
|
||||
|
||||
| Component | What | Reference |
|
||||
| ----------------------------- | --------------------------------------------- | -------------------------------- |
|
||||
| Next.js setup | App Router, Tailwind, dark theme | New |
|
||||
| `GET /api/sessions` | List all sessions with full state | `claude-dashboard` /api/sessions |
|
||||
| `POST /api/spawn` | Spawn new session | New |
|
||||
| `POST /api/sessions/:id/send` | Send message to session | New |
|
||||
| `POST /api/sessions/:id/kill` | Kill session | New |
|
||||
| `POST /api/prs/:id/merge` | Merge PR | New |
|
||||
| `GET /api/events` | SSE stream for real-time updates | New (replaces polling) |
|
||||
| Dashboard page | Attention-prioritized session cards | `claude-dashboard` HTML |
|
||||
| Session detail page | Full session info + terminal embed | New |
|
||||
| Components | SessionCard, PRStatus, CIBadge, AttentionZone | `claude-dashboard` HTML |
|
||||
|
||||
**Key complexity**: SSE endpoint that streams lifecycle events in real-time. Attention-zone layout. xterm.js terminal embed.
|
||||
|
||||
**Can start immediately** with mock data, wire up real API when Agent 1 finishes.
|
||||
|
||||
**Estimated effort**: Large (~1500-2000 lines)
|
||||
|
||||
---
|
||||
|
||||
### Agent 7: Notifier + Terminal Plugins
|
||||
|
||||
**Packages**: `packages/plugins/notifier-desktop/`, `notifier-slack/`, `notifier-webhook/`, `terminal-iterm2/`, `terminal-web/`
|
||||
**Branch**: `feat/notifier-terminal-plugins`
|
||||
**Depends on**: Phase 0 types only
|
||||
**Blocked by**: Nothing after Phase 0
|
||||
|
||||
| Plugin | What | Reference |
|
||||
| ------------------ | ---------------------------------------------------------------- | ----------------------------------- |
|
||||
| `notifier-desktop` | OS notifications (node-notifier), click → deep link to dashboard | `notify-session` |
|
||||
| `notifier-slack` | Slack webhook messages with action buttons | New |
|
||||
| `notifier-webhook` | Generic HTTP POST | New |
|
||||
| `terminal-iterm2` | AppleScript tab management, reuse existing tabs | `open-iterm-tab`, `claude-open-all` |
|
||||
| `terminal-web` | xterm.js config for web-based terminal | New |
|
||||
|
||||
**Key complexity**: `notifier-desktop` needs to be cross-platform (macOS/Linux/Windows). `terminal-iterm2` has AppleScript quirks (string length limits, tab detection).
|
||||
|
||||
**Estimated effort**: Medium (~500-700 lines)
|
||||
|
||||
---
|
||||
|
||||
## Parallelism Summary
|
||||
|
||||
```
|
||||
Time ──────────────────────────────────────────────────►
|
||||
|
||||
Phase 0 (orchestrator):
|
||||
████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
||||
|
||||
Phase 1 (7 parallel agents):
|
||||
Agent 1 (core): ████████████████████████░░
|
||||
Agent 2 (runtime): ██████████████░░░░░░░░░░░░
|
||||
Agent 3 (agent): ██████████████░░░░░░░░░░░░
|
||||
Agent 4 (scm): ████████████████████░░░░░░
|
||||
Agent 5 (cli): ████████████████████████░░ ← can start on interfaces, wire later
|
||||
Agent 6 (web): ██████████████████████████ ← can start on UI, wire later
|
||||
Agent 7 (notifier): ██████████░░░░░░░░░░░░░░░░
|
||||
|
||||
Phase 2 (integration): after all agents done
|
||||
████ ← wire everything together, test
|
||||
```
|
||||
|
||||
### True Independence
|
||||
|
||||
These agents are **truly independent** after Phase 0:
|
||||
|
||||
- Agents 2, 3, 4, 7 implement plugin interfaces → zero inter-dependency
|
||||
- Agent 1 (core) is the critical path
|
||||
- Agents 5, 6 can start with mock/interface-only imports, wire later
|
||||
|
||||
### Risk: Agent 1 (Core) Is the Bottleneck
|
||||
|
||||
If core services are delayed, CLI and Web can't fully test. Mitigations:
|
||||
|
||||
- Agent 1 gets the most experienced agent
|
||||
- Phase 0 writes enough core scaffolding (types, config, plugin-registry) that other agents aren't waiting
|
||||
- CLI and Web start with mock implementations
|
||||
|
||||
---
|
||||
|
||||
## Linear Tickets (for spawning)
|
||||
|
||||
| Ticket | Title | Agent |
|
||||
| ------ | ------------------------------------------------------------------------------------- | ----- |
|
||||
| AO-10 | Implement core services (metadata, event-bus, session-manager, lifecycle-manager) | 1 |
|
||||
| AO-11 | Implement runtime + workspace plugins (tmux, process, worktree, clone) | 2 |
|
||||
| AO-12 | Implement agent plugins (claude-code, codex, aider) | 3 |
|
||||
| AO-13 | Implement SCM + tracker plugins (github SCM, github tracker, linear tracker) | 4 |
|
||||
| AO-14 | Implement CLI (ao init, status, spawn, session, send, review-check, dashboard, open) | 5 |
|
||||
| AO-15 | Implement web dashboard (Next.js, API routes, SSE, attention-zone UI, session detail) | 6 |
|
||||
| AO-16 | Implement notifier + terminal plugins (desktop, slack, webhook, iterm2, web) | 7 |
|
||||
|
||||
## Spawning Command
|
||||
|
||||
After Phase 0 is committed to `main`:
|
||||
|
||||
```bash
|
||||
~/claude-batch-spawn ao AO-10 AO-11 AO-12 AO-13 AO-14 AO-15 AO-16
|
||||
```
|
||||
|
||||
Each agent gets its own worktree branched from main (which contains the scaffold + types).
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
# golangci-lint v2 config for the AO backend.
|
||||
# Run: golangci-lint run ./... (from backend/)
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
issues:
|
||||
# Report every finding, not the default first-50-per-linter / 3-same.
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
# --- correctness ---
|
||||
- errcheck # unchecked errors
|
||||
- govet # suspicious constructs
|
||||
- ineffassign # ineffectual assignments
|
||||
- staticcheck # the big static analyzer
|
||||
- unused # dead code (funcs/vars/types/fields)
|
||||
- errorlint # error wrapping / comparison bugs
|
||||
- bodyclose # unclosed HTTP response bodies
|
||||
- sqlclosecheck # unclosed sql.Rows/Stmt
|
||||
- rowserrcheck # missing rows.Err()
|
||||
- nilerr # `return nil` after a non-nil err check
|
||||
- makezero # append to a non-zero-len make() slice
|
||||
- gocheckcompilerdirectives # malformed //go: directives
|
||||
- reassign # reassigning package-level vars from other pkgs
|
||||
# --- dead code / boilerplate (the "nuke" linters) ---
|
||||
- unparam # unused function params / always-same returns
|
||||
- unconvert # unnecessary type conversions
|
||||
- wastedassign # assignments never read
|
||||
- copyloopvar # redundant loop-var copies (Go 1.22+)
|
||||
- prealloc # slices that could be preallocated
|
||||
- dupl # copy-pasted code blocks
|
||||
# --- style / quality ---
|
||||
- revive # configurable golint successor
|
||||
- gocritic # opinionated diagnostics + style
|
||||
- misspell # typos in comments/strings
|
||||
- usestdlibvars # use stdlib consts (http.MethodGet, etc.)
|
||||
- predeclared # shadowing predeclared identifiers
|
||||
- nakedret # naked returns in long funcs
|
||||
# --- security ---
|
||||
- gosec
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment # struct field ordering is not worth the churn
|
||||
- shadow # shadowing `err` in nested scopes is idiomatic Go
|
||||
revive:
|
||||
rules:
|
||||
- { name: exported } # doc comments on every exported symbol
|
||||
- { name: blank-imports }
|
||||
- { name: context-as-argument }
|
||||
- { name: context-keys-type }
|
||||
- { name: dot-imports }
|
||||
- { name: error-return }
|
||||
- { name: error-strings }
|
||||
- { name: error-naming }
|
||||
- { name: indent-error-flow }
|
||||
- { name: errorf }
|
||||
- { name: empty-block }
|
||||
- { name: superfluous-else }
|
||||
- { name: unreachable-code }
|
||||
- { name: redefines-builtin-id }
|
||||
- { name: range }
|
||||
- { name: time-naming }
|
||||
- { name: var-declaration }
|
||||
gocritic:
|
||||
enabled-tags: [diagnostic, performance, style]
|
||||
disabled-checks:
|
||||
- ifElseChain # overlaps revive/superfluous-else
|
||||
- commentedOutCode
|
||||
- hugeParam # pass-by-pointer micro-opt; hurts clarity, risks nil/aliasing
|
||||
- rangeValCopy # same — copying a struct in range is usually fine
|
||||
- unnamedResult # named returns are a style choice, not a defect
|
||||
dupl:
|
||||
threshold: 140
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unchecked errors — errcheck owns this
|
||||
- G304 # file inclusion via variable — paths are config/run-file/worktree-derived, not user input
|
||||
- G703 # path traversal via taint analysis — same as G304: binary-resolution and worktree-derived paths, not user input
|
||||
- G704 # SSRF via taint analysis — the daemon client's host is hardcoded loopback; only the request path varies, so it cannot be steered to an external host
|
||||
|
||||
exclusions:
|
||||
generated: lax # skip sqlc/codegen ("Code generated ... DO NOT EDIT")
|
||||
rules:
|
||||
# Tests: relax the noisiest checks (deliberate error-drops, repeated setup,
|
||||
# preallocation, and upgrade-response bodies that don't need closing).
|
||||
- path: _test\.go
|
||||
linters: [errcheck, dupl, gosec, unparam, gocritic, prealloc, bodyclose]
|
||||
# status.go deliberately reports probe failures in the result struct
|
||||
# (st.State/st.Error) and returns nil — a down daemon is the status being
|
||||
# reported, not a failure of the status command itself.
|
||||
- path: internal/cli/status\.go
|
||||
linters: [nilerr]
|
||||
# The reflect/unsafe field-inspection test is intentional.
|
||||
- path: wiring_test\.go
|
||||
linters: [gosec]
|
||||
# Spawning git/agent subprocesses with computed args is the point.
|
||||
- linters: [gosec]
|
||||
text: "G204"
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- goimports
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/aoagents/agent-orchestrator
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(cli.ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Command genspec writes the code-first OpenAPI document produced by
|
||||
// apispec.Build() to disk. It is invoked via `go generate` (see
|
||||
// internal/httpd/apispec/gen.go); the output openapi.yaml is committed and
|
||||
// embedded by the apispec package.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec/specgen"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "openapi.yaml", "output path for the generated OpenAPI document")
|
||||
flag.Parse()
|
||||
|
||||
doc, err := specgen.Build()
|
||||
if err != nil {
|
||||
log.Fatalf("genspec: build openapi: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, doc, 0o600); err != nil {
|
||||
log.Fatalf("genspec: write %s: %v", *out, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
module github.com/aoagents/agent-orchestrator/backend
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-pty v0.2.3
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/swaggest/jsonschema-go v0.3.79
|
||||
github.com/swaggest/openapi-go v0.2.61
|
||||
golang.org/x/sys v0.44.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/swaggest/refl v1.4.0 // indirect
|
||||
github.com/u-root/u-root v0.16.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
github.com/aymanbagabas/go-pty v0.2.3 h1:hsqcTIUV8I4iTSh3HQl61CR2wh0YPS6gHOYLhAfWu/E=
|
||||
github.com/aymanbagabas/go-pty v0.2.3/go.mod h1:GLkgQovzqN5A1xMB79yHWiG1rhcquZCjkwKQGKFPdPg=
|
||||
github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ=
|
||||
github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
|
||||
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
|
||||
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d h1:nP8SfQJqruIVSWYJTuYc37jLHEY1Z0fF+zKSrs3K/C8=
|
||||
github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE=
|
||||
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
|
||||
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
|
||||
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
|
||||
github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs=
|
||||
github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0=
|
||||
github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc=
|
||||
github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw=
|
||||
github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k=
|
||||
github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA=
|
||||
github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7 h1:dtiVT4SeBUc/vHtwI2HjDZN+FCKTstQBxugIxJEGo9g=
|
||||
github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7/go.mod h1:PW3wGFCHjdHxAhra5FKvcARbCGqGfentYuPKmuhv8DY=
|
||||
github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8=
|
||||
github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90=
|
||||
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Package activitydispatch is the single source of truth mapping the agent
|
||||
// token in `ao hooks <agent> <event>` onto the function that interprets that
|
||||
// agent's hook callbacks as an AO activity state.
|
||||
//
|
||||
// The hidden `ao hooks` CLI command dispatches a live callback through it. Every
|
||||
// adapter that installs `ao hooks <tok>` callbacks must have a deriver
|
||||
// registered here — otherwise the adapter writes callbacks that nothing on the
|
||||
// receiving side understands, so its activity is silently never reported.
|
||||
package activitydispatch
|
||||
|
||||
import (
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/autohand"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cline"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kilocode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kiro"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// DeriveFunc maps a native agent hook event and its raw stdin payload onto an AO
|
||||
// activity state. ok=false means the event carries no activity signal.
|
||||
type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool)
|
||||
|
||||
// Derivers maps the agent token in `ao hooks <agent> <event>` to its deriver.
|
||||
// Per-adapter PRs add their tokens here as they land.
|
||||
var Derivers = map[string]DeriveFunc{
|
||||
"claude-code": claudecode.DeriveActivityState,
|
||||
"codex": codex.DeriveActivityState,
|
||||
"cursor": cursor.DeriveActivityState,
|
||||
"opencode": opencode.DeriveActivityState,
|
||||
"qwen": qwen.DeriveActivityState,
|
||||
"copilot": copilot.DeriveActivityState,
|
||||
"droid": droid.DeriveActivityState,
|
||||
"agy": agy.DeriveActivityState,
|
||||
"goose": goose.DeriveActivityState,
|
||||
"cline": cline.DeriveActivityState,
|
||||
"kiro": kiro.DeriveActivityState,
|
||||
"kilocode": kilocode.DeriveActivityState,
|
||||
"autohand": autohand.DeriveActivityState,
|
||||
}
|
||||
|
||||
// Derive looks up the deriver for an agent token and applies it. ok=false when
|
||||
// the token has no registered deriver or the event carries no activity signal —
|
||||
// the caller reports nothing in either case.
|
||||
func Derive(agent, event string, payload []byte) (domain.ActivityState, bool) {
|
||||
derive, found := Derivers[agent]
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
return derive(event, payload)
|
||||
}
|
||||
|
||||
// SupportsHarness reports whether a harness has an activity pipeline at all:
|
||||
// a registered deriver here means its adapter installs `ao hooks <harness>`
|
||||
// callbacks that can reach the daemon. Status derivation uses this to decide
|
||||
// whether prolonged silence is suspicious (no_signal) or simply all a hook-less
|
||||
// harness can ever report (idle). Harness names and `ao hooks` agent tokens are
|
||||
// the same strings by convention.
|
||||
func SupportsHarness(h domain.AgentHarness) bool {
|
||||
_, ok := Derivers[string(h)]
|
||||
return ok
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package activitydispatch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// Every deriver key must be a known harness name: SupportsHarness equates the
|
||||
// two, so a token that drifts from its harness constant would silently report
|
||||
// the harness as hook-less.
|
||||
func TestDeriverTokensAreKnownHarnesses(t *testing.T) {
|
||||
for token := range Derivers {
|
||||
if !domain.AgentHarness(token).IsKnown() {
|
||||
t.Errorf("deriver token %q is not a known AgentHarness", token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsHarness(t *testing.T) {
|
||||
for _, h := range []domain.AgentHarness{domain.HarnessCodex, domain.HarnessClaudeCode, domain.HarnessOpenCode} {
|
||||
if !SupportsHarness(h) {
|
||||
t.Errorf("SupportsHarness(%q) = false, want true", h)
|
||||
}
|
||||
}
|
||||
// Harnesses whose adapters install no hooks must read as unsupported so
|
||||
// their silence never derives no_signal.
|
||||
for _, h := range []domain.AgentHarness{domain.HarnessAmp, domain.HarnessAider, domain.HarnessCrush, domain.AgentHarness("")} {
|
||||
if SupportsHarness(h) {
|
||||
t.Errorf("SupportsHarness(%q) = true, want false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package agy
|
||||
|
||||
import (
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// DeriveActivityState maps an Agy hook event onto an AO activity state. The
|
||||
// bool is false when the event carries no activity signal.
|
||||
//
|
||||
// event is the AO hook sub-command name installed in agyManagedHooks:
|
||||
// "session-start", "session-end", "before-agent", "after-agent", "after-tool".
|
||||
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
|
||||
switch event {
|
||||
case "before-agent":
|
||||
return domain.ActivityActive, true
|
||||
case "after-agent":
|
||||
return domain.ActivityIdle, true
|
||||
case "after-tool":
|
||||
return domain.ActivityActive, true
|
||||
case "session-end":
|
||||
return domain.ActivityExited, true
|
||||
case "session-start":
|
||||
return "", false
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package agy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
want domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"before agent -> active", "before-agent", domain.ActivityActive, true},
|
||||
{"after agent -> idle", "after-agent", domain.ActivityIdle, true},
|
||||
{"after tool -> active", "after-tool", domain.ActivityActive, true},
|
||||
{"session end -> exited", "session-end", domain.ActivityExited, true},
|
||||
{"session start -> no signal", "session-start", "", false},
|
||||
{"unknown event -> no signal", "unknown", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
// Package agy implements the Agy (Antigravity) agent adapter: launching new sessions,
|
||||
// resuming sessions by native ID, installing workspace-local hooks, and reading
|
||||
// hook-derived session info.
|
||||
package agy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "agy"
|
||||
|
||||
// Normalized session-metadata keys. Shared vocabulary with the Codex and Claude Code
|
||||
// adapters so the dashboard treats every agent uniformly.
|
||||
agyTitleMetadataKey = "title"
|
||||
agySummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Agy agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.RWMutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Agy adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Agy",
|
||||
Description: "Run Agy (Antigravity) worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Agy exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start an interactive Agy session.
|
||||
// Shape:
|
||||
//
|
||||
// agy --add-dir <WorkspacePath> [--dangerously-skip-permissions] [--prompt-interactive <Prompt>]
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.agyBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
|
||||
if cfg.WorkspacePath != "" {
|
||||
cmd = append(cmd, "--add-dir", cfg.WorkspacePath)
|
||||
}
|
||||
|
||||
if cfg.Permissions == ports.PermissionModeBypassPermissions {
|
||||
cmd = append(cmd, "--dangerously-skip-permissions")
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--prompt-interactive", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Agy receives its prompt in the
|
||||
// launch command itself via --prompt-interactive.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Agy session:
|
||||
// `agy --add-dir <WorkspacePath> [--dangerously-skip-permissions] --conversation <agentSessionId>`.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.agyBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
|
||||
if cfg.Session.WorkspacePath != "" {
|
||||
cmd = append(cmd, "--add-dir", cfg.Session.WorkspacePath)
|
||||
}
|
||||
|
||||
if cfg.Permissions == ports.PermissionModeBypassPermissions {
|
||||
cmd = append(cmd, "--dangerously-skip-permissions")
|
||||
}
|
||||
|
||||
cmd = append(cmd, "--conversation", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces Agy hook-derived metadata.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[agyTitleMetadataKey],
|
||||
Summary: session.Metadata[agySummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// ResolveAgyBinary returns the path to the agy binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations.
|
||||
// Returns "agy" as a last-ditch fallback.
|
||||
func ResolveAgyBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"agy.cmd", "agy.exe", "agy"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "agy.cmd"),
|
||||
filepath.Join(appData, "npm", "agy.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "agy.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("agy"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/agy",
|
||||
"/opt/homebrew/bin/agy",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "agy"),
|
||||
filepath.Join(home, ".cargo", "bin", "agy"),
|
||||
filepath.Join(home, ".npm", "bin", "agy"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) agyBinary(ctx context.Context) (string, error) {
|
||||
// Fast path: a concurrent-safe read of the already-resolved binary.
|
||||
p.binaryMu.RLock()
|
||||
cached := p.resolvedBinary
|
||||
p.binaryMu.RUnlock()
|
||||
if cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Populate path: take the write lock and re-check, since another goroutine
|
||||
// may have resolved the binary between releasing RLock and acquiring Lock.
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveAgyBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package agy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
plugin := New()
|
||||
manifest := plugin.Manifest()
|
||||
if manifest.ID != "agy" {
|
||||
t.Fatalf("manifest id = %q, want agy", manifest.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "agy"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "fix this",
|
||||
WorkspacePath: "/tmp/ws",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"agy",
|
||||
"--add-dir", "/tmp/ws",
|
||||
"--dangerously-skip-permissions",
|
||||
"--prompt-interactive", "fix this",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "agy"}
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("strategy = %q, want in_command", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "agy"}
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "native-id-123"},
|
||||
WorkspacePath: "/tmp/ws",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"agy",
|
||||
"--add-dir", "/tmp/ws",
|
||||
"--dangerously-skip-permissions",
|
||||
"--conversation", "native-id-123",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandNoSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "agy"}
|
||||
_, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected ok=false when agentSessionId is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfo(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "native-id-123",
|
||||
"title": "My Title",
|
||||
"summary": "My Summary",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if info.AgentSessionID != "native-id-123" || info.Title != "My Title" || info.Summary != "My Summary" {
|
||||
t.Fatalf("unexpected SessionInfo: %#v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHooksLifecycle(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agy-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
plugin := &Plugin{}
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
WorkspacePath: tmpDir,
|
||||
}
|
||||
|
||||
// 1. Initially hooks should not be installed.
|
||||
installed, err := plugin.AreHooksInstalled(context.Background(), tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed {
|
||||
t.Fatal("expected hooks to not be installed initially")
|
||||
}
|
||||
|
||||
// 2. Install hooks.
|
||||
err = plugin.GetAgentHooks(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installed, err = plugin.AreHooksInstalled(context.Background(), tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installed {
|
||||
t.Fatal("expected hooks to be installed after GetAgentHooks")
|
||||
}
|
||||
|
||||
// Verify hooks.json structure
|
||||
hooksJSONPath := filepath.Join(tmpDir, ".gemini", "hooks.json")
|
||||
data, err := os.ReadFile(hooksJSONPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var hookFile agyHookFile
|
||||
if err := json.Unmarshal(data, &hookFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(hookFile.Hooks) != len(agyManagedHooks) {
|
||||
t.Fatalf("expected %d events in hooks, got %d", len(agyManagedHooks), len(hookFile.Hooks))
|
||||
}
|
||||
|
||||
for _, spec := range agyManagedHooks {
|
||||
groups, ok := hookFile.Hooks[spec.Event]
|
||||
if !ok {
|
||||
t.Fatalf("expected event %q in hooks.json", spec.Event)
|
||||
}
|
||||
found := false
|
||||
for _, group := range groups {
|
||||
for _, h := range group.Hooks {
|
||||
if h.Command == spec.Command {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected command %q for event %q", spec.Command, spec.Event)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Uninstall hooks.
|
||||
err = plugin.UninstallHooks(context.Background(), tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installed, err = plugin.AreHooksInstalled(context.Background(), tmpDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed {
|
||||
t.Fatal("expected hooks to be uninstalled after UninstallHooks")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
package agy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
agyHooksDirName = ".gemini"
|
||||
agyHooksFileName = "hooks.json"
|
||||
|
||||
agyHookCommandPrefix = "ao hooks agy "
|
||||
)
|
||||
|
||||
type agyHookFile struct {
|
||||
Hooks map[string][]agyMatcherGroup `json:"hooks"`
|
||||
}
|
||||
|
||||
type agyMatcherGroup struct {
|
||||
Matcher *string `json:"matcher,omitempty"`
|
||||
Hooks []agyHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type agyHookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type agyHookSpec struct {
|
||||
Event string
|
||||
Command string
|
||||
}
|
||||
|
||||
var agyManagedHooks = []agyHookSpec{
|
||||
{Event: "SessionStart", Command: agyHookCommandPrefix + "session-start"},
|
||||
{Event: "SessionEnd", Command: agyHookCommandPrefix + "session-end"},
|
||||
{Event: "BeforeAgent", Command: agyHookCommandPrefix + "before-agent"},
|
||||
{Event: "AfterAgent", Command: agyHookCommandPrefix + "after-agent"},
|
||||
{Event: "AfterTool", Command: agyHookCommandPrefix + "after-tool"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Agy hooks into the worktree-local
|
||||
// .gemini/hooks.json file. Existing hook entries are preserved and duplicate
|
||||
// AO commands are not appended.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.WorkspacePath) == "" {
|
||||
return errors.New("agy.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := agyHooksPath(cfg.WorkspacePath)
|
||||
topLevel, rawHooks, err := readAgyHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agy.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
for event, specs := range groupAgyHooksByEvent() {
|
||||
var existingGroups []agyMatcherGroup
|
||||
if err := parseAgyHookType(rawHooks, event, &existingGroups); err != nil {
|
||||
return fmt.Errorf("agy.GetAgentHooks: %w", err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !agyHookCommandExists(existingGroups, spec.Command) {
|
||||
entry := agyHookEntry{Type: "command", Command: spec.Command}
|
||||
existingGroups = addAgyHook(existingGroups, entry)
|
||||
}
|
||||
}
|
||||
if err := marshalAgyHookType(rawHooks, event, existingGroups); err != nil {
|
||||
return fmt.Errorf("agy.GetAgentHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeAgyHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("agy.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), agyHooksFileName); err != nil {
|
||||
return fmt.Errorf("agy.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Agy hooks from the workspace-local
|
||||
// .gemini/hooks.json file, leaving user-defined hooks untouched. A missing file
|
||||
// is a no-op.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return errors.New("agy.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := agyHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readAgyHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agy.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range agyManagedEvents() {
|
||||
var groups []agyMatcherGroup
|
||||
if err := parseAgyHookType(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("agy.UninstallHooks: %w", err)
|
||||
}
|
||||
groups = removeAgyManagedHooks(groups)
|
||||
if err := marshalAgyHookType(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("agy.UninstallHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeAgyHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("agy.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Agy hook is present in the
|
||||
// workspace-local hooks file. A missing file means none are installed.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, errors.New("agy.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := agyHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readAgyHooks(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("agy.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range agyManagedEvents() {
|
||||
var groups []agyMatcherGroup
|
||||
if err := parseAgyHookType(rawHooks, event, &groups); err != nil {
|
||||
return false, fmt.Errorf("agy.AreHooksInstalled: %w", err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if isAgyManagedHook(hook.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func agyHooksPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, agyHooksDirName, agyHooksFileName)
|
||||
}
|
||||
|
||||
// readAgyHooks loads the hooks file into a top-level raw map plus the decoded
|
||||
// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty
|
||||
// file yields empty maps.
|
||||
func readAgyHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) {
|
||||
topLevel = map[string]json.RawMessage{}
|
||||
rawHooks = map[string]json.RawMessage{}
|
||||
|
||||
data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err)
|
||||
}
|
||||
if hooksRaw, ok := topLevel["hooks"]; ok {
|
||||
if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err)
|
||||
}
|
||||
}
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
|
||||
// writeAgyHooks folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeAgyHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
|
||||
if len(rawHooks) == 0 {
|
||||
delete(topLevel, "hooks")
|
||||
} else {
|
||||
hooksJSON, err := json.Marshal(rawHooks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hooks: %w", err)
|
||||
}
|
||||
topLevel["hooks"] = hooksJSON
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
|
||||
return fmt.Errorf("create hook dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(topLevel, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", hooksPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupAgyHooksByEvent() map[string][]agyHookSpec {
|
||||
byEvent := map[string][]agyHookSpec{}
|
||||
for _, spec := range agyManagedHooks {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
func agyManagedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(agyManagedHooks))
|
||||
for _, spec := range agyManagedHooks {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func isAgyManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, agyHookCommandPrefix)
|
||||
}
|
||||
|
||||
func removeAgyManagedHooks(groups []agyMatcherGroup) []agyMatcherGroup {
|
||||
result := make([]agyMatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]agyHookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !isAgyManagedHook(hook.Command) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseAgyHookType(rawHooks map[string]json.RawMessage, event string, target *[]agyMatcherGroup) error {
|
||||
data, ok := rawHooks[event]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return fmt.Errorf("parse %s hooks: %w", event, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalAgyHookType(rawHooks map[string]json.RawMessage, event string, groups []agyMatcherGroup) error {
|
||||
if len(groups) == 0 {
|
||||
delete(rawHooks, event)
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s hooks: %w", event, err)
|
||||
}
|
||||
rawHooks[event] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func agyHookCommandExists(groups []agyMatcherGroup, command string) bool {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addAgyHook(groups []agyMatcherGroup, hook agyHookEntry) []agyMatcherGroup {
|
||||
for i, group := range groups {
|
||||
if group.Matcher == nil {
|
||||
groups[i].Hooks = append(groups[i].Hooks, hook)
|
||||
return groups
|
||||
}
|
||||
}
|
||||
return append(groups, agyMatcherGroup{Matcher: nil, Hooks: []agyHookEntry{hook}})
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
// Package aider implements the Aider agent adapter: launching headless Aider
|
||||
// worker sessions.
|
||||
//
|
||||
// Aider is a Tier C adapter: it has no lifecycle hook surface, no native
|
||||
// session id, and no resume-by-id mechanism, so hook installation, restore, and
|
||||
// SessionInfo are intentionally no-ops. The permission mapping is lossy because
|
||||
// Aider lacks a graduated approval ladder or sandbox (see the comments on
|
||||
// appendApprovalFlags).
|
||||
package aider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const adapterID = "aider"
|
||||
|
||||
// Plugin is the Aider agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Aider adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Aider",
|
||||
Description: "Run Aider worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a headless Aider session:
|
||||
//
|
||||
// aider -m <prompt> [permission flags] --no-check-update --no-stream --no-pretty [--read <system prompt file>]
|
||||
//
|
||||
// The prompt is delivered with `-m <prompt>` rather than positionally: Aider
|
||||
// treats positional arguments as files to add to the chat, so a positional
|
||||
// prompt would be misread. The `-m` pair is only appended when a prompt is set.
|
||||
//
|
||||
// Aider has no inline system-prompt mechanism; only SystemPromptFile is honored
|
||||
// via --read. The --no-check-update --no-stream --no-pretty flags keep Aider
|
||||
// well-behaved in a non-interactive, captured-output context.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.aiderBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "-m", cfg.Prompt)
|
||||
}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
cmd = append(cmd, "--no-check-update", "--no-stream", "--no-pretty")
|
||||
if cfg.SystemPromptFile != "" {
|
||||
cmd = append(cmd, "--read", cfg.SystemPromptFile)
|
||||
}
|
||||
// aider has no inline system-prompt mechanism; only SystemPromptFile is
|
||||
// honored via --read. A cfg.SystemPrompt with no file is intentionally
|
||||
// dropped here rather than written to disk.
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Aider receives its prompt in the launch
|
||||
// command itself (via -m).
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks is a no-op: Aider emits no lifecycle hooks (Tier C), so there
|
||||
// is no native hook config to install AO hooks into.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand always reports that no native session can be continued.
|
||||
// Aider has no native session id or resume-by-id mechanism
|
||||
// (see github.com/Aider-AI/aider issues/166), so the manager always falls back
|
||||
// to a fresh launch.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// SessionInfo is a no-op: Aider exposes no captureable session metadata.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
|
||||
// normalizePermissionMode collapses an empty mode onto PermissionModeDefault so
|
||||
// callers can switch over a stable set of values.
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
if mode == "" {
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's permission modes onto Aider's flags. The mapping
|
||||
// is lossy: Aider has no graduated approval ladder and no sandbox, so multiple
|
||||
// AO modes collapse onto the same Aider behavior.
|
||||
func appendApprovalFlags(cmd *[]string, mode ports.PermissionMode) {
|
||||
switch normalizePermissionMode(mode) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flags: Aider's interactive confirmation prompts apply. In headless
|
||||
// -m mode an unanswered confirm can hang; this is acceptable and
|
||||
// documented, deferring the choice to the user's own Aider config.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
// Apply edits without prompting but leave them uncommitted.
|
||||
*cmd = append(*cmd, "--yes-always", "--no-auto-commits")
|
||||
case ports.PermissionModeAuto:
|
||||
// Apply edits without prompting and keep Aider's default auto-commit.
|
||||
*cmd = append(*cmd, "--yes-always")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
// Lossy: Aider has no sandbox/bypass, so this is identical to auto.
|
||||
*cmd = append(*cmd, "--yes-always")
|
||||
default:
|
||||
// Unhandled/future modes: no flags, deferring to the user's Aider config.
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveAiderBinary finds the `aider` binary, searching PATH then common
|
||||
// install locations. It returns "aider" as a last resort so callers get the
|
||||
// shell's normal command-not-found behavior if Aider is absent.
|
||||
func ResolveAiderBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"aider.exe", "aider.cmd", "aider"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("aider: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("aider"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/aider",
|
||||
"/opt/homebrew/bin/aider",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append([]string{filepath.Join(home, ".local", "bin", "aider")}, candidates...)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("aider: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) aiderBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveAiderBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
package aider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
m := (&Plugin{}).Manifest()
|
||||
if m.ID != "aider" {
|
||||
t.Fatalf("ID = %q, want aider", m.ID)
|
||||
}
|
||||
if m.Name != "Aider" {
|
||||
t.Fatalf("Name = %q, want Aider", m.Name)
|
||||
}
|
||||
hasAgent := false
|
||||
for _, c := range m.Capabilities {
|
||||
if c == adapters.CapabilityAgent {
|
||||
hasAgent = true
|
||||
}
|
||||
}
|
||||
if !hasAgent {
|
||||
t.Fatal("missing CapabilityAgent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecEmpty(t *testing.T) {
|
||||
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("expected no fields, got %d", len(spec.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if s != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandDeliversPromptWithFlag(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "add a health check",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"aider", "-m", "add a health check", "--no-check-update", "--no-stream", "--no-pretty"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandOmitsPromptFlagWhenEmpty(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"aider", "--no-check-update", "--no-stream", "--no-pretty"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
for _, arg := range cmd {
|
||||
if arg == "-m" {
|
||||
t.Fatalf("cmd = %#v unexpectedly contains -m for empty prompt", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAlwaysAppendsHeadlessFlags(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "do the thing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, want := range []string{"--no-check-update", "--no-stream", "--no-pretty"} {
|
||||
found := false
|
||||
for _, arg := range cmd {
|
||||
if arg == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("cmd = %#v missing headless flag %q", cmd, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode ports.PermissionMode
|
||||
wantFlags []string
|
||||
wantAbsent []string
|
||||
}{
|
||||
{
|
||||
name: "default omits approval flags",
|
||||
mode: ports.PermissionModeDefault,
|
||||
wantFlags: nil,
|
||||
wantAbsent: []string{"--yes-always", "--no-auto-commits"},
|
||||
},
|
||||
{
|
||||
name: "empty omits approval flags",
|
||||
mode: "",
|
||||
wantFlags: nil,
|
||||
wantAbsent: []string{"--yes-always", "--no-auto-commits"},
|
||||
},
|
||||
{
|
||||
name: "accept edits applies but leaves uncommitted",
|
||||
mode: ports.PermissionModeAcceptEdits,
|
||||
wantFlags: []string{"--yes-always", "--no-auto-commits"},
|
||||
wantAbsent: nil,
|
||||
},
|
||||
{
|
||||
name: "auto applies and auto-commits",
|
||||
mode: ports.PermissionModeAuto,
|
||||
wantFlags: []string{"--yes-always"},
|
||||
wantAbsent: []string{"--no-auto-commits"},
|
||||
},
|
||||
{
|
||||
name: "bypass collapses onto auto",
|
||||
mode: ports.PermissionModeBypassPermissions,
|
||||
wantFlags: []string{"--yes-always"},
|
||||
wantAbsent: []string{"--no-auto-commits"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "do the thing",
|
||||
Permissions: tt.mode,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, want := range tt.wantFlags {
|
||||
found := false
|
||||
for _, arg := range cmd {
|
||||
if arg == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("cmd = %#v missing expected flag %q", cmd, want)
|
||||
}
|
||||
}
|
||||
for _, absent := range tt.wantAbsent {
|
||||
for _, arg := range cmd {
|
||||
if arg == absent {
|
||||
t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandSystemPromptFileUsesRead(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "do the thing",
|
||||
SystemPromptFile: "/tmp/system.md",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"aider", "-m", "do the thing", "--no-check-update", "--no-stream", "--no-pretty", "--read", "/tmp/system.md"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandInlineSystemPromptIsDropped(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "do the thing",
|
||||
SystemPrompt: "inline ignored",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"aider", "-m", "do the thing", "--no-check-update", "--no-stream", "--no-pretty"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
for _, arg := range cmd {
|
||||
if arg == "--read" {
|
||||
t.Fatalf("cmd = %#v unexpectedly contains --read for inline system prompt", cmd)
|
||||
}
|
||||
if arg == "inline ignored" {
|
||||
t.Fatalf("cmd = %#v unexpectedly contains inline system prompt text", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandAlwaysFalse(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"},
|
||||
},
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok=true, want false (aider has no resume-by-id)")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("cmd = %#v, want nil", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksNoOp(t *testing.T) {
|
||||
if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil {
|
||||
t.Fatalf("GetAgentHooks err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoNoOp(t *testing.T) {
|
||||
info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok=true with info %#v, want no-op false", info)
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetLaunchCommand err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err)
|
||||
}
|
||||
if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("SessionInfo err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAiderBinaryFallback(t *testing.T) {
|
||||
// When the binary is not on PATH or any well-known location, the resolver
|
||||
// MUST surface ports.ErrAgentBinaryNotFound rather than a silent string
|
||||
// fallback that lets a missing CLI launch into an empty zellij pane.
|
||||
bin, err := ResolveAiderBinary(context.Background())
|
||||
if err != nil {
|
||||
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
|
||||
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if bin == "" {
|
||||
t.Fatal("ResolveAiderBinary returned empty string with no error")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
// Package amp implements the Amp agent adapter: launching new interactive Amp
|
||||
// sessions and resuming sessions when a native Amp thread id is known.
|
||||
//
|
||||
// Amp activity hooks and SessionInfo derivation will likely require an
|
||||
// Amp-specific TypeScript plugin, similar to opencode. Until that integration
|
||||
// exists, hook installation and SessionInfo are intentionally no-ops.
|
||||
package amp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const adapterID = "amp"
|
||||
|
||||
// Plugin is the Amp agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Amp adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Amp",
|
||||
Description: "Run Amp worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new interactive Amp session:
|
||||
//
|
||||
// amp [--permission-mode <mode>] [--append-system-prompt <system prompt>] [-- <prompt>]
|
||||
//
|
||||
// The prompt is passed after `--` so a prompt beginning with "-" is not
|
||||
// mistaken for a flag. System prompts are appended to Amp's defaults, mirroring
|
||||
// the Claude Code adapter's launch shape.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary, err := p.ampBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
appendPermissionFlags(&cmd, cfg.Permissions)
|
||||
if cfg.SystemPromptFile != "" {
|
||||
cmd = append(cmd, "--append-system-prompt-file", cfg.SystemPromptFile)
|
||||
} else if cfg.SystemPrompt != "" {
|
||||
cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt)
|
||||
}
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Amp receives its prompt in the launch
|
||||
// command itself.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks is intentionally a no-op until Amp activity can be reported via
|
||||
// an Amp-specific plugin.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Amp session
|
||||
// when plugin-derived native session metadata is available. Until that metadata
|
||||
// exists, ok is false and callers fall back to fresh launch behavior.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.ampBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// Capacity fits binary + up to two permission flags + --resume + sessionID.
|
||||
cmd = make([]string, 0, 5)
|
||||
cmd = append(cmd, binary)
|
||||
appendPermissionFlags(&cmd, cfg.Permissions)
|
||||
cmd = append(cmd, "--resume", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo is intentionally a no-op until Amp plugin metadata exists.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
|
||||
func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) {
|
||||
switch mode {
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--permission-mode", "acceptEdits")
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--permission-mode", "auto")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--permission-mode", "bypassPermissions")
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveAmpBinary finds the `amp` binary, searching PATH then common install
|
||||
// locations. It returns "amp" as a last resort so callers get the shell's normal
|
||||
// command-not-found behavior if Amp is absent.
|
||||
func ResolveAmpBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"amp.cmd", "amp.exe", "amp"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "amp.cmd"),
|
||||
filepath.Join(appData, "npm", "amp.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("amp"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/amp",
|
||||
"/opt/homebrew/bin/amp",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "amp"),
|
||||
filepath.Join(home, ".npm", "bin", "amp"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) ampBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveAmpBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
package amp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
m := (&Plugin{}).Manifest()
|
||||
if m.ID != "amp" {
|
||||
t.Fatalf("ID = %q, want amp", m.ID)
|
||||
}
|
||||
if m.Name != "Amp" {
|
||||
t.Fatalf("Name = %q, want Amp", m.Name)
|
||||
}
|
||||
hasAgent := false
|
||||
for _, c := range m.Capabilities {
|
||||
if c == adapters.CapabilityAgent {
|
||||
hasAgent = true
|
||||
}
|
||||
}
|
||||
if !hasAgent {
|
||||
t.Fatal("missing CapabilityAgent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecEmpty(t *testing.T) {
|
||||
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("expected no fields, got %d", len(spec.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if s != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBypassWithPrompt(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "amp"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-add a health check",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"amp", "--permission-mode", "bypassPermissions", "--", "-add a health check"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode ports.PermissionMode
|
||||
want []string
|
||||
wantAbsent string
|
||||
}{
|
||||
{"default omits flag", ports.PermissionModeDefault, []string{"amp"}, "--permission-mode"},
|
||||
{"empty omits flag", "", []string{"amp"}, "--permission-mode"},
|
||||
{"accept edits", ports.PermissionModeAcceptEdits, []string{"amp", "--permission-mode", "acceptEdits"}, ""},
|
||||
{"auto", ports.PermissionModeAuto, []string{"amp", "--permission-mode", "auto"}, ""},
|
||||
{"bypass", ports.PermissionModeBypassPermissions, []string{"amp", "--permission-mode", "bypassPermissions"}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "amp"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.mode})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, tt.want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, tt.want)
|
||||
}
|
||||
if tt.wantAbsent != "" {
|
||||
for _, arg := range cmd {
|
||||
if arg == tt.wantAbsent {
|
||||
t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, tt.wantAbsent)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAppendsSystemPrompt(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "amp"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPrompt: "follow repo rules",
|
||||
Prompt: "do the thing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"amp", "--append-system-prompt", "follow repo rules", "--", "do the thing"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandPrefersSystemPromptFileFlag(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "amp"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPromptFile: "/tmp/system.md",
|
||||
SystemPrompt: "inline ignored",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"amp", "--append-system-prompt-file", "/tmp/system.md"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommand(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "amp"}
|
||||
cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "T-abc123"},
|
||||
},
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok=false, want true")
|
||||
}
|
||||
|
||||
want := []string{"amp", "--permission-mode", "bypassPermissions", "--resume", "T-abc123"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandNoID(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "amp"}
|
||||
_, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{Metadata: map[string]string{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ok=true with no agentSessionId, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksNoOp(t *testing.T) {
|
||||
if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil {
|
||||
t.Fatalf("GetAgentHooks err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoNoOp(t *testing.T) {
|
||||
info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "T-abc123"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok=true with info %#v, want no-op false", info)
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetLaunchCommand err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err)
|
||||
}
|
||||
if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("SessionInfo err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
// Package auggie implements the Auggie (Augment Code) agent adapter: launching
|
||||
// new headless Auggie sessions and resuming sessions when a native Auggie
|
||||
// session id is known.
|
||||
//
|
||||
// Auggie is Augment Code's terminal coding agent (binary "auggie", installed via
|
||||
// `npm install -g @augmentcode/auggie`). It exposes a headless one-shot mode via
|
||||
// `--print` (alias `-p`) which runs a single instruction and exits — the mode AO
|
||||
// uses to drive it unattended.
|
||||
//
|
||||
// Launch shape:
|
||||
//
|
||||
// auggie --print [--instruction-file <f> | --instruction <s>] [-- <prompt>]
|
||||
//
|
||||
// The prompt is the print-mode positional, passed after `--` so a prompt
|
||||
// beginning with "-" is not mistaken for a flag. A system prompt, when supplied,
|
||||
// is injected via Auggie's `--instruction-file` / `--instruction` flags, which
|
||||
// append guidance to the workspace rules.
|
||||
//
|
||||
// Permissions: Auggie has no single "approve everything" flag. It governs
|
||||
// unattended tool/file approval through granular `--permission <tool>:<allow|deny>`
|
||||
// rules (and a read-only `--ask` mode), not a 4-mode bypass like Claude Code.
|
||||
// Because there is no verifiable blanket auto-approve flag, every AO permission
|
||||
// mode emits no flag and defers to the user's Auggie configuration, rather than
|
||||
// guessing a flag that does not exist.
|
||||
//
|
||||
// Resume: Auggie supports `--resume <sessionId>` (alias `-r`), usable with
|
||||
// `--print` for headless resume. AO only has a native session id to resume from
|
||||
// when one was captured into session metadata; Auggie exposes no hook/lifecycle
|
||||
// system, so that id is not captured automatically yet. GetRestoreCommand
|
||||
// therefore returns ok=false until a native session id is present, at which point
|
||||
// callers fall back to a fresh launch.
|
||||
//
|
||||
// Hooks/activity: Auggie has no hook or lifecycle event system (it reads
|
||||
// .claude/commands/ for slash commands, but that is not Claude Code hook
|
||||
// compatibility). Hook installation and SessionInfo are intentionally no-ops
|
||||
// (Tier C) until an Auggie-specific activity integration exists.
|
||||
package auggie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const adapterID = "auggie"
|
||||
|
||||
// Plugin is the Auggie agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Auggie adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Auggie",
|
||||
Description: "Run Auggie (Augment Code) worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new headless Auggie session:
|
||||
//
|
||||
// auggie --print [--instruction-file <f> | --instruction <s>] [-- <prompt>]
|
||||
//
|
||||
// The prompt is passed after `--` so a prompt beginning with "-" is not mistaken
|
||||
// for a flag. A system prompt is injected via --instruction-file / --instruction,
|
||||
// mirroring the system-prompt handling of the other adapters.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary, err := p.auggieBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary, "--print"}
|
||||
if cfg.SystemPromptFile != "" {
|
||||
cmd = append(cmd, "--instruction-file", cfg.SystemPromptFile)
|
||||
} else if cfg.SystemPrompt != "" {
|
||||
cmd = append(cmd, "--instruction", cfg.SystemPrompt)
|
||||
}
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Auggie receives its prompt in the launch
|
||||
// command itself (the print-mode positional).
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks is intentionally a no-op: Auggie has no hook or lifecycle event
|
||||
// system, so there is nothing to install. Activity reporting will require an
|
||||
// Auggie-specific integration once one exists.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Auggie session
|
||||
// when a native session id is available in metadata:
|
||||
//
|
||||
// auggie --print --resume <sessionId>
|
||||
//
|
||||
// Auggie has no hook surface to capture that id automatically yet, so in practice
|
||||
// the id is empty and ok is false, letting callers fall back to a fresh launch.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.auggieBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
cmd = []string{binary, "--print", "--resume", agentSessionID}
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo is intentionally a no-op until Auggie session metadata can be
|
||||
// captured (Auggie exposes no hook surface to derive it from).
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
|
||||
// Auggie has no single blanket auto-approve/bypass flag; unattended tool/file
|
||||
// approval is governed by granular `--permission <tool>:<allow|deny>` rules, so
|
||||
// AO emits no approval flag and defers every mode to the user's Auggie config.
|
||||
// There is therefore no appendApprovalFlags helper for this adapter.
|
||||
|
||||
// ResolveAuggieBinary finds the `auggie` binary, searching PATH then common
|
||||
// install locations. It returns "auggie" as a last resort so callers get the
|
||||
// shell's normal command-not-found behavior if Auggie is absent.
|
||||
func ResolveAuggieBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"auggie.cmd", "auggie.exe", "auggie"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "auggie.cmd"),
|
||||
filepath.Join(appData, "npm", "auggie.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("auggie"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/auggie",
|
||||
"/opt/homebrew/bin/auggie",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "auggie"),
|
||||
filepath.Join(home, ".npm", "bin", "auggie"),
|
||||
filepath.Join(home, ".npm-global", "bin", "auggie"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) auggieBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveAuggieBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
package auggie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
m := (&Plugin{}).Manifest()
|
||||
if m.ID != "auggie" {
|
||||
t.Fatalf("ID = %q, want auggie", m.ID)
|
||||
}
|
||||
if m.Name != "Auggie" {
|
||||
t.Fatalf("Name = %q, want Auggie", m.Name)
|
||||
}
|
||||
hasAgent := false
|
||||
for _, c := range m.Capabilities {
|
||||
if c == adapters.CapabilityAgent {
|
||||
hasAgent = true
|
||||
}
|
||||
}
|
||||
if !hasAgent {
|
||||
t.Fatal("missing CapabilityAgent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecEmpty(t *testing.T) {
|
||||
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("expected no fields, got %d", len(spec.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if s != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandWithPrompt(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "auggie"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-add a health check",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"auggie", "--print", "--", "-add a health check"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLaunchCommandPermissionModesEmitNoFlag documents that Auggie has no
|
||||
// blanket auto-approve flag, so every AO permission mode produces the same argv
|
||||
// (no permission flag) and defers to the user's Auggie config.
|
||||
func TestGetLaunchCommandPermissionModesEmitNoFlag(t *testing.T) {
|
||||
modes := []ports.PermissionMode{
|
||||
ports.PermissionModeDefault,
|
||||
"",
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions,
|
||||
}
|
||||
want := []string{"auggie", "--print"}
|
||||
for _, mode := range modes {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "auggie"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: mode})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
for _, arg := range cmd {
|
||||
if arg == "--permission" || arg == "--permission-mode" {
|
||||
t.Fatalf("cmd = %#v unexpectedly contains a permission flag", cmd)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAppendsSystemPrompt(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "auggie"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPrompt: "follow repo rules",
|
||||
Prompt: "do the thing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"auggie", "--print", "--instruction", "follow repo rules", "--", "do the thing"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandPrefersSystemPromptFileFlag(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "auggie"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPromptFile: "/tmp/system.md",
|
||||
SystemPrompt: "inline ignored",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"auggie", "--print", "--instruction-file", "/tmp/system.md"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommand(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "auggie"}
|
||||
cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "sess-abc123"},
|
||||
},
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok=false, want true")
|
||||
}
|
||||
|
||||
want := []string{"auggie", "--print", "--resume", "sess-abc123"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandNoID(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "auggie"}
|
||||
_, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{Metadata: map[string]string{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ok=true with no agentSessionId, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksNoOp(t *testing.T) {
|
||||
if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil {
|
||||
t.Fatalf("GetAgentHooks err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoNoOp(t *testing.T) {
|
||||
info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "sess-abc123"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok=true with info %#v, want no-op false", info)
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuggieBinaryFallback(t *testing.T) {
|
||||
// When the binary is not on PATH or any well-known location, the resolver
|
||||
// MUST surface ports.ErrAgentBinaryNotFound rather than a silent string
|
||||
// fallback that lets a missing CLI launch into an empty zellij pane.
|
||||
bin, err := ResolveAuggieBinary(context.Background())
|
||||
if err != nil {
|
||||
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
|
||||
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if bin == "" {
|
||||
t.Fatal("ResolveAuggieBinary returned empty path with no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetLaunchCommand err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err)
|
||||
}
|
||||
if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err)
|
||||
}
|
||||
if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("SessionInfo err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package autohand
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps an Autohand hook event onto an AO activity state. The
|
||||
// bool is false when the event carries no activity signal.
|
||||
//
|
||||
// event is the AO hook sub-command name installed in autohandManagedHooks
|
||||
// ("session-start", "user-prompt-submit", "permission-request", "stop"), routed
|
||||
// from Autohand's native lifecycle events. Autohand has no SessionEnd/process-
|
||||
// exit hook wired into the adapter, so runtime exit still falls back to the
|
||||
// lifecycle reaper.
|
||||
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
|
||||
switch event {
|
||||
case "session-start":
|
||||
return domain.ActivityActive, true
|
||||
case "user-prompt-submit":
|
||||
return domain.ActivityActive, true
|
||||
case "stop":
|
||||
return domain.ActivityIdle, true
|
||||
case "permission-request":
|
||||
return domain.ActivityWaitingInput, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
// Package autohand implements the Autohand Code agent adapter: launching new
|
||||
// command-mode sessions, resuming native sessions by id, installing AO's
|
||||
// lifecycle hooks into Autohand's config, and reading hook-derived session info.
|
||||
//
|
||||
// Autohand ("autohand") is an autonomous coding agent with a non-interactive
|
||||
// command mode (`autohand -p <prompt>` / positional prompt), native session
|
||||
// resume (`autohand resume <sessionId>`), and a native hook/lifecycle system
|
||||
// whose events (session-start, stop, permission-request, ...) AO maps onto
|
||||
// activity states. See hooks.go for hook installation and activity.go for the
|
||||
// event→state mapping.
|
||||
package autohand
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "autohand"
|
||||
|
||||
autohandTitleMetadataKey = "title"
|
||||
autohandSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Autohand agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Autohand adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Autohand",
|
||||
Description: "Run Autohand worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Autohand exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new Autohand command-mode session,
|
||||
// scoping the run to the workspace, applying the approval-mode flags and optional
|
||||
// system-prompt override, and passing the initial prompt as a positional argument
|
||||
// after `--` so a prompt beginning with "-" is not read as a flag.
|
||||
//
|
||||
// autohand [--path <workspace>] [<approval flags>] [--sys-prompt <value>] [-- <prompt>]
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.autohandBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
appendWorkspaceFlag(&cmd, cfg.WorkspacePath)
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
// Autohand's --sys-prompt accepts either an inline string or a file path,
|
||||
// auto-detected by the CLI; prefer the file form when AO provides one.
|
||||
if cfg.SystemPromptFile != "" {
|
||||
cmd = append(cmd, "--sys-prompt", cfg.SystemPromptFile)
|
||||
} else if cfg.SystemPrompt != "" {
|
||||
cmd = append(cmd, "--sys-prompt", cfg.SystemPrompt)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Autohand receives its prompt in the
|
||||
// launch command itself.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Autohand
|
||||
// session: `autohand resume [--path <workspace>] <sessionId>`. ok is false when
|
||||
// the hook-derived native session id has not landed yet, so callers can fall
|
||||
// back to fresh launch behavior. Autohand's resume sub-command does not accept
|
||||
// approval flags, so none are appended here.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.autohandBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 5)
|
||||
cmd = append(cmd, binary, "resume")
|
||||
appendWorkspaceFlag(&cmd, cfg.Session.WorkspacePath)
|
||||
cmd = append(cmd, agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces Autohand hook-derived metadata. Metadata is intentionally
|
||||
// nil: callers get the normalized fields directly.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[autohandTitleMetadataKey],
|
||||
Summary: session.Metadata[autohandSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// appendWorkspaceFlag scopes the run to the given workspace path via --path.
|
||||
func appendWorkspaceFlag(cmd *[]string, workspacePath string) {
|
||||
if strings.TrimSpace(workspacePath) != "" {
|
||||
*cmd = append(*cmd, "--path", workspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's four permission modes onto Autohand's approval
|
||||
// flags. Default emits no flag so Autohand resolves its starting mode from the
|
||||
// user's own config (permissions.mode). Autohand has no distinct "accept-edits"
|
||||
// mode, so it maps to --yes (auto-confirm risky actions) — the least-privileged
|
||||
// non-interactive option — while auto/bypass map to --unrestricted.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Autohand config/default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--yes")
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--unrestricted")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--unrestricted")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveAutohandBinary returns the path to the autohand binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (Homebrew, the
|
||||
// official ~/.local/bin installer, npm global). Returns "autohand" as a
|
||||
// last-ditch fallback so callers see a clear "command not found" rather than an
|
||||
// empty argv.
|
||||
func ResolveAutohandBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"autohand.cmd", "autohand.exe", "autohand"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "autohand.cmd"),
|
||||
filepath.Join(appData, "npm", "autohand.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".local", "bin", "autohand.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("autohand"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/autohand",
|
||||
"/opt/homebrew/bin/autohand",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "autohand"),
|
||||
filepath.Join(home, ".npm", "bin", "autohand"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) autohandBinary(ctx context.Context) (string, error) {
|
||||
// Honor cancellation even on the cached path, where ResolveAutohandBinary
|
||||
// (which has its own ctx.Err() guard) is never reached.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveAutohandBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
package autohand
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifestIDMatchesHarness(t *testing.T) {
|
||||
m := (&Plugin{}).Manifest()
|
||||
if m.ID != "autohand" {
|
||||
t.Fatalf("Manifest ID = %q, want %q", m.ID, "autohand")
|
||||
}
|
||||
if adapterID != "autohand" {
|
||||
t.Fatalf("adapterID = %q, want %q", adapterID, "autohand")
|
||||
}
|
||||
if len(m.Capabilities) != 1 || m.Capabilities[0] != "agent" {
|
||||
t.Fatalf("Capabilities = %#v, want [agent]", m.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBuildsArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-fix this",
|
||||
WorkspacePath: "/work/space",
|
||||
SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"),
|
||||
SystemPrompt: "ignored",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"autohand",
|
||||
"--path", "/work/space",
|
||||
"--unrestricted",
|
||||
"--sys-prompt", filepath.Join("tmp", "prompt with spaces.md"),
|
||||
"--", "-fix this",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandInlineSystemPrompt(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPrompt: "be terse",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"autohand", "--sys-prompt", "be terse"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission ports.PermissionMode
|
||||
want []string
|
||||
notExpected []string
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
permission: ports.PermissionModeDefault,
|
||||
notExpected: []string{"--unrestricted", "--yes", "--restricted"},
|
||||
},
|
||||
{
|
||||
name: "accept-edits",
|
||||
permission: ports.PermissionModeAcceptEdits,
|
||||
want: []string{"--yes"},
|
||||
notExpected: []string{"--unrestricted"},
|
||||
},
|
||||
{
|
||||
name: "auto",
|
||||
permission: ports.PermissionModeAuto,
|
||||
want: []string{"--unrestricted"},
|
||||
},
|
||||
{
|
||||
name: "bypass-permissions",
|
||||
permission: ports.PermissionModeBypassPermissions,
|
||||
want: []string{"--unrestricted"},
|
||||
},
|
||||
{
|
||||
name: "unknown falls back to default",
|
||||
permission: "frobnicate",
|
||||
notExpected: []string{"--unrestricted", "--yes", "--restricted"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range tt.want {
|
||||
if !contains(cmd, want) {
|
||||
t.Fatalf("command %#v missing %q", cmd, want)
|
||||
}
|
||||
}
|
||||
for _, missing := range tt.notExpected {
|
||||
if contains(cmd, missing) {
|
||||
t.Fatalf("command %#v contains %q", cmd, missing)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("unexpected config fields: %#v", spec.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: ports.SessionRef{
|
||||
WorkspacePath: "/work/space",
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "sess-123"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{"autohand", "resume", "--path", "/work/space", "sess-123"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ref ports.SessionRef
|
||||
}{
|
||||
{"empty session ref", ports.SessionRef{}},
|
||||
{"empty metadata", ports.SessionRef{Metadata: map[string]string{}}},
|
||||
{"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
|
||||
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: tc.ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("cmd = %#v, want nil", cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "sess-123",
|
||||
autohandTitleMetadataKey: "Fix login redirect",
|
||||
autohandSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("ok = false, want true")
|
||||
}
|
||||
if info.AgentSessionID != "sess-123" {
|
||||
t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q, want hook title", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q, want hook summary", info.Summary)
|
||||
}
|
||||
if info.Metadata != nil {
|
||||
t.Fatalf("Metadata = %#v, want nil", info.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero value", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellationIsRespected(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := plugin.GetConfigSpec(ctx); err == nil {
|
||||
t.Fatal("GetConfigSpec: want context error")
|
||||
}
|
||||
if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil {
|
||||
t.Fatal("GetPromptDeliveryStrategy: want context error")
|
||||
}
|
||||
if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil {
|
||||
t.Fatal("GetRestoreCommand: want context error")
|
||||
}
|
||||
if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil {
|
||||
t.Fatal("SessionInfo: want context error")
|
||||
}
|
||||
if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil {
|
||||
t.Fatal("GetAgentHooks: want context error")
|
||||
}
|
||||
// resolvedBinary is set, so this exercises the cached-binary path, which
|
||||
// must still honor cancellation.
|
||||
if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{}); err == nil {
|
||||
t.Fatal("GetLaunchCommand: want context error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAgentHooksPreservesUnknownEntryFields locks the round-trip behavior:
|
||||
// keys AO does not model on a user hook entry (here "async") must survive a
|
||||
// GetAgentHooks rewrite instead of being silently dropped.
|
||||
func TestGetAgentHooksPreservesUnknownEntryFields(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
t.Setenv("AUTOHAND_CONFIG", configPath)
|
||||
|
||||
existing := `{
|
||||
"hooks": {
|
||||
"enabled": false,
|
||||
"hooks": [
|
||||
{"event": "stop", "command": "~/.autohand/hooks/sound-alert.sh", "description": "user hook", "enabled": true, "async": true, "filter": {"glob": "*.go"}}
|
||||
]
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var top struct {
|
||||
Hooks struct {
|
||||
Hooks []map[string]json.RawMessage `json:"hooks"`
|
||||
} `json:"hooks"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &top); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var userEntry map[string]json.RawMessage
|
||||
for _, entry := range top.Hooks.Hooks {
|
||||
if string(entry["command"]) == `"~/.autohand/hooks/sound-alert.sh"` {
|
||||
userEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if userEntry == nil {
|
||||
t.Fatalf("user hook entry not found in %s", data)
|
||||
}
|
||||
if string(userEntry["async"]) != "true" {
|
||||
t.Fatalf("unknown field async dropped: %s", data)
|
||||
}
|
||||
filterRaw, ok := userEntry["filter"]
|
||||
if !ok {
|
||||
t.Fatalf("unknown field filter dropped: %s", data)
|
||||
}
|
||||
var filter map[string]string
|
||||
if err := json.Unmarshal(filterRaw, &filter); err != nil {
|
||||
t.Fatalf("filter not valid json: %v (%s)", err, filterRaw)
|
||||
}
|
||||
if filter["glob"] != "*.go" {
|
||||
t.Fatalf("unknown field filter not preserved: got %v in %s", filter, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
want domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"session start -> active", "session-start", domain.ActivityActive, true},
|
||||
{"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true},
|
||||
{"stop -> idle", "stop", domain.ActivityIdle, true},
|
||||
{"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true},
|
||||
{"unknown event -> no signal", "frobnicate", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksInstallsAndPreservesConfig(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
t.Setenv("AUTOHAND_CONFIG", configPath)
|
||||
|
||||
// Seed a config with unrelated keys plus a user hook; both must survive.
|
||||
existing := `{
|
||||
"provider": "openai",
|
||||
"auth": {"token": "keep-me"},
|
||||
"hooks": {
|
||||
"enabled": false,
|
||||
"hooks": [
|
||||
{"event": "stop", "command": "~/.autohand/hooks/sound-alert.sh", "description": "user hook", "enabled": true, "async": true}
|
||||
]
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: t.TempDir(),
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second install must not duplicate AO hook commands.
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Unrelated top-level config keys are preserved.
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &top); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(top["provider"]) != `"openai"` {
|
||||
t.Fatalf("provider not preserved: %s", top["provider"])
|
||||
}
|
||||
if _, ok := top["auth"]; !ok {
|
||||
t.Fatalf("auth block dropped: %s", data)
|
||||
}
|
||||
|
||||
_, hooksSection, entries := mustReadHooks(t, configPath)
|
||||
if string(hooksSection["enabled"]) != "true" {
|
||||
t.Fatalf("hooks.enabled = %s, want true", hooksSection["enabled"])
|
||||
}
|
||||
|
||||
for _, spec := range autohandManagedHooks {
|
||||
command := autohandHookCommandPrefix + spec.Subcommand
|
||||
if got := countCommand(entries, command); got != 1 {
|
||||
t.Fatalf("command %q count = %d, want 1 in %#v", command, got, entries)
|
||||
}
|
||||
}
|
||||
if countCommand(entries, "~/.autohand/hooks/sound-alert.sh") != 1 {
|
||||
t.Fatalf("user hook not preserved: %#v", entries)
|
||||
}
|
||||
|
||||
if installed, err := plugin.AreHooksInstalled(context.Background(), ""); err != nil || !installed {
|
||||
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesOnlyAOHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
t.Setenv("AUTOHAND_CONFIG", configPath)
|
||||
|
||||
existing := `{
|
||||
"hooks": {
|
||||
"enabled": false,
|
||||
"hooks": [
|
||||
{"event": "stop", "command": "~/.autohand/hooks/sound-alert.sh", "description": "user hook", "enabled": true}
|
||||
]
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: t.TempDir()}
|
||||
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, ""); err != nil || !installed {
|
||||
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
|
||||
}
|
||||
|
||||
if err := plugin.UninstallHooks(ctx, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, ""); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
|
||||
_, _, entries := mustReadHooks(t, configPath)
|
||||
for _, spec := range autohandManagedHooks {
|
||||
command := autohandHookCommandPrefix + spec.Subcommand
|
||||
if got := countCommand(entries, command); got != 0 {
|
||||
t.Fatalf("command %q count = %d after uninstall, want 0", command, got)
|
||||
}
|
||||
}
|
||||
if countCommand(entries, "~/.autohand/hooks/sound-alert.sh") != 1 {
|
||||
t.Fatalf("user hook not preserved after uninstall: %#v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksMissingFileIsNoOp(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
configPath := filepath.Join(t.TempDir(), "missing", "config.json")
|
||||
t.Setenv("AUTOHAND_CONFIG", configPath)
|
||||
|
||||
if err := plugin.UninstallHooks(context.Background(), ""); err != nil {
|
||||
t.Fatalf("UninstallHooks on missing file = %v, want nil", err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(context.Background(), ""); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled on missing file = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksCreatesConfigWhenAbsent(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
configPath := filepath.Join(t.TempDir(), "nested", "config.json")
|
||||
t.Setenv("AUTOHAND_CONFIG", configPath)
|
||||
|
||||
if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, hooksSection, entries := mustReadHooks(t, configPath)
|
||||
if string(hooksSection["enabled"]) != "true" {
|
||||
t.Fatalf("hooks.enabled = %s, want true", hooksSection["enabled"])
|
||||
}
|
||||
if len(entries) != len(autohandManagedHooks) {
|
||||
t.Fatalf("entry count = %d, want %d", len(entries), len(autohandManagedHooks))
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadHooks(t *testing.T, configPath string) (map[string]json.RawMessage, map[string]json.RawMessage, []autohandHookEntry) {
|
||||
t.Helper()
|
||||
top, section, entries, err := readAutohandHooks(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readAutohandHooks: %v", err)
|
||||
}
|
||||
return top, section, entries
|
||||
}
|
||||
|
||||
func countCommand(entries []autohandHookEntry, command string) int {
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.Command == command {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, v := range values {
|
||||
if v == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
package autohand
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
autohandConfigDirName = ".autohand"
|
||||
autohandConfigFileName = "config.json"
|
||||
|
||||
// autohandHookCommandPrefix identifies the hook commands AO owns, so
|
||||
// install skips duplicates and uninstall recognizes AO entries by prefix
|
||||
// without an embedded template to diff against.
|
||||
autohandHookCommandPrefix = "ao hooks autohand "
|
||||
autohandHookTimeout = 30
|
||||
)
|
||||
|
||||
// autohandManagedHookKeys are the entry keys AO owns. On marshal they are
|
||||
// written from the typed fields below; any other key the user set is preserved
|
||||
// from Extra. Keep in sync with the json tags on autohandHookEntry.
|
||||
var autohandManagedHookKeys = []string{"event", "command", "description", "enabled", "timeout"}
|
||||
|
||||
// autohandHookEntry is the on-disk shape of one entry in the config's
|
||||
// hooks.hooks array. AO owns the five typed fields; any other key the user set
|
||||
// on an entry (matcher, filter, async, ...) is captured in Extra so a rewrite
|
||||
// preserves fields AO does not own instead of silently dropping them.
|
||||
type autohandHookEntry struct {
|
||||
Event string `json:"event"`
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
|
||||
// Extra holds keys AO does not manage, captured on unmarshal and written
|
||||
// back on marshal so they round-trip. encoding/json does not support
|
||||
// `json:",inline"`, so the round-trip is implemented via the custom
|
||||
// UnmarshalJSON/MarshalJSON below.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the entry's typed fields and captures every key AO does
|
||||
// not manage into Extra, so a later MarshalJSON can write them back verbatim.
|
||||
func (e *autohandHookEntry) UnmarshalJSON(data []byte) error {
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decode the managed fields via a type alias to avoid recursing into this
|
||||
// method, then drop the managed keys so Extra holds only unknown ones.
|
||||
type managedAlias autohandHookEntry
|
||||
var managed managedAlias
|
||||
if err := json.Unmarshal(data, &managed); err != nil {
|
||||
return err
|
||||
}
|
||||
*e = autohandHookEntry(managed)
|
||||
|
||||
for _, key := range autohandManagedHookKeys {
|
||||
delete(raw, key)
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
e.Extra = raw
|
||||
} else {
|
||||
e.Extra = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes AO's managed fields merged with any preserved unknown keys
|
||||
// from Extra. Managed fields win on key collision so AO's values stay
|
||||
// authoritative.
|
||||
func (e autohandHookEntry) MarshalJSON() ([]byte, error) {
|
||||
out := make(map[string]json.RawMessage, len(e.Extra)+len(autohandManagedHookKeys))
|
||||
for key, val := range e.Extra {
|
||||
out[key] = val
|
||||
}
|
||||
|
||||
type managedAlias autohandHookEntry
|
||||
managedJSON, err := json.Marshal(managedAlias(e))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var managed map[string]json.RawMessage
|
||||
if err := json.Unmarshal(managedJSON, &managed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, val := range managed {
|
||||
out[key] = val
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// autohandHookSpec describes one hook AO installs. Event is Autohand's native
|
||||
// lifecycle event name; Subcommand is the AO hook sub-command appended after the
|
||||
// command prefix (and the value DeriveActivityState switches on).
|
||||
type autohandHookSpec struct {
|
||||
Event string
|
||||
Subcommand string
|
||||
}
|
||||
|
||||
// autohandManagedHooks is the source of truth for the hooks AO installs. Each
|
||||
// native Autohand event is routed to the AO sub-command DeriveActivityState
|
||||
// understands. Autohand's pre-prompt event is the user-prompt-submit signal.
|
||||
var autohandManagedHooks = []autohandHookSpec{
|
||||
{Event: "session-start", Subcommand: "session-start"},
|
||||
{Event: "pre-prompt", Subcommand: "user-prompt-submit"},
|
||||
{Event: "permission-request", Subcommand: "permission-request"},
|
||||
{Event: "stop", Subcommand: "stop"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Autohand hooks into the Autohand config's
|
||||
// hooks.hooks array. Existing user hooks are preserved and duplicate AO commands
|
||||
// are not appended. The rest of the config (auth, provider, ...) is preserved
|
||||
// byte-for-byte because only the hooks section is decoded and rewritten.
|
||||
//
|
||||
// Autohand loads hooks from a single config file (default ~/.autohand/config.json,
|
||||
// overridable via AUTOHAND_CONFIG); it does not merge a workspace-local file at
|
||||
// runtime, so AO installs into that config rather than a per-workspace file. The
|
||||
// AUTOHAND_CONFIG env var, when set, takes precedence so AO and the agent agree
|
||||
// on the target.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := autohandConfigPath()
|
||||
topLevel, hooksSection, entries, err := readAutohandHooks(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autohand.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
for _, spec := range autohandManagedHooks {
|
||||
command := autohandHookCommandPrefix + spec.Subcommand
|
||||
if autohandHookCommandExists(entries, command) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, autohandHookEntry{
|
||||
Event: spec.Event,
|
||||
Command: command,
|
||||
Description: "AO activity hook",
|
||||
Enabled: true,
|
||||
Timeout: autohandHookTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// Autohand only fires hooks when the hooks section is enabled.
|
||||
hooksSection["enabled"] = json.RawMessage(`true`)
|
||||
|
||||
if err := writeAutohandHooks(configPath, topLevel, hooksSection, entries); err != nil {
|
||||
return fmt.Errorf("autohand.GetAgentHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Autohand hooks from the config's hooks.hooks
|
||||
// array, leaving user-defined hooks and the rest of the config untouched. A
|
||||
// missing file is a no-op. The hooks.enabled flag is left in place because it
|
||||
// enables every Autohand hook, not just AO's.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, _ string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := autohandConfigPath()
|
||||
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, hooksSection, entries, err := readAutohandHooks(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autohand.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
kept := make([]autohandHookEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !isAutohandManagedHook(entry.Command) {
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeAutohandHooks(configPath, topLevel, hooksSection, kept); err != nil {
|
||||
return fmt.Errorf("autohand.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Autohand hook is present in the
|
||||
// config. A missing file means none are installed.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, _ string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
configPath := autohandConfigPath()
|
||||
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, _, entries, err := readAutohandHooks(configPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("autohand.AreHooksInstalled: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if isAutohandManagedHook(entry.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// autohandConfigPath returns the config file Autohand loads hooks from: the
|
||||
// AUTOHAND_CONFIG override if set, else ~/.autohand/config.json.
|
||||
func autohandConfigPath() string {
|
||||
if env := strings.TrimSpace(os.Getenv("AUTOHAND_CONFIG")); env != "" {
|
||||
return env
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Fall back to a relative path; callers surface the resulting error.
|
||||
return filepath.Join(autohandConfigDirName, autohandConfigFileName)
|
||||
}
|
||||
return filepath.Join(home, autohandConfigDirName, autohandConfigFileName)
|
||||
}
|
||||
|
||||
// readAutohandHooks loads the config into a top-level raw map, the decoded
|
||||
// "hooks" section (preserving keys AO doesn't manage such as "enabled"), and the
|
||||
// decoded hooks array. A missing or empty file yields empty maps and a nil
|
||||
// slice.
|
||||
func readAutohandHooks(configPath string) (topLevel, hooksSection map[string]json.RawMessage, entries []autohandHookEntry, err error) {
|
||||
topLevel = map[string]json.RawMessage{}
|
||||
hooksSection = map[string]json.RawMessage{}
|
||||
|
||||
data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return topLevel, hooksSection, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("read %s: %w", configPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return topLevel, hooksSection, nil, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("parse %s: %w", configPath, err)
|
||||
}
|
||||
if hooksRaw, ok := topLevel["hooks"]; ok {
|
||||
if err := json.Unmarshal(hooksRaw, &hooksSection); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("parse hooks in %s: %w", configPath, err)
|
||||
}
|
||||
}
|
||||
if arrRaw, ok := hooksSection["hooks"]; ok {
|
||||
if err := json.Unmarshal(arrRaw, &entries); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("parse hooks array in %s: %w", configPath, err)
|
||||
}
|
||||
}
|
||||
return topLevel, hooksSection, entries, nil
|
||||
}
|
||||
|
||||
// writeAutohandHooks folds the entries back into the hooks section, the hooks
|
||||
// section back into topLevel, and writes the file atomically. An empty entries
|
||||
// slice drops the "hooks" array key.
|
||||
func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]json.RawMessage, entries []autohandHookEntry) error {
|
||||
if len(entries) == 0 {
|
||||
delete(hooksSection, "hooks")
|
||||
} else {
|
||||
arrJSON, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hooks array: %w", err)
|
||||
}
|
||||
hooksSection["hooks"] = arrJSON
|
||||
}
|
||||
|
||||
if len(hooksSection) == 0 {
|
||||
delete(topLevel, "hooks")
|
||||
} else {
|
||||
hooksJSON, err := json.Marshal(hooksSection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hooks section: %w", err)
|
||||
}
|
||||
topLevel["hooks"] = hooksJSON
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(topLevel, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", configPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := atomicWriteFile(configPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", configPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
|
||||
// write can't leave a truncated/empty config that Autohand then fails to parse.
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func isAutohandManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, autohandHookCommandPrefix)
|
||||
}
|
||||
|
||||
func autohandHookCommandExists(entries []autohandHookEntry, command string) bool {
|
||||
for _, entry := range entries {
|
||||
if entry.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package claudecode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// DeriveActivityState maps a Claude Code hook event (and its native stdin
|
||||
// payload) onto an AO activity state. The bool is false when the event carries
|
||||
// no activity signal — e.g. SessionStart (metadata only, v1), a Notification
|
||||
// type we don't track, or a SessionEnd reason that doesn't actually end the AO
|
||||
// session — in which case the caller reports nothing.
|
||||
//
|
||||
// event is the AO hook sub-command name installed in claudeManagedHooks
|
||||
// ("user-prompt-submit", "stop", "notification", "session-end", ...), NOT the
|
||||
// native Claude event name. Keeping this beside hooks.go means the events AO
|
||||
// installs and what they mean live in one place.
|
||||
func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) {
|
||||
switch event {
|
||||
case "user-prompt-submit":
|
||||
return domain.ActivityActive, true
|
||||
case "stop":
|
||||
// End of a turn: the agent is idle but alive (not exited). A following
|
||||
// Notification(idle_prompt) upgrades this to the sticky waiting_input.
|
||||
return domain.ActivityIdle, true
|
||||
case "notification":
|
||||
return notificationState(payload)
|
||||
case "session-end":
|
||||
return sessionEndState(payload)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// notificationState reports waiting_input only for the notification types that
|
||||
// mean "the agent is blocked on the user": a pending tool-permission prompt or
|
||||
// an idle prompt awaiting the next instruction. Other types (auth_success,
|
||||
// elicitation_*) carry no activity meaning, as does a malformed payload.
|
||||
func notificationState(payload []byte) (domain.ActivityState, bool) {
|
||||
var p struct {
|
||||
NotificationType string `json:"notification_type"`
|
||||
}
|
||||
_ = json.Unmarshal(payload, &p)
|
||||
switch p.NotificationType {
|
||||
case "idle_prompt", "permission_prompt":
|
||||
return domain.ActivityWaitingInput, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// sessionEndState reports exited for reasons that actually end the session.
|
||||
// clear/resume keep the same AO session alive (a new native session continues
|
||||
// in the worktree), so they report nothing. Any other reason — logout,
|
||||
// prompt_input_exit, bypass_permissions_disabled, other, or an absent/unknown
|
||||
// reason on a SessionEnd that did fire — is treated as a real exit. SessionEnd
|
||||
// is not guaranteed on crash/SIGKILL, so the reaper remains the backstop; both
|
||||
// paths guard on IsTerminated, so whichever lands first wins.
|
||||
func sessionEndState(payload []byte) (domain.ActivityState, bool) {
|
||||
var p struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = json.Unmarshal(payload, &p)
|
||||
switch p.Reason {
|
||||
case "clear", "resume":
|
||||
return "", false
|
||||
default:
|
||||
return domain.ActivityExited, true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package claudecode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
payload string
|
||||
want domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true},
|
||||
{"stop -> idle", "stop", `{}`, domain.ActivityIdle, true},
|
||||
{"notification idle_prompt -> waiting_input", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityWaitingInput, true},
|
||||
{"notification permission_prompt -> waiting_input", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityWaitingInput, true},
|
||||
{"notification auth_success -> no signal", "notification", `{"notification_type":"auth_success"}`, "", false},
|
||||
{"notification empty type -> no signal", "notification", `{}`, "", false},
|
||||
{"notification malformed payload -> no signal", "notification", `not json`, "", false},
|
||||
{"session-end logout -> exited", "session-end", `{"reason":"logout"}`, domain.ActivityExited, true},
|
||||
{"session-end prompt_input_exit -> exited", "session-end", `{"reason":"prompt_input_exit"}`, domain.ActivityExited, true},
|
||||
{"session-end other -> exited", "session-end", `{"reason":"other"}`, domain.ActivityExited, true},
|
||||
{"session-end absent reason -> exited", "session-end", `{}`, domain.ActivityExited, true},
|
||||
{"session-end clear -> no signal", "session-end", `{"reason":"clear"}`, "", false},
|
||||
{"session-end resume -> no signal", "session-end", `{"reason":"resume"}`, "", false},
|
||||
{"session-start -> no signal", "session-start", `{}`, "", false},
|
||||
{"unknown event -> no signal", "frobnicate", `{}`, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, []byte(tt.payload))
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q, %q) = (%q, %v), want (%q, %v)",
|
||||
tt.event, tt.payload, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,494 @@
|
|||
// Package claudecode implements the Claude Code agent adapter.
|
||||
//
|
||||
// It builds the argv to launch `claude` as an interactive session inside a
|
||||
// session's worktree, installs worktree-local hooks that report normalized
|
||||
// session metadata (native id, title, summary) back into AO's store,
|
||||
// and supports resume: GetLaunchCommand pins a stable `--session-id` so
|
||||
// GetRestoreCommand can rebuild `claude --resume <uuid>`. SessionInfo reads the
|
||||
// hook-captured metadata from the store — it does not parse transcripts.
|
||||
// GetConfigSpec remains a no-op (no agent-specific config keys yet).
|
||||
//
|
||||
// Claude Code starts an interactive session by default (no -p/--print), which
|
||||
// is exactly what AO wants: a live agent the user can attach to in the
|
||||
// browser terminal or via `zellij attach`. The initial task prompt is passed
|
||||
// as the positional argument; the orchestrator system prompt (if any) is
|
||||
// appended to Claude's default system prompt so its built-in coding
|
||||
// instructions are preserved.
|
||||
package claudecode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
// adapterID is the registry id and the value users pass to
|
||||
// `ao spawn --agent`.
|
||||
adapterID = "claude-code"
|
||||
)
|
||||
|
||||
// claudeSessionNamespace seeds the UUIDv5 derivation that maps an AO
|
||||
// session id onto a stable Claude Code `--session-id`. A fixed namespace makes
|
||||
// the mapping deterministic, so GetLaunchCommand (which pins --session-id at
|
||||
// launch) and GetRestoreCommand (which recomputes it as a fallback for
|
||||
// pre-hook sessions) agree without persisting anything.
|
||||
var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4c")
|
||||
|
||||
// Plugin is the Claude Code agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Claude Code adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Claude Code",
|
||||
Description: "Run Claude Code worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// permissionConfigEnum lists the permission modes the "permissions" config key
|
||||
// accepts. It mirrors the ports.PermissionMode constants so a project's stored
|
||||
// config validates against the same vocabulary the launch command maps.
|
||||
var permissionConfigEnum = []string{
|
||||
string(ports.PermissionModeDefault),
|
||||
string(ports.PermissionModeAcceptEdits),
|
||||
string(ports.PermissionModeAuto),
|
||||
string(ports.PermissionModeBypassPermissions),
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the per-project agent config keys Claude Code
|
||||
// understands: a model override and a starting permission mode.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{
|
||||
Fields: []ports.ConfigField{
|
||||
{
|
||||
Key: "model",
|
||||
Type: ports.ConfigFieldString,
|
||||
Description: "Model override passed to `claude --model` (e.g. claude-opus-4-5).",
|
||||
},
|
||||
{
|
||||
Key: "permissions",
|
||||
Type: ports.ConfigFieldEnum,
|
||||
Description: "Starting permission mode.",
|
||||
Enum: permissionConfigEnum,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start an interactive Claude Code
|
||||
// session. Shape:
|
||||
//
|
||||
// claude [--session-id <uuid>] \
|
||||
// [--permission-mode <mode>] \
|
||||
// [--append-system-prompt <system prompt>] \
|
||||
// [-- <prompt>]
|
||||
//
|
||||
// --session-id pins Claude's native session UUID to a value derived from the
|
||||
// AO session id, so the session is resumable later (see
|
||||
// GetRestoreCommand) and its transcript is locatable (see SessionInfo) without
|
||||
// a separate capture step.
|
||||
//
|
||||
// <mode> is acceptEdits, auto, or bypassPermissions. AO's "default"
|
||||
// mode emits no --permission-mode flag, so Claude's TUI resolves the starting
|
||||
// mode from ~/.claude/settings.json exactly as a normal launch.
|
||||
//
|
||||
// The prompt is passed after `--` so a prompt beginning with "-" is not
|
||||
// mistaken for a flag.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
// Defense-in-depth: the project service validates on write, but re-check
|
||||
// here so a config written by any other path can't launch a bad command.
|
||||
if err := cfg.Config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("claude-code: %w", err)
|
||||
}
|
||||
|
||||
binary, err := p.claudeBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
if cfg.SessionID != "" {
|
||||
cmd = append(cmd, "--session-id", claudeSessionUUID(cfg.SessionID))
|
||||
}
|
||||
// A project's configured permissions drive the starting mode; the explicit
|
||||
// LaunchConfig.Permissions wins when set so a per-spawn override still takes
|
||||
// precedence over the stored project default.
|
||||
permissions := cfg.Permissions
|
||||
if permissions == "" {
|
||||
permissions = cfg.Config.Permissions
|
||||
}
|
||||
appendPermissionFlags(&cmd, permissions)
|
||||
|
||||
if model := strings.TrimSpace(cfg.Config.Model); model != "" {
|
||||
cmd = append(cmd, "--model", model)
|
||||
}
|
||||
|
||||
systemPrompt, err := resolveSystemPrompt(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if systemPrompt != "" {
|
||||
// Append rather than replace: Claude Code's default system prompt
|
||||
// carries its tool-use and coding instructions, which we want to
|
||||
// keep. The orchestrator prompt layers on top.
|
||||
cmd = append(cmd, "--append-system-prompt", systemPrompt)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Claude Code receives its prompt in the
|
||||
// launch command itself.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// PreLaunch is an optional capability the spawn engine invokes (via type
|
||||
// assertion) immediately before creating the session. Claude Code shows a
|
||||
// blocking "do you trust this folder?" dialog the first time it runs in any
|
||||
// directory. Every AO worktree is a fresh path, so without this the
|
||||
// agent would hang at that prompt with no one to answer it.
|
||||
//
|
||||
// An AO worktree is derived from the repo the user is already running
|
||||
// AO in, so it is inherently trusted. PreLaunch records that trust in
|
||||
// ~/.claude.json before launch, additively and atomically, so it cannot
|
||||
// clobber a concurrently-running Claude instance's config.
|
||||
func (p *Plugin) PreLaunch(ctx context.Context, cfg ports.LaunchConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.WorkspacePath == "" {
|
||||
return nil
|
||||
}
|
||||
cfgPath, err := claudeConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureWorkspaceTrusted(cfgPath, cfg.WorkspacePath)
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Claude Code
|
||||
// session: `claude [--permission-mode <mode>] --resume <agentSessionId>`. It
|
||||
// prefers the hook-captured native session id from
|
||||
// cfg.Session.Metadata["agentSessionId"]; for sessions created before hooks
|
||||
// captured it, it falls back to the deterministic UUID AO pins via
|
||||
// --session-id at launch. ok is false only when neither is available, so the
|
||||
// caller fresh-spawns. The command re-applies the permission mode (resume
|
||||
// otherwise reverts to the configured default) but not the prompt/system
|
||||
// prompt, which the session already carries.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
sessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if sessionID == "" && cfg.Session.ID != "" {
|
||||
// Explicit fallback for pre-hook sessions: the id AO
|
||||
// deterministically pinned via --session-id at launch.
|
||||
sessionID = claudeSessionUUID(cfg.Session.ID)
|
||||
}
|
||||
if sessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.claudeBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
cmd = make([]string, 0, 7)
|
||||
cmd = append(cmd, binary)
|
||||
appendPermissionFlags(&cmd, cfg.Permissions)
|
||||
if cfg.SystemPrompt != "" {
|
||||
// --resume rebuilds the system prompt from the current flags (it is
|
||||
// not stored in the transcript), so standing instructions must be
|
||||
// re-appended or a restored orchestrator loses its role.
|
||||
cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt)
|
||||
}
|
||||
cmd = append(cmd, "--resume", sessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces the normalized session metadata that the Claude Code
|
||||
// hooks persisted into AO's store: the native session id, the title (the
|
||||
// first user prompt), and the summary (the final assistant message). It reads
|
||||
// only from session.Metadata — never from transcript files — and returns
|
||||
// ok=false when none of those fields are present. Metadata is intentionally nil:
|
||||
// there is no Claude-specific field callers need beyond the normalized ones.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[ports.MetadataKeyTitle],
|
||||
Summary: session.Metadata[ports.MetadataKeySummary],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// claudeSessionUUID maps an AO session id onto a stable Claude Code
|
||||
// session UUID via UUIDv5 over a fixed namespace, so the same AO session
|
||||
// always resolves to the same Claude session.
|
||||
func claudeSessionUUID(aoSessionID string) string {
|
||||
return uuid.NewSHA1(claudeSessionNamespace, []byte(aoSessionID)).String()
|
||||
}
|
||||
|
||||
// resolveSystemPrompt returns the system prompt text to append, preferring
|
||||
// SystemPromptFile (read from disk) over an inline SystemPrompt.
|
||||
func resolveSystemPrompt(cfg ports.LaunchConfig) (string, error) {
|
||||
if cfg.SystemPromptFile != "" {
|
||||
data, err := os.ReadFile(cfg.SystemPromptFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("claude-code: read system prompt file: %w", err)
|
||||
}
|
||||
return strings.TrimRight(string(data), "\n"), nil
|
||||
}
|
||||
return cfg.SystemPrompt, nil
|
||||
}
|
||||
|
||||
// appendPermissionFlags maps AO's permission modes onto Claude Code's
|
||||
// --permission-mode values:
|
||||
// - default → no flag. Claude's TUI resolves the starting mode
|
||||
// from ~/.claude/settings.json (defaultMode), exactly as a normal launch.
|
||||
// - accept-edits → --permission-mode acceptEdits (auto-accept edits +
|
||||
// safe filesystem bash; still prompts for network/system bash, MCP, web)
|
||||
// - auto → --permission-mode auto (classifier-gated
|
||||
// auto-approval; auto-runs what a safety model deems safe)
|
||||
// - bypass-permissions → --permission-mode bypassPermissions (skip all
|
||||
// checks; equivalent to --dangerously-skip-permissions)
|
||||
//
|
||||
// Empty/unrecognized normalizes to default, so no flag is emitted.
|
||||
func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's settings.json defaultMode.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--permission-mode", "acceptEdits")
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--permission-mode", "auto")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--permission-mode", "bypassPermissions")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
// Empty or unrecognized: defer to settings.json (no flag).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveClaudeBinary finds the `claude` binary, searching PATH then a few
|
||||
// well-known install locations (the native installer's ~/.local/bin, npm
|
||||
// global, Homebrew). Returns "claude" as a last resort so callers get a
|
||||
// clear "command not found" rather than an empty argv.
|
||||
func ResolveClaudeBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"claude.cmd", "claude.exe", "claude"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "claude.cmd"),
|
||||
filepath.Join(appData, "npm", "claude.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("claude"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "claude"),
|
||||
filepath.Join(home, ".npm", "bin", "claude"),
|
||||
filepath.Join(home, ".claude", "local", "claude"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) claudeBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveClaudeBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
// claudeConfigPath returns the path to Claude Code's global config file,
|
||||
// ~/.claude.json.
|
||||
func claudeConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("claude-code: resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".claude.json"), nil
|
||||
}
|
||||
|
||||
// ensureWorkspaceTrusted records workspacePath as trusted in Claude Code's
|
||||
// config so the interactive trust dialog does not block a spawned session.
|
||||
//
|
||||
// It is additive and concurrency-safe: it reads the existing config, sets
|
||||
// only projects[workspacePath].hasTrustDialogAccepted = true (preserving the
|
||||
// rest of the entry and every other project), and writes back via a
|
||||
// temp-file + atomic rename. If the path is already trusted, it makes no
|
||||
// write at all. A missing config file is treated as an empty one.
|
||||
// claudeTrustMu serializes ensureWorkspaceTrusted within the process. Concurrent
|
||||
// spawns to different workspaces otherwise read the same ~/.claude.json snapshot
|
||||
// and the last rename drops the other's trust entry.
|
||||
var claudeTrustMu sync.Mutex
|
||||
|
||||
func ensureWorkspaceTrusted(configPath, workspacePath string) error {
|
||||
claudeTrustMu.Lock()
|
||||
defer claudeTrustMu.Unlock()
|
||||
|
||||
root := map[string]any{}
|
||||
data, err := os.ReadFile(configPath)
|
||||
switch {
|
||||
case err == nil:
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return fmt.Errorf("claude-code: parse %s: %w", configPath, err)
|
||||
}
|
||||
}
|
||||
case os.IsNotExist(err):
|
||||
// Treat as empty config; we'll create it.
|
||||
default:
|
||||
return fmt.Errorf("claude-code: read %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
projects, _ := root["projects"].(map[string]any)
|
||||
if projects == nil {
|
||||
projects = map[string]any{}
|
||||
root["projects"] = projects
|
||||
}
|
||||
|
||||
entry, _ := projects[workspacePath].(map[string]any)
|
||||
if entry == nil {
|
||||
entry = map[string]any{}
|
||||
projects[workspacePath] = entry
|
||||
}
|
||||
|
||||
if trusted, ok := entry["hasTrustDialogAccepted"].(bool); ok && trusted {
|
||||
// Already trusted — no write needed, so no race window at all.
|
||||
return nil
|
||||
}
|
||||
entry["hasTrustDialogAccepted"] = true
|
||||
|
||||
out, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("claude-code: encode %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
// Atomic write: temp file in the same directory, then rename. Matches
|
||||
// how Claude Code itself updates this file, so concurrent updates are
|
||||
// last-writer-wins rather than corrupting.
|
||||
dir := filepath.Dir(configPath)
|
||||
tmp, err := os.CreateTemp(dir, ".claude.json.tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("claude-code: create temp config: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
|
||||
|
||||
if _, err := tmp.Write(out); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("claude-code: write temp config: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("claude-code: close temp config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, configPath); err != nil {
|
||||
return fmt.Errorf("claude-code: replace config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,609 @@
|
|||
package claudecode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestGetLaunchCommandBypassWithPrompt(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-add a health check",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"claude",
|
||||
"--permission-mode", "bypassPermissions",
|
||||
"--", "-add a health check",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission ports.PermissionMode
|
||||
want []string
|
||||
notExpected string
|
||||
}{
|
||||
{"default omits flag (defers to settings.json)", ports.PermissionModeDefault, nil, "--permission-mode"},
|
||||
{"accept-edits", ports.PermissionModeAcceptEdits, []string{"--permission-mode", "acceptEdits"}, ""},
|
||||
{"auto", ports.PermissionModeAuto, []string{"--permission-mode", "auto"}, ""},
|
||||
{"bypass-permissions", ports.PermissionModeBypassPermissions, []string{"--permission-mode", "bypassPermissions"}, ""},
|
||||
{"empty omits permission flags", "", nil, "--permission-mode"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) {
|
||||
t.Fatalf("command %#v does not contain %#v", cmd, tt.want)
|
||||
}
|
||||
if tt.notExpected != "" && contains(cmd, tt.notExpected) {
|
||||
t.Fatalf("command %#v unexpectedly contains %q", cmd, tt.notExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAppendsSystemPromptFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
promptFile := filepath.Join(dir, "system.md")
|
||||
if err := os.WriteFile(promptFile, []byte("You are an orchestrator.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPromptFile: promptFile,
|
||||
Prompt: "do the thing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"claude",
|
||||
"--append-system-prompt", "You are an orchestrator.",
|
||||
"--", "do the thing",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandInlineSystemPrompt(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPrompt: "inline instructions",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !containsSubsequence(cmd, []string{"--append-system-prompt", "inline instructions"}) {
|
||||
t.Fatalf("command %#v does not append inline system prompt", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMissingSystemPromptFileErrors(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
_, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SystemPromptFile: filepath.Join(t.TempDir(), "does-not-exist.md"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing system prompt file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandInjectsSessionID(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
SessionID: "e0tt49",
|
||||
Prompt: "do the thing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantUUID := claudeSessionUUID("e0tt49")
|
||||
if !containsSubsequence(cmd, []string{"--session-id", wantUUID}) {
|
||||
t.Fatalf("command %#v missing --session-id %q", cmd, wantUUID)
|
||||
}
|
||||
|
||||
// No SessionID → no --session-id flag.
|
||||
cmd, err = p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(cmd, "--session-id") {
|
||||
t.Fatalf("command %#v unexpectedly contains --session-id", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSessionUUIDDeterministicAndUnique(t *testing.T) {
|
||||
a1 := claudeSessionUUID("alpha")
|
||||
a2 := claudeSessionUUID("alpha")
|
||||
b := claudeSessionUUID("beta")
|
||||
if a1 != a2 {
|
||||
t.Fatalf("derivation not deterministic: %q != %q", a1, a2)
|
||||
}
|
||||
if a1 == b {
|
||||
t.Fatalf("distinct ids collided: both %q", a1)
|
||||
}
|
||||
if _, err := uuid.Parse(a1); err != nil {
|
||||
t.Fatalf("derived value is not a valid UUID: %q (%v)", a1, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
workspace := t.TempDir()
|
||||
settingsDir := filepath.Join(workspace, ".claude")
|
||||
if err := os.MkdirAll(settingsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settingsPath := filepath.Join(settingsDir, "settings.local.json")
|
||||
// Pre-seed a user's own Stop hook + an unrelated setting; both must survive.
|
||||
existing := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my own stop hook","timeout":5}]}]},"permissions":{"defaultMode":"plan"}}`
|
||||
if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
|
||||
if err := p.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second install must not duplicate AO hook commands.
|
||||
if err := p.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config struct {
|
||||
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Hooks == nil {
|
||||
t.Fatalf("hooks object missing: %s", data)
|
||||
}
|
||||
|
||||
// Every managed command is installed exactly once under its event.
|
||||
for _, spec := range claudeManagedHooks {
|
||||
if got := countClaudeHookCommand(config.Hooks[spec.Event], spec.Command); got != 1 {
|
||||
t.Fatalf("%s command %q count = %d, want 1", spec.Event, spec.Command, got)
|
||||
}
|
||||
}
|
||||
// Existing user hook preserved.
|
||||
if countClaudeHookCommand(config.Hooks["Stop"], "my own stop hook") != 1 {
|
||||
t.Fatalf("existing Stop hook not preserved: %#v", config.Hooks["Stop"])
|
||||
}
|
||||
// Unrelated settings preserved.
|
||||
if len(config.Permissions) == 0 {
|
||||
t.Fatalf("unrelated settings clobbered: %s", data)
|
||||
}
|
||||
// SessionStart carries the required matcher; UserPromptSubmit omits it.
|
||||
if m := matcherForCommand(config.Hooks["SessionStart"], "ao hooks claude-code session-start"); m == nil || *m != "startup" {
|
||||
t.Fatalf("SessionStart matcher = %v, want startup", m)
|
||||
}
|
||||
if m := matcherForCommand(config.Hooks["UserPromptSubmit"], "ao hooks claude-code user-prompt-submit"); m != nil {
|
||||
t.Fatalf("UserPromptSubmit matcher = %v, want none", m)
|
||||
}
|
||||
// Notification and SessionEnd install with no matcher (they fire for all
|
||||
// sub-types; the handler filters on the payload).
|
||||
if m := matcherForCommand(config.Hooks["Notification"], "ao hooks claude-code notification"); m != nil {
|
||||
t.Fatalf("Notification matcher = %v, want none", m)
|
||||
}
|
||||
if m := matcherForCommand(config.Hooks["SessionEnd"], "ao hooks claude-code session-end"); m != nil {
|
||||
t.Fatalf("SessionEnd matcher = %v, want none", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesClaudeHooks(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
workspace := t.TempDir()
|
||||
settingsPath := filepath.Join(workspace, ".claude", "settings.local.json")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
|
||||
|
||||
// Pre-seed a user's own Stop hook + an unrelated setting; both must survive.
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"my own stop hook","timeout":5}]}]},"permissions":{"defaultMode":"plan"}}`
|
||||
if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := p.GetAgentHooks(ctx, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := p.AreHooksInstalled(ctx, workspace); err != nil || !installed {
|
||||
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
|
||||
}
|
||||
|
||||
if err := p.UninstallHooks(ctx, workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := p.AreHooksInstalled(ctx, workspace); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config struct {
|
||||
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No managed command survives; the SessionStart/UserPromptSubmit events,
|
||||
// which held only AO hooks, are removed entirely.
|
||||
for _, spec := range claudeManagedHooks {
|
||||
if got := countClaudeHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 {
|
||||
t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got)
|
||||
}
|
||||
}
|
||||
// The user's own Stop hook and unrelated settings are preserved.
|
||||
if countClaudeHookCommand(config.Hooks["Stop"], "my own stop hook") != 1 {
|
||||
t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"])
|
||||
}
|
||||
if len(config.Permissions) == 0 {
|
||||
t.Fatalf("unrelated settings clobbered: %s", data)
|
||||
}
|
||||
|
||||
// Uninstall is idempotent: a second call is a clean no-op.
|
||||
if err := p.UninstallHooks(ctx, workspace); err != nil {
|
||||
t.Fatalf("second uninstall: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksNoSettingsFile(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
workspace := t.TempDir()
|
||||
if err := p.UninstallHooks(context.Background(), workspace); err != nil {
|
||||
t.Fatalf("uninstall with no settings file: %v", err)
|
||||
}
|
||||
if installed, err := p.AreHooksInstalled(context.Background(), workspace); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
info, ok, err := (&Plugin{resolvedBinary: "claude"}).SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "claude-native-1",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("SessionInfo = (ok=%v, err=%v), want ok", ok, err)
|
||||
}
|
||||
if info.AgentSessionID != "claude-native-1" {
|
||||
t.Fatalf("AgentSessionID = %q", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q", info.Summary)
|
||||
}
|
||||
if info.Metadata != nil {
|
||||
t.Fatalf("Metadata = %#v, want nil for Claude", info.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
info, ok, err := (&Plugin{resolvedBinary: "claude"}).SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero", info)
|
||||
}
|
||||
}
|
||||
|
||||
// countClaudeHookCommand counts how many hook entries under one event register
|
||||
// the given command — used to prove no duplicate AO hooks.
|
||||
func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int {
|
||||
count := 0
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// matcherForCommand returns the matcher on the group that registers the given
|
||||
// command (nil if the group has no matcher).
|
||||
func matcherForCommand(groups []claudeMatcherGroup, command string) *string {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return group.Matcher
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Session: ports.SessionRef{
|
||||
ID: "sess-r",
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "claude-native-1"},
|
||||
},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err)
|
||||
}
|
||||
// The hook-captured native id wins over the derived fallback.
|
||||
want := []string{"claude", "--permission-mode", "bypassPermissions", "--resume", "claude-native-1"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReappendsSystemPrompt(t *testing.T) {
|
||||
// --resume rebuilds the system prompt from flags, so standing instructions
|
||||
// (e.g. the orchestrator role) must be re-appended on restore.
|
||||
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
SystemPrompt: "You are an orchestrator.",
|
||||
Session: ports.SessionRef{
|
||||
ID: "sess-r",
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "claude-native-1"},
|
||||
},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err)
|
||||
}
|
||||
want := []string{"claude", "--permission-mode", "bypassPermissions", "--append-system-prompt", "You are an orchestrator.", "--resume", "claude-native-1"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFallsBackToDerivedUUID(t *testing.T) {
|
||||
// No agentSessionId captured (pre-hook session) → derive deterministically
|
||||
// from the AO session id, the explicit fallback.
|
||||
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Session: ports.SessionRef{ID: "sess-r"},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err)
|
||||
}
|
||||
want := []string{"claude", "--permission-mode", "bypassPermissions", "--resume", claudeSessionUUID("sess-r")}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFalseWithoutSessionID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ref ports.SessionRef
|
||||
}{
|
||||
{"empty ref", ports.SessionRef{}},
|
||||
{"blank agent session, no id", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
|
||||
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(),
|
||||
ports.RestoreConfig{Permissions: ports.PermissionModeBypassPermissions, Session: tc.ref})
|
||||
if err != nil || ok || cmd != nil {
|
||||
t.Fatalf("restore = (%#v, %v, %v), want (nil,false,nil)", cmd, ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAppliesAgentConfig(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Config: ports.AgentConfig{
|
||||
Model: "claude-opus-4-5",
|
||||
Permissions: ports.PermissionModeAcceptEdits,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !containsSubsequence(cmd, []string{"--model", "claude-opus-4-5"}) {
|
||||
t.Fatalf("command %#v missing --model flag", cmd)
|
||||
}
|
||||
if !containsSubsequence(cmd, []string{"--permission-mode", "acceptEdits"}) {
|
||||
t.Fatalf("command %#v missing config-driven permission mode", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandExplicitPermissionsOverrideConfig(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Config: ports.AgentConfig{Permissions: ports.PermissionModeAcceptEdits},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !containsSubsequence(cmd, []string{"--permission-mode", "bypassPermissions"}) {
|
||||
t.Fatalf("explicit Permissions should win; got %#v", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandRejectsInvalidConfig(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "claude"}
|
||||
if _, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Config: ports.AgentConfig{Permissions: "yolo"},
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for invalid permission mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestID(t *testing.T) {
|
||||
if got := New().Manifest().ID; got != "claude-code" {
|
||||
t.Fatalf("manifest id = %q, want claude-code", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, ".claude.json")
|
||||
// Seed an existing config with another project + a top-level key, to
|
||||
// prove we preserve unrelated state.
|
||||
seed := `{"userID":"abc","projects":{"/existing/proj":{"hasTrustDialogAccepted":true,"lastCost":1.5}}}`
|
||||
if err := os.WriteFile(cfgPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
work := "/Users/me/.ao/worktrees/01ABC"
|
||||
if err := ensureWorkspaceTrusted(cfgPath, work); err != nil {
|
||||
t.Fatalf("ensureWorkspaceTrusted: %v", err)
|
||||
}
|
||||
|
||||
root := readJSON(t, cfgPath)
|
||||
projects := root["projects"].(map[string]any)
|
||||
|
||||
// New entry trusted.
|
||||
newEntry := projects[work].(map[string]any)
|
||||
if newEntry["hasTrustDialogAccepted"] != true {
|
||||
t.Fatalf("new entry not trusted: %#v", newEntry)
|
||||
}
|
||||
// Existing project preserved (including its other fields).
|
||||
existing := projects["/existing/proj"].(map[string]any)
|
||||
if existing["hasTrustDialogAccepted"] != true || existing["lastCost"].(float64) != 1.5 {
|
||||
t.Fatalf("existing project clobbered: %#v", existing)
|
||||
}
|
||||
// Top-level key preserved.
|
||||
if root["userID"] != "abc" {
|
||||
t.Fatalf("top-level key clobbered: %#v", root["userID"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureWorkspaceTrustedIsIdempotentAndNoWriteWhenAlreadyTrusted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, ".claude.json")
|
||||
work := "/w"
|
||||
if err := os.WriteFile(cfgPath, []byte(`{"projects":{"/w":{"hasTrustDialogAccepted":true}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info1, err := os.Stat(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureWorkspaceTrusted(cfgPath, work); err != nil {
|
||||
t.Fatalf("ensureWorkspaceTrusted: %v", err)
|
||||
}
|
||||
|
||||
// Already trusted → no rewrite → mtime unchanged.
|
||||
info2, err := os.Stat(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info1.ModTime().Equal(info2.ModTime()) {
|
||||
t.Fatal("expected no rewrite when already trusted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureWorkspaceTrustedCreatesMissingConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, ".claude.json") // does not exist yet
|
||||
work := "/fresh/worktree"
|
||||
|
||||
if err := ensureWorkspaceTrusted(cfgPath, work); err != nil {
|
||||
t.Fatalf("ensureWorkspaceTrusted: %v", err)
|
||||
}
|
||||
|
||||
root := readJSON(t, cfgPath)
|
||||
projects := root["projects"].(map[string]any)
|
||||
entry := projects[work].(map[string]any)
|
||||
if entry["hasTrustDialogAccepted"] != true {
|
||||
t.Fatalf("entry not trusted in freshly-created config: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func readJSON(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, v := range values {
|
||||
if v == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSubsequence(values, needle []string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
}
|
||||
for start := 0; start+len(needle) <= len(values); start++ {
|
||||
ok := true
|
||||
for i, w := range needle {
|
||||
if values[start+i] != w {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
package claudecode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
claudeSettingsDirName = ".claude"
|
||||
claudeSettingsFileName = "settings.local.json"
|
||||
|
||||
// claudeHookCommandPrefix identifies the hook commands AO owns. Every
|
||||
// managed command starts with it, so install can skip duplicates and
|
||||
// uninstall can recognize AO entries by prefix without an embedded
|
||||
// template to diff against.
|
||||
claudeHookCommandPrefix = "ao hooks claude-code "
|
||||
claudeHookTimeout = 30
|
||||
)
|
||||
|
||||
type claudeMatcherGroup struct {
|
||||
// Matcher is a pointer so it round-trips exactly: SessionStart requires a
|
||||
// real matcher ("startup"); UserPromptSubmit/Stop omit it (Claude ignores
|
||||
// matcher for those events). omitempty drops a nil matcher on write.
|
||||
Matcher *string `json:"matcher,omitempty"`
|
||||
Hooks []claudeHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type claudeHookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// claudeHookSpec describes one hook AO installs, defined in code rather
|
||||
// than read from an embedded settings file.
|
||||
type claudeHookSpec struct {
|
||||
Event string
|
||||
Matcher *string
|
||||
Command string
|
||||
}
|
||||
|
||||
// claudeStartupMatcher is referenced by pointer so SessionStart serializes with
|
||||
// its required "startup" matcher.
|
||||
var claudeStartupMatcher = "startup"
|
||||
|
||||
// claudeManagedHooks is the source of truth for the hooks AO installs:
|
||||
// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop,
|
||||
// Notification, and SessionEnd. They report normalized session metadata and
|
||||
// activity-state signals back into AO's store (see DeriveActivityState).
|
||||
// Notification and SessionEnd carry no matcher: each installs once and fires
|
||||
// for every sub-type, and the handler filters on the payload's
|
||||
// notification_type / reason field — installing one command under multiple
|
||||
// matchers would trip the per-command dedup in claudeHookCommandExists.
|
||||
var claudeManagedHooks = []claudeHookSpec{
|
||||
{Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"},
|
||||
{Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"},
|
||||
{Event: "Stop", Command: claudeHookCommandPrefix + "stop"},
|
||||
{Event: "Notification", Command: claudeHookCommandPrefix + "notification"},
|
||||
{Event: "SessionEnd", Command: claudeHookCommandPrefix + "session-end"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Claude Code hooks into the worktree-local
|
||||
// .claude/settings.local.json file (the per-session local settings, not the
|
||||
// shared .claude/settings.json). The hooks (SessionStart, UserPromptSubmit,
|
||||
// Stop, Notification, SessionEnd) report normalized session metadata and
|
||||
// activity-state signals back into AO's store. Existing hooks and unrelated
|
||||
// settings are preserved, and duplicate AO commands are not appended, so
|
||||
// the install is idempotent.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.WorkspacePath) == "" {
|
||||
return errors.New("claude-code.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
settingsPath := claudeSettingsPath(cfg.WorkspacePath)
|
||||
topLevel, rawHooks, err := readClaudeSettings(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claude-code.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
for event, specs := range groupClaudeHooksByEvent() {
|
||||
var existingGroups []claudeMatcherGroup
|
||||
if err := parseClaudeHookType(rawHooks, event, &existingGroups); err != nil {
|
||||
return fmt.Errorf("claude-code.GetAgentHooks: %w", err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !claudeHookCommandExists(existingGroups, spec.Command) {
|
||||
entry := claudeHookEntry{Type: "command", Command: spec.Command, Timeout: claudeHookTimeout}
|
||||
existingGroups = addClaudeHook(existingGroups, entry, spec.Matcher)
|
||||
}
|
||||
}
|
||||
if err := marshalClaudeHookType(rawHooks, event, existingGroups); err != nil {
|
||||
return fmt.Errorf("claude-code.GetAgentHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("claude-code.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), claudeSettingsFileName); err != nil {
|
||||
return fmt.Errorf("claude-code.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Claude Code hooks from the workspace-local
|
||||
// .claude/settings.local.json file, leaving user-defined hooks and unrelated
|
||||
// settings untouched. A missing settings file is a no-op.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return errors.New("claude-code.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
settingsPath := claudeSettingsPath(workspacePath)
|
||||
if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readClaudeSettings(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claude-code.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range claudeManagedEvents() {
|
||||
var groups []claudeMatcherGroup
|
||||
if err := parseClaudeHookType(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("claude-code.UninstallHooks: %w", err)
|
||||
}
|
||||
groups = removeClaudeManagedHooks(groups)
|
||||
if err := marshalClaudeHookType(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("claude-code.UninstallHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("claude-code.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Claude Code hook is present in
|
||||
// the workspace-local settings file. A missing file means none are installed.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, errors.New("claude-code.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
settingsPath := claudeSettingsPath(workspacePath)
|
||||
if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readClaudeSettings(settingsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range claudeManagedEvents() {
|
||||
var groups []claudeMatcherGroup
|
||||
if err := parseClaudeHookType(rawHooks, event, &groups); err != nil {
|
||||
return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if isClaudeManagedHook(hook.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func claudeSettingsPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, claudeSettingsDirName, claudeSettingsFileName)
|
||||
}
|
||||
|
||||
// readClaudeSettings loads the settings file into a top-level raw map plus the
|
||||
// decoded "hooks" sub-map, preserving every key AO doesn't manage. A
|
||||
// missing or empty file yields empty maps.
|
||||
func readClaudeSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) {
|
||||
topLevel = map[string]json.RawMessage{}
|
||||
rawHooks = map[string]json.RawMessage{}
|
||||
|
||||
data, err := os.ReadFile(settingsPath) //nolint:gosec // path built from caller-owned workspace dir
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read %s: %w", settingsPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse %s: %w", settingsPath, err)
|
||||
}
|
||||
if hooksRaw, ok := topLevel["hooks"]; ok {
|
||||
if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse hooks in %s: %w", settingsPath, err)
|
||||
}
|
||||
}
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
|
||||
// writeClaudeSettings folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeClaudeSettings(settingsPath string, topLevel, rawHooks map[string]json.RawMessage) error {
|
||||
if len(rawHooks) == 0 {
|
||||
delete(topLevel, "hooks")
|
||||
} else {
|
||||
hooksJSON, err := json.Marshal(rawHooks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hooks: %w", err)
|
||||
}
|
||||
topLevel["hooks"] = hooksJSON
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil {
|
||||
return fmt.Errorf("create settings dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(topLevel, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", settingsPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := hookutil.AtomicWriteFile(settingsPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", settingsPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so
|
||||
// each event's settings array is rewritten once.
|
||||
func groupClaudeHooksByEvent() map[string][]claudeHookSpec {
|
||||
byEvent := map[string][]claudeHookSpec{}
|
||||
for _, spec := range claudeManagedHooks {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
// claudeManagedEvents returns the distinct Claude events AO manages, in
|
||||
// the order they first appear in claudeManagedHooks.
|
||||
func claudeManagedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(claudeManagedHooks))
|
||||
for _, spec := range claudeManagedHooks {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func isClaudeManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, claudeHookCommandPrefix)
|
||||
}
|
||||
|
||||
// removeClaudeManagedHooks strips AO hook entries from every group,
|
||||
// dropping any group left without hooks so the event array doesn't accumulate
|
||||
// empty matcher objects.
|
||||
func removeClaudeManagedHooks(groups []claudeMatcherGroup) []claudeMatcherGroup {
|
||||
result := make([]claudeMatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]claudeHookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !isClaudeManagedHook(hook.Command) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseClaudeHookType(rawHooks map[string]json.RawMessage, event string, target *[]claudeMatcherGroup) error {
|
||||
data, ok := rawHooks[event]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return fmt.Errorf("parse %s hooks: %w", event, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalClaudeHookType(rawHooks map[string]json.RawMessage, event string, groups []claudeMatcherGroup) error {
|
||||
if len(groups) == 0 {
|
||||
delete(rawHooks, event)
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s hooks: %w", event, err)
|
||||
}
|
||||
rawHooks[event] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func claudeHookCommandExists(groups []claudeMatcherGroup, command string) bool {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addClaudeHook appends hook to an existing group with the same matcher (so a
|
||||
// SessionStart hook lands under its "startup" matcher), creating that group if
|
||||
// none matches.
|
||||
func addClaudeHook(groups []claudeMatcherGroup, hook claudeHookEntry, matcher *string) []claudeMatcherGroup {
|
||||
for i, group := range groups {
|
||||
if matchersEqual(group.Matcher, matcher) {
|
||||
groups[i].Hooks = append(groups[i].Hooks, hook)
|
||||
return groups
|
||||
}
|
||||
}
|
||||
return append(groups, claudeMatcherGroup{Matcher: matcher, Hooks: []claudeHookEntry{hook}})
|
||||
}
|
||||
|
||||
func matchersEqual(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package cline
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Cline hook event onto an AO activity state. The
|
||||
// bool is false when the event carries no activity signal.
|
||||
//
|
||||
// event is the AO hook sub-command name installed by clineManagedHooks
|
||||
// ("session-start", "user-prompt-submit", "permission-request", "stop"), not
|
||||
// the native Cline event name. Cline currently exposes no stable
|
||||
// session/process-end hook the adapter installs, so runtime exit still falls
|
||||
// back to the lifecycle reaper.
|
||||
//
|
||||
// TODO(cline): ActivityExited is still runtime-observation-owned. If Cline adds
|
||||
// a stable native session/process-end hook (e.g. session_shutdown via the CLI
|
||||
// `cline hook` path), map it to ActivityExited here. Until then, ensure the
|
||||
// reaper can still mark a dead Cline runtime as exited even when the last hook
|
||||
// signal was sticky waiting_input.
|
||||
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
|
||||
switch event {
|
||||
case "session-start":
|
||||
return domain.ActivityActive, true
|
||||
case "user-prompt-submit":
|
||||
return domain.ActivityActive, true
|
||||
case "stop":
|
||||
return domain.ActivityIdle, true
|
||||
case "permission-request":
|
||||
return domain.ActivityWaitingInput, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
// Package cline implements the Cline CLI agent adapter: launching new
|
||||
// headless sessions, resuming sessions by native session id, installing
|
||||
// workspace-local Cline hooks, and reading hook-derived session info.
|
||||
//
|
||||
// Cline is an autonomous coding agent that runs in the terminal (binary
|
||||
// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing
|
||||
// the prompt as a positional argument and requesting NDJSON output with
|
||||
// `--json`, which Cline emits one event per line for machine parsing.
|
||||
//
|
||||
// AO-managed sessions derive native session identity from Cline hooks
|
||||
// (the workspace-local `.clinerules/hooks/` executable scripts AO installs)
|
||||
// rather than transcript/cache scans.
|
||||
package cline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
clineTitleMetadataKey = "title"
|
||||
clineSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Cline agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Cline adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: "cline",
|
||||
Name: "Cline",
|
||||
Description: "Run Cline worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Cline exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new headless Cline session,
|
||||
// requesting machine-readable NDJSON output (`--json`), applying the approval
|
||||
// flags, an optional system-prompt override (`-s`), and the initial prompt as
|
||||
// the trailing positional argument. The prompt is placed after `--` so a
|
||||
// leading "-" is not read as a flag.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.clineBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary, "--json"}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
if cfg.SystemPrompt != "" {
|
||||
cmd = append(cmd, "-s", cfg.SystemPrompt)
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Cline receives its prompt in the
|
||||
// launch command itself (as a positional argument).
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Cline session:
|
||||
// `cline --json [approval flags] --id <agentSessionId>`. ok is false when the
|
||||
// hook-derived native session id has not landed yet, so callers can fall back
|
||||
// to fresh launch behavior.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.clineBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 8)
|
||||
cmd = append(cmd, binary, "--json")
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
cmd = append(cmd, "--id", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces Cline hook-derived metadata. Metadata is intentionally
|
||||
// nil for Cline: callers get the normalized fields directly.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[clineTitleMetadataKey],
|
||||
Summary: session.Metadata[clineSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// ResolveClineBinary returns the path to the cline binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations
|
||||
// (Homebrew, npm global). Returns "cline" as a last-ditch fallback so callers
|
||||
// see a clear "command not found" rather than an empty argv.
|
||||
func ResolveClineBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"cline.cmd", "cline.exe", "cline"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "cline.cmd"),
|
||||
filepath.Join(appData, "npm", "cline.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("cline"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/cline",
|
||||
"/opt/homebrew/bin/cline",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".npm-global", "bin", "cline"),
|
||||
filepath.Join(home, ".npm", "bin", "cline"),
|
||||
filepath.Join(home, ".local", "bin", "cline"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) clineBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveClineBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Cline config/default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
// Edit-accepting mode: turn on Cline's auto-approval so edits are
|
||||
// applied without prompting, matching the AcceptEdits semantics every
|
||||
// other adapter uses (the more-permissive, edit-accepting mode).
|
||||
*cmd = append(*cmd, "--auto-approve", "true")
|
||||
case ports.PermissionModeAuto:
|
||||
// Auto-approve every tool for unattended runs.
|
||||
*cmd = append(*cmd, "--auto-approve", "true")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
// yolo mode: auto-approve tools with the restricted (safer) toolset.
|
||||
*cmd = append(*cmd, "--yolo")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
package cline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-fix this",
|
||||
SystemPrompt: "be careful",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"cline",
|
||||
"--json",
|
||||
"--yolo",
|
||||
"-s", "be careful",
|
||||
"--", "-fix this",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission ports.PermissionMode
|
||||
want []string
|
||||
notExpected string
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
permission: ports.PermissionModeDefault,
|
||||
notExpected: "--auto-approve",
|
||||
},
|
||||
{
|
||||
name: "accept-edits",
|
||||
permission: ports.PermissionModeAcceptEdits,
|
||||
want: []string{"--auto-approve", "true"},
|
||||
},
|
||||
{
|
||||
name: "auto",
|
||||
permission: ports.PermissionModeAuto,
|
||||
want: []string{"--auto-approve", "true"},
|
||||
},
|
||||
{
|
||||
name: "bypass-permissions",
|
||||
permission: ports.PermissionModeBypassPermissions,
|
||||
want: []string{"--yolo"},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
permission: "",
|
||||
notExpected: "--auto-approve",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) {
|
||||
t.Fatalf("command %#v does not contain %#v", cmd, tt.want)
|
||||
}
|
||||
if tt.notExpected != "" && contains(cmd, tt.notExpected) {
|
||||
t.Fatalf("command %#v contains %q", cmd, tt.notExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("unexpected config fields: %#v", spec.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestIDMatchesHarness(t *testing.T) {
|
||||
m := (&Plugin{}).Manifest()
|
||||
if m.ID != "cline" {
|
||||
t.Fatalf("manifest ID = %q, want %q", m.ID, "cline")
|
||||
}
|
||||
if m.Name != "Cline" {
|
||||
t.Fatalf("manifest Name = %q, want %q", m.Name, "Cline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksInstallsClineHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
workspace := t.TempDir()
|
||||
hooksDir := filepath.Join(workspace, clineHooksDirName, clineHooksSubDir)
|
||||
|
||||
// Pre-seed a user's own hook script; it must survive install.
|
||||
if err := os.MkdirAll(hooksDir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userHook := filepath.Join(hooksDir, "PostToolUse")
|
||||
if err := os.WriteFile(userHook, []byte("#!/usr/bin/env bash\necho '{\"cancel\": false}'\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second install must be idempotent (no error, scripts still single).
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, spec := range clineManagedHooks {
|
||||
scriptPath := filepath.Join(hooksDir, spec.Event)
|
||||
data, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", spec.Event, err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, clineHookMarker) {
|
||||
t.Fatalf("%s missing AO marker:\n%s", spec.Event, content)
|
||||
}
|
||||
if !strings.Contains(content, clineHookCommandPrefix+spec.Subcommand) {
|
||||
t.Fatalf("%s missing forward command %q:\n%s", spec.Event, clineHookCommandPrefix+spec.Subcommand, content)
|
||||
}
|
||||
info, err := os.Stat(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatalf("%s is not executable: %v", spec.Event, info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
// User-authored hook untouched.
|
||||
data, err := os.ReadFile(userHook)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), clineHookMarker) {
|
||||
t.Fatalf("user PostToolUse hook was overwritten by AO: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{}); err == nil {
|
||||
t.Fatal("expected error for empty WorkspacePath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesClineHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
workspace := t.TempDir()
|
||||
hooksDir := filepath.Join(workspace, clineHooksDirName, clineHooksSubDir)
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
|
||||
|
||||
// Pre-seed a user's own hook; it must survive uninstall.
|
||||
if err := os.MkdirAll(hooksDir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userHook := filepath.Join(hooksDir, "PostToolUse")
|
||||
if err := os.WriteFile(userHook, []byte("#!/usr/bin/env bash\necho '{\"cancel\": false}'\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed {
|
||||
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
|
||||
}
|
||||
|
||||
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
|
||||
for _, spec := range clineManagedHooks {
|
||||
if fileExists(filepath.Join(hooksDir, spec.Event)) {
|
||||
t.Fatalf("%s still present after uninstall", spec.Event)
|
||||
}
|
||||
}
|
||||
if !fileExists(userHook) {
|
||||
t.Fatal("user PostToolUse hook was removed by uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksMissingDirIsNoOp(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
if err := plugin.UninstallHooks(context.Background(), t.TempDir()); err != nil {
|
||||
t.Fatalf("uninstall on missing hooks dir = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "session-123"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{
|
||||
"cline",
|
||||
"--json",
|
||||
"--auto-approve", "true",
|
||||
"--id", "session-123",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ref ports.SessionRef
|
||||
}{
|
||||
{"empty session ref", ports.SessionRef{}},
|
||||
{"empty metadata", ports.SessionRef{Metadata: map[string]string{}}},
|
||||
{"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
|
||||
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: tc.ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("cmd = %#v, want nil", cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "session-123",
|
||||
clineTitleMetadataKey: "Fix login redirect",
|
||||
clineSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("ok = false, want true")
|
||||
}
|
||||
if info.AgentSessionID != "session-123" {
|
||||
t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q, want hook title", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q, want hook summary", info.Summary)
|
||||
}
|
||||
if info.Metadata != nil {
|
||||
t.Fatalf("Metadata = %#v, want nil for Cline", info.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero value", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
event string
|
||||
want domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"session-start", domain.ActivityActive, true},
|
||||
{"user-prompt-submit", domain.ActivityActive, true},
|
||||
{"stop", domain.ActivityIdle, true},
|
||||
{"permission-request", domain.ActivityWaitingInput, true},
|
||||
{"unknown", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.event, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, nil)
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellationIsHonored(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{}); err == nil {
|
||||
// GetLaunchCommand resolves the cached binary first; ctx.Err is checked
|
||||
// inside ResolveClineBinary only when no cached binary. With a cached
|
||||
// binary it may not error, so we assert the other methods instead.
|
||||
_ = err
|
||||
}
|
||||
if _, err := plugin.GetConfigSpec(ctx); err == nil {
|
||||
t.Fatal("GetConfigSpec: expected context error")
|
||||
}
|
||||
if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil {
|
||||
t.Fatal("GetPromptDeliveryStrategy: expected context error")
|
||||
}
|
||||
if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil {
|
||||
t.Fatal("GetRestoreCommand: expected context error")
|
||||
}
|
||||
if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil {
|
||||
t.Fatal("SessionInfo: expected context error")
|
||||
}
|
||||
if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: "/x"}); err == nil {
|
||||
t.Fatal("GetAgentHooks: expected context error")
|
||||
}
|
||||
if _, err := ResolveClineBinary(ctx); err == nil {
|
||||
t.Fatal("ResolveClineBinary: expected context error")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSubsequence(values []string, needle []string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for start := range values {
|
||||
if start+len(needle) > len(values) {
|
||||
return false
|
||||
}
|
||||
ok := true
|
||||
for offset, want := range needle {
|
||||
if values[start+offset] != want {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package cline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// Cline's hook system is git-style: each lifecycle hook is an executable script
|
||||
// placed in the workspace-local `.clinerules/hooks/` directory, named exactly
|
||||
// after the hook event (no extension), reading a JSON payload on stdin and
|
||||
// writing a JSON result on stdout (see docs.cline.bot hooks reference).
|
||||
//
|
||||
// AO installs one wrapper script per managed event. Each script forwards the
|
||||
// hook payload to `ao hooks cline <subcommand>` and emits the no-op
|
||||
// continuation result Cline expects. Scripts carry a marker line so install is
|
||||
// idempotent and uninstall recognizes AO-owned scripts without an embedded
|
||||
// template to diff against; user-authored hooks (lacking the marker) are never
|
||||
// touched.
|
||||
const (
|
||||
clineHooksDirName = ".clinerules"
|
||||
clineHooksSubDir = "hooks"
|
||||
|
||||
// clineHookCommandPrefix identifies the hook commands AO owns. The CLI hook
|
||||
// dispatcher routes "ao hooks cline <subcommand>" to DeriveActivityState.
|
||||
clineHookCommandPrefix = "ao hooks cline "
|
||||
|
||||
// clineHookMarker tags AO-generated hook scripts so install/uninstall can
|
||||
// distinguish them from user-authored Cline hooks in the same directory.
|
||||
clineHookMarker = "# ao-managed-cline-hook"
|
||||
)
|
||||
|
||||
// clineHookSpec describes one hook AO installs: the native Cline hook event
|
||||
// (used as the script's filename) and the AO sub-command its wrapper forwards
|
||||
// to (used by DeriveActivityState).
|
||||
type clineHookSpec struct {
|
||||
// Event is the native Cline hook name, which is also the script filename.
|
||||
Event string
|
||||
// Subcommand is the fixed AO hook sub-command name the wrapper invokes.
|
||||
Subcommand string
|
||||
}
|
||||
|
||||
// clineManagedHooks is the source of truth for the hooks AO installs. The
|
||||
// native Cline events are mapped onto AO's fixed sub-command names so activity
|
||||
// derivation stays uniform across adapters:
|
||||
// - TaskStart -> session-start (a new task begins: active)
|
||||
// - UserPromptSubmit -> user-prompt-submit (user message submitted: active)
|
||||
// - PreToolUse -> permission-request (about to act: approval point)
|
||||
// - TaskCancel -> stop (task cancelled/aborted: idle)
|
||||
var clineManagedHooks = []clineHookSpec{
|
||||
{Event: "TaskStart", Subcommand: "session-start"},
|
||||
{Event: "UserPromptSubmit", Subcommand: "user-prompt-submit"},
|
||||
{Event: "PreToolUse", Subcommand: "permission-request"},
|
||||
{Event: "TaskCancel", Subcommand: "stop"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Cline hook scripts into the worktree-local
|
||||
// `.clinerules/hooks/` directory. Existing user-authored hook scripts are
|
||||
// preserved, and re-running install simply rewrites AO-owned scripts in place.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.WorkspacePath) == "" {
|
||||
return errors.New("cline.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
hooksDir := clineHooksDir(cfg.WorkspacePath)
|
||||
if err := os.MkdirAll(hooksDir, 0o750); err != nil {
|
||||
return fmt.Errorf("cline.GetAgentHooks: create hook dir: %w", err)
|
||||
}
|
||||
|
||||
// Only scripts AO actually wrote go into the workspace .gitignore: a
|
||||
// user-authored script at one of these paths must keep counting as dirt so
|
||||
// workspace teardown preserves it.
|
||||
written := make([]string, 0, len(clineManagedHooks))
|
||||
for _, spec := range clineManagedHooks {
|
||||
scriptPath := filepath.Join(hooksDir, spec.Event)
|
||||
// Never clobber a user-authored hook with the same event name.
|
||||
if fileExists(scriptPath) && !isManagedClineHook(scriptPath) {
|
||||
continue
|
||||
}
|
||||
script := renderClineHookScript(spec.Subcommand)
|
||||
if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil {
|
||||
return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err)
|
||||
}
|
||||
written = append(written, spec.Event)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(hooksDir, written...); err != nil {
|
||||
return fmt.Errorf("cline.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Cline hook scripts from the workspace-local
|
||||
// `.clinerules/hooks/` directory, leaving user-authored hooks untouched. A
|
||||
// missing directory is a no-op.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return errors.New("cline.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksDir := clineHooksDir(workspacePath)
|
||||
if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, spec := range clineManagedHooks {
|
||||
scriptPath := filepath.Join(hooksDir, spec.Event)
|
||||
if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("cline.UninstallHooks: remove %s: %w", spec.Event, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Cline hook script is present in the
|
||||
// workspace-local hooks directory. A missing directory means none.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, errors.New("cline.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksDir := clineHooksDir(workspacePath)
|
||||
if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for _, spec := range clineManagedHooks {
|
||||
scriptPath := filepath.Join(hooksDir, spec.Event)
|
||||
if fileExists(scriptPath) && isManagedClineHook(scriptPath) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func clineHooksDir(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, clineHooksDirName, clineHooksSubDir)
|
||||
}
|
||||
|
||||
// renderClineHookScript builds an executable wrapper that forwards the Cline
|
||||
// hook payload (JSON on stdin) to the AO CLI hook dispatcher and prints the
|
||||
// no-op continuation result Cline expects ({"cancel": false}). The marker line
|
||||
// identifies it as AO-owned.
|
||||
func renderClineHookScript(subcommand string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("#!/usr/bin/env bash\n")
|
||||
b.WriteString(clineHookMarker + "\n")
|
||||
// Forward stdin to the AO dispatcher; ignore its exit code so a missing/old
|
||||
// `ao` binary can never block Cline's own execution.
|
||||
b.WriteString(clineHookCommandPrefix + subcommand + " || true\n")
|
||||
// Cline requires a JSON result on stdout; never block the agent.
|
||||
b.WriteString(`echo '{"cancel": false}'` + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isManagedClineHook(scriptPath string) bool {
|
||||
data, err := os.ReadFile(scriptPath) //nolint:gosec // path built from caller-owned workspace dir
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(data), clineHookMarker)
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
|
||||
// write can't leave a truncated script that Cline then fails to execute.
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package codex
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Codex hook event onto an AO activity state. The
|
||||
// bool is false when the event carries no activity signal.
|
||||
//
|
||||
// event is the AO hook sub-command name installed in codexManagedHooks
|
||||
// ("user-prompt-submit", "permission-request", "stop", ...), not the native
|
||||
// Codex event name. Codex currently has no SessionEnd/Notification equivalent
|
||||
// in the adapter, so runtime exit still falls back to the reaper.
|
||||
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
|
||||
switch event {
|
||||
case "user-prompt-submit":
|
||||
return domain.ActivityActive, true
|
||||
case "permission-request":
|
||||
return domain.ActivityWaitingInput, true
|
||||
case "stop":
|
||||
return domain.ActivityIdle, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
want domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true},
|
||||
{"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true},
|
||||
{"stop -> idle", "stop", domain.ActivityIdle, true},
|
||||
{"session start -> no signal", "session-start", "", false},
|
||||
{"unknown event -> no signal", "frobnicate", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
// Package codex implements the Codex agent adapter: launching new sessions,
|
||||
// resuming hook-tracked sessions, installing workspace-local hooks, and reading
|
||||
// hook-derived session info.
|
||||
//
|
||||
// AO-managed sessions derive native session identity and display
|
||||
// metadata from Codex hooks instead of transcript/cache scans.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// Plugin is the Codex agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Codex adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: "codex",
|
||||
Name: "Codex",
|
||||
Description: "Run Codex worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Codex exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new Codex session, applying the
|
||||
// no-update-check, hook-trust bypass, and approval flags, AO's session-flag
|
||||
// activity hooks, the workspace trust override, optional system-prompt
|
||||
// instructions, and the initial prompt (passed after `--` so a leading "-" is
|
||||
// not read as a flag).
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.codexBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
appendNoUpdateCheckFlag(&cmd)
|
||||
appendHideRateLimitNudgeFlag(&cmd)
|
||||
appendHookTrustBypassFlag(&cmd)
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
appendSessionHookFlags(&cmd)
|
||||
appendTerminalCompatibilityFlags(&cmd)
|
||||
appendWorkspaceTrustFlag(&cmd, cfg.WorkspacePath)
|
||||
|
||||
if cfg.SystemPromptFile != "" {
|
||||
cmd = append(cmd, "-c", "model_instructions_file="+cfg.SystemPromptFile)
|
||||
} else if cfg.SystemPrompt != "" {
|
||||
cmd = append(cmd, "-c", "developer_instructions="+codexTOMLConfigString(cfg.SystemPrompt))
|
||||
}
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Codex receives its prompt in the
|
||||
// launch command itself.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Codex
|
||||
// session: `codex resume <agentSessionId>`. ok is false when the hook-derived
|
||||
// native session id has not landed yet, so callers can fall back to fresh
|
||||
// launch behavior.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.codexBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 24)
|
||||
cmd = append(cmd, binary, "resume")
|
||||
appendNoUpdateCheckFlag(&cmd)
|
||||
appendHideRateLimitNudgeFlag(&cmd)
|
||||
appendHookTrustBypassFlag(&cmd)
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
appendSessionHookFlags(&cmd)
|
||||
appendTerminalCompatibilityFlags(&cmd)
|
||||
appendWorkspaceTrustFlag(&cmd, cfg.Session.WorkspacePath)
|
||||
cmd = append(cmd, agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces Codex hook-derived metadata. Metadata is intentionally
|
||||
// nil for Codex: callers get the normalized fields directly.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[ports.MetadataKeyTitle],
|
||||
Summary: session.Metadata[ports.MetadataKeySummary],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// ResolveCodexBinary returns the path to the codex binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations
|
||||
// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback
|
||||
// so callers see a clear "command not found" rather than an empty argv.
|
||||
func ResolveCodexBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"codex.exe", "codex.cmd", "codex"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil && path != "" {
|
||||
return resolveNativeWindowsCodex(path), nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
shim := filepath.Join(appData, "npm", "codex.cmd")
|
||||
candidates = append(candidates, windowsNativeCodexCandidatesForShim(shim)...)
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "codex.exe"),
|
||||
shim,
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "codex.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return resolveNativeWindowsCodex(candidate), nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("codex"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/codex",
|
||||
"/opt/homebrew/bin/codex",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".cargo", "bin", "codex"),
|
||||
filepath.Join(home, ".npm", "bin", "codex"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func resolveNativeWindowsCodex(path string) string {
|
||||
if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") {
|
||||
return path
|
||||
}
|
||||
for _, candidate := range windowsNativeCodexCandidatesForShim(path) {
|
||||
if fileExists(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func windowsNativeCodexCandidatesForShim(shim string) []string {
|
||||
dir := filepath.Dir(shim)
|
||||
return []string{
|
||||
filepath.Join(dir, "node_modules", "@openai", "codex", "node_modules", "@openai", "codex-win32-x64", "vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe"),
|
||||
filepath.Join(dir, "node_modules", "@openai", "codex", "bin", "codex.exe"),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) codexBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveCodexBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
// DoctorLaunchProbes returns argv tails `ao doctor` runs against the installed
|
||||
// codex binary to smoke-test the launch surface AO's hook delivery depends on.
|
||||
// Probe 1 confirms --dangerously-bypass-hook-trust still exists (clap rejects
|
||||
// unknown flags with a non-zero exit even alongside --version). Probe 2 loads
|
||||
// codex's config with AO's `-c` session-flag overrides through the offline
|
||||
// `features list` subcommand, so an override-parse regression surfaces as a
|
||||
// non-zero exit or warning output. Both are built from the same flag builders
|
||||
// the launch command uses, so the probes cannot drift from the real spawn argv.
|
||||
func DoctorLaunchProbes() [][]string {
|
||||
flagProbe := make([]string, 0, 2)
|
||||
appendHookTrustBypassFlag(&flagProbe)
|
||||
flagProbe = append(flagProbe, "--version")
|
||||
|
||||
overrideProbe := []string{"features", "list"}
|
||||
appendNoUpdateCheckFlag(&overrideProbe)
|
||||
appendHideRateLimitNudgeFlag(&overrideProbe)
|
||||
appendSessionHookFlags(&overrideProbe)
|
||||
appendWorkspaceTrustFlag(&overrideProbe, os.TempDir())
|
||||
return [][]string{flagProbe, overrideProbe}
|
||||
}
|
||||
|
||||
func appendNoUpdateCheckFlag(cmd *[]string) {
|
||||
*cmd = append(*cmd, "-c", "check_for_update_on_startup=false")
|
||||
}
|
||||
|
||||
func appendHideRateLimitNudgeFlag(cmd *[]string) {
|
||||
// When the account nears its rate limit, the Codex TUI interposes an
|
||||
// interactive "switch to a cheaper model?" dialog before the first turn.
|
||||
// In a headless AO pane that dialog hangs the session invisibly and
|
||||
// swallows the auto-submitted spawn prompt, so suppress it.
|
||||
*cmd = append(*cmd, "-c", "notice.hide_rate_limit_model_nudge=true")
|
||||
}
|
||||
|
||||
func appendHookTrustBypassFlag(cmd *[]string) {
|
||||
// AO's activity hooks ride the launch command as session-flag config (see
|
||||
// appendSessionHookFlags) and carry no persisted trust hash in the user's
|
||||
// `[hooks.state]`. Without this flag Codex would hold them for an
|
||||
// interactive hooks review, leaving AO without activity signals.
|
||||
*cmd = append(*cmd, "--dangerously-bypass-hook-trust")
|
||||
}
|
||||
|
||||
func appendTerminalCompatibilityFlags(cmd *[]string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
*cmd = append(*cmd, "--no-alt-screen")
|
||||
}
|
||||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// Codex sessions are AO-managed and run headlessly inside a terminal
|
||||
// mux pane; default to no approval prompts unless project settings
|
||||
// explicitly choose a more restrictive mode.
|
||||
*cmd = append(*cmd, "--dangerously-bypass-approvals-and-sandbox")
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--ask-for-approval", "on-request")
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--ask-for-approval", "on-request", "-c", `approvals_reviewer="auto_review"`)
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--dangerously-bypass-approvals-and-sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,583 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// canonicalTempDir returns a t.TempDir() with symlinks resolved so the
|
||||
// workspace trust flag collapses to a single predictable entry (macOS TempDir
|
||||
// lives under a /var -> /private/var symlink).
|
||||
func canonicalTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// sessionHookFlags mirrors the `-c` hook config appendSessionHookFlags emits,
|
||||
// asserted literally so accidental format drift fails loudly: Codex parses
|
||||
// these values as TOML.
|
||||
func sessionHookFlags() []string {
|
||||
return []string{
|
||||
"-c", `hooks.SessionStart=[{hooks=[{type="command",command="ao hooks codex session-start",timeout=5}]}]`,
|
||||
"-c", `hooks.UserPromptSubmit=[{hooks=[{type="command",command="ao hooks codex user-prompt-submit",timeout=5}]}]`,
|
||||
"-c", `hooks.PermissionRequest=[{hooks=[{type="command",command="ao hooks codex permission-request",timeout=5}]}]`,
|
||||
"-c", `hooks.Stop=[{hooks=[{type="command",command="ao hooks codex stop",timeout=5}]}]`,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
workspace := canonicalTempDir(t)
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-fix this",
|
||||
SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"),
|
||||
SystemPrompt: "ignored",
|
||||
WorkspacePath: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"codex",
|
||||
"-c", "check_for_update_on_startup=false",
|
||||
"-c", "notice.hide_rate_limit_model_nudge=true",
|
||||
"--dangerously-bypass-hook-trust",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
}
|
||||
want = append(want, sessionHookFlags()...)
|
||||
if runtime.GOOS == "windows" {
|
||||
want = append(want, "--no-alt-screen")
|
||||
}
|
||||
want = append(want,
|
||||
"-c", `projects={`+codexTOMLConfigString(workspace)+`={trust_level="trusted"}}`,
|
||||
"-c", "model_instructions_file="+filepath.Join("tmp", "prompt with spaces.md"),
|
||||
"--", "-fix this",
|
||||
)
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, arg := range cmd {
|
||||
if strings.HasPrefix(arg, "projects=") {
|
||||
t.Fatalf("command %#v contains a projects trust flag without a workspace", cmd)
|
||||
}
|
||||
}
|
||||
if !containsSubsequence(cmd, sessionHookFlags()) {
|
||||
t.Fatalf("command %#v missing session hook flags", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission ports.PermissionMode
|
||||
want []string
|
||||
notExpected string
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
permission: ports.PermissionModeDefault,
|
||||
want: []string{"--dangerously-bypass-approvals-and-sandbox"},
|
||||
},
|
||||
{
|
||||
name: "accept-edits",
|
||||
permission: ports.PermissionModeAcceptEdits,
|
||||
want: []string{"--ask-for-approval", "on-request"},
|
||||
notExpected: "--dangerously-bypass-approvals-and-sandbox",
|
||||
},
|
||||
{
|
||||
name: "auto",
|
||||
permission: ports.PermissionModeAuto,
|
||||
want: []string{"--ask-for-approval", "on-request", "-c", `approvals_reviewer="auto_review"`},
|
||||
notExpected: "--dangerously-bypass-approvals-and-sandbox",
|
||||
},
|
||||
{
|
||||
name: "bypass-permissions",
|
||||
permission: ports.PermissionModeBypassPermissions,
|
||||
want: []string{"--dangerously-bypass-approvals-and-sandbox"},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
permission: "",
|
||||
want: []string{"--dangerously-bypass-approvals-and-sandbox"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) {
|
||||
t.Fatalf("command %#v does not contain %#v", cmd, tt.want)
|
||||
}
|
||||
if tt.notExpected != "" && contains(cmd, tt.notExpected) {
|
||||
t.Fatalf("command %#v contains %q", cmd, tt.notExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendWorkspaceTrustFlagCoversLiteralAndResolvedPaths(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs extra privileges on Windows")
|
||||
}
|
||||
base := canonicalTempDir(t)
|
||||
target := filepath.Join(base, "real")
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(base, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var cmd []string
|
||||
appendWorkspaceTrustFlag(&cmd, link)
|
||||
want := []string{
|
||||
"-c",
|
||||
`projects={'` + link + `'={trust_level="trusted"},'` + target + `'={trust_level="trusted"}}`,
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("trust flag\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
|
||||
cmd = nil
|
||||
appendWorkspaceTrustFlag(&cmd, target)
|
||||
want = []string{"-c", `projects={'` + target + `'={trust_level="trusted"}}`}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("canonical-path trust flag\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
|
||||
cmd = nil
|
||||
appendWorkspaceTrustFlag(&cmd, " ")
|
||||
if cmd != nil {
|
||||
t.Fatalf("blank workspace produced %#v, want no flag", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexTOMLBasicStringEscapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"plain", "\"plain\""},
|
||||
{"C:\\Users\\dev", "\"C:\\\\Users\\\\dev\""},
|
||||
{"with \"quotes\"", "\"with \\\"quotes\\\"\""},
|
||||
{"tab\there", "\"tab\\u0009here\""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := codexTOMLBasicString(tt.in); got != tt.want {
|
||||
t.Fatalf("codexTOMLBasicString(%q) = %s, want %s", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("unexpected config fields: %#v", spec.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyHooksJSON builds a hooks.json in the shape older AO versions wrote:
|
||||
// AO-managed entries plus one user-defined Stop hook.
|
||||
func legacyHooksJSON() string {
|
||||
return `{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{"matcher": null, "hooks": [
|
||||
{"type": "command", "command": "custom stop hook", "timeout": 3},
|
||||
{"type": "command", "command": "ao hooks codex stop", "timeout": 30}
|
||||
]}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{"matcher": null, "hooks": [
|
||||
{"type": "command", "command": "ao hooks codex user-prompt-submit", "timeout": 30}
|
||||
]}
|
||||
]
|
||||
},
|
||||
"unmanagedKey": {"keep": true}
|
||||
}`
|
||||
}
|
||||
|
||||
func TestGetAgentHooksWritesNothingIntoFreshWorkspace(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
workspace := t.TempDir()
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(workspace, codexHooksDirName)); !os.IsNotExist(err) {
|
||||
t.Fatalf(".codex dir state = %v, want not-exist: hooks ride the launch command", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: " "})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for blank WorkspacePath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksStripsLegacyAOEntries(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := filepath.Join(workspace, codexHooksDirName, codexHooksFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(hooksPath, []byte(legacyHooksJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config codexHookFile
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range codexManagedHooks {
|
||||
if got := countCodexHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 {
|
||||
t.Fatalf("%s command %q count = %d after cleanup, want 0", spec.Event, spec.Command, got)
|
||||
}
|
||||
}
|
||||
if countCodexHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 {
|
||||
t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"])
|
||||
}
|
||||
if _, ok := config.Hooks["UserPromptSubmit"]; ok {
|
||||
t.Fatalf("UserPromptSubmit left behind after its only entry was AO's: %#v", config.Hooks)
|
||||
}
|
||||
if !strings.Contains(string(data), "unmanagedKey") {
|
||||
t.Fatalf("top-level keys AO doesn't manage were dropped: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksLeavesFilesWithoutAOEntriesUntouched(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := filepath.Join(workspace, codexHooksDirName, codexHooksFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := `{"hooks":{"Stop":[{"matcher":null,"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}`
|
||||
if err := os.WriteFile(hooksPath, []byte(seed), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != seed {
|
||||
t.Fatalf("user-only hooks.json was rewritten\n--- before ---\n%s\n--- after ---\n%s", seed, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesLegacyCodexHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := filepath.Join(workspace, codexHooksDirName, codexHooksFileName)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Missing file is a no-op.
|
||||
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(hooksPath, []byte(legacyHooksJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed {
|
||||
t.Fatalf("AreHooksInstalled with legacy entries = (%v, %v), want (true, nil)", installed, err)
|
||||
}
|
||||
|
||||
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config codexHookFile
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range codexManagedHooks {
|
||||
if got := countCodexHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 {
|
||||
t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got)
|
||||
}
|
||||
}
|
||||
if countCodexHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 {
|
||||
t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
workspace := canonicalTempDir(t)
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "thread-123"},
|
||||
WorkspacePath: workspace,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{
|
||||
"codex",
|
||||
"resume",
|
||||
"-c", "check_for_update_on_startup=false",
|
||||
"-c", "notice.hide_rate_limit_model_nudge=true",
|
||||
"--dangerously-bypass-hook-trust",
|
||||
"--ask-for-approval", "on-request",
|
||||
"-c", `approvals_reviewer="auto_review"`,
|
||||
}
|
||||
want = append(want, sessionHookFlags()...)
|
||||
if runtime.GOOS == "windows" {
|
||||
want = append(want, "--no-alt-screen")
|
||||
}
|
||||
want = append(want,
|
||||
"-c", `projects={`+codexTOMLConfigString(workspace)+`={trust_level="trusted"}}`,
|
||||
"thread-123",
|
||||
)
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ref ports.SessionRef
|
||||
}{
|
||||
{"empty session ref", ports.SessionRef{}},
|
||||
{"empty metadata", ports.SessionRef{Metadata: map[string]string{}}},
|
||||
{"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
|
||||
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: tc.ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("cmd = %#v, want nil", cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "thread-123",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("ok = false, want true")
|
||||
}
|
||||
if info.AgentSessionID != "thread-123" {
|
||||
t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q, want hook title", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q, want hook summary", info.Summary)
|
||||
}
|
||||
if info.Metadata != nil {
|
||||
t.Fatalf("Metadata = %#v, want nil for Codex", info.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero value", info)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSubsequence(values []string, needle []string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for start := range values {
|
||||
if start+len(needle) > len(values) {
|
||||
return false
|
||||
}
|
||||
ok := true
|
||||
for offset, want := range needle {
|
||||
if values[start+offset] != want {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func countCodexHookCommand(entries []codexMatcherGroup, command string) int {
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
for _, hook := range entry.Hooks {
|
||||
if hook.Command == command {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestDoctorLaunchProbesMirrorLaunchFlags(t *testing.T) {
|
||||
probes := DoctorLaunchProbes()
|
||||
if len(probes) != 2 {
|
||||
t.Fatalf("probes = %d, want 2", len(probes))
|
||||
}
|
||||
if !reflect.DeepEqual(probes[0], []string{"--dangerously-bypass-hook-trust", "--version"}) {
|
||||
t.Fatalf("flag probe = %#v", probes[0])
|
||||
}
|
||||
override := probes[1]
|
||||
if len(override) < 2 || override[0] != "features" || override[1] != "list" {
|
||||
t.Fatalf("override probe must ride `features list`, got %#v", override)
|
||||
}
|
||||
joined := strings.Join(override, " ")
|
||||
for _, want := range []string{
|
||||
"hooks.SessionStart=", "hooks.UserPromptSubmit=", "hooks.PermissionRequest=", "hooks.Stop=",
|
||||
"notice.hide_rate_limit_model_nudge=true",
|
||||
`projects={`,
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("override probe missing %q in %s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// Codex (0.136+) never loads hook config from AO's per-session worktrees, so
|
||||
// AO's hooks ride the launch command as `-c` session-flag config instead of
|
||||
// workspace files:
|
||||
//
|
||||
// - Project-local `.codex/` layers only load when the directory is trusted,
|
||||
// and for linked git worktrees Codex sources hook declarations from the
|
||||
// matching `.codex/` folder in the ROOT checkout, not the worktree. A
|
||||
// hooks.json written into an AO worktree is therefore dead config.
|
||||
// - Hooks passed as `-c 'hooks.<Event>=[...]'` land in Codex's session-flags
|
||||
// config layer, which is not trust-gated and aggregates with (never
|
||||
// replaces) the user's own hooks from `~/.codex`. They carry no persisted
|
||||
// trust hash, so the launch command also passes
|
||||
// `--dangerously-bypass-hook-trust` to let them run.
|
||||
const (
|
||||
codexHooksDirName = ".codex"
|
||||
codexHooksFileName = "hooks.json"
|
||||
|
||||
// codexHookCommandPrefix identifies the hook commands AO owns, so the
|
||||
// legacy-file cleanup and uninstall recognize AO entries by prefix
|
||||
// without an embedded template to diff against.
|
||||
codexHookCommandPrefix = "ao hooks codex "
|
||||
// codexHookTimeout caps how long Codex waits on one AO hook callback. The
|
||||
// callback is a loopback POST that normally returns in milliseconds; a
|
||||
// tight cap keeps a hung daemon from stalling the agent's turn.
|
||||
codexHookTimeout = 5
|
||||
)
|
||||
|
||||
// codexHookFile is the on-disk shape of .codex/hooks.json. It is used by tests
|
||||
// to decode the written file.
|
||||
type codexHookFile struct {
|
||||
Hooks map[string][]codexMatcherGroup `json:"hooks"`
|
||||
}
|
||||
|
||||
type codexMatcherGroup struct {
|
||||
Matcher *string `json:"matcher,omitempty"`
|
||||
Hooks []codexHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type codexHookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// codexHookSpec describes one hook AO delivers via launch-command config.
|
||||
type codexHookSpec struct {
|
||||
Event string
|
||||
Command string
|
||||
}
|
||||
|
||||
// codexManagedHooks is the source of truth for the hooks AO delivers. Event
|
||||
// names must not contain dots: they are spliced into a dotted `-c` key path,
|
||||
// and Codex splits that path on every dot without honoring quoting.
|
||||
var codexManagedHooks = []codexHookSpec{
|
||||
{Event: "SessionStart", Command: codexHookCommandPrefix + "session-start"},
|
||||
{Event: "UserPromptSubmit", Command: codexHookCommandPrefix + "user-prompt-submit"},
|
||||
{Event: "PermissionRequest", Command: codexHookCommandPrefix + "permission-request"},
|
||||
{Event: "Stop", Command: codexHookCommandPrefix + "stop"},
|
||||
}
|
||||
|
||||
// appendSessionHookFlags adds AO's activity hooks to the argv as `-c`
|
||||
// session-flag config, one flag per managed event.
|
||||
func appendSessionHookFlags(cmd *[]string) {
|
||||
for _, spec := range codexManagedHooks {
|
||||
flag := fmt.Sprintf(`hooks.%s=[{hooks=[{type="command",command=%s,timeout=%d}]}]`,
|
||||
spec.Event, codexTOMLBasicString(spec.Command), codexHookTimeout)
|
||||
*cmd = append(*cmd, "-c", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// appendWorkspaceTrustFlag marks the session's worktree as a trusted Codex
|
||||
// project for this invocation only, so spawns into never-before-trusted repos
|
||||
// don't hang on the interactive "Do you trust this directory?" prompt.
|
||||
//
|
||||
// The override is shaped as a single `projects={...}` value (not a dotted
|
||||
// `projects."<path>".trust_level` key) because Codex splits `-c` key paths on
|
||||
// every dot without honoring quoted segments, which corrupts path keys. The
|
||||
// inline table deep-merges with the user's persisted projects map. Both the
|
||||
// literal and symlink-resolved paths are trusted because Codex looks trust up
|
||||
// by the canonicalized cwd first and the literal path second (on macOS the two
|
||||
// commonly differ, e.g. /tmp vs /private/tmp).
|
||||
func appendWorkspaceTrustFlag(cmd *[]string, workspacePath string) {
|
||||
path := strings.TrimSpace(workspacePath)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
keys := []string{path}
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path {
|
||||
keys = append(keys, resolved)
|
||||
}
|
||||
entries := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
entries = append(entries, codexTOMLConfigString(key)+`={trust_level="trusted"}`)
|
||||
}
|
||||
*cmd = append(*cmd, "-c", "projects={"+strings.Join(entries, ",")+"}")
|
||||
}
|
||||
|
||||
func codexTOMLConfigString(s string) string {
|
||||
if !containsTOMLControl(s) && !strings.Contains(s, "'") {
|
||||
return codexTOMLLiteralString(s)
|
||||
}
|
||||
return codexTOMLBasicString(s)
|
||||
}
|
||||
|
||||
func codexTOMLLiteralString(s string) string {
|
||||
return "'" + s + "'"
|
||||
}
|
||||
|
||||
// codexTOMLBasicString renders s as a TOML basic string, escaping backslashes
|
||||
// and quotes (Windows paths) plus control characters so the value survives
|
||||
// Codex's TOML parse of the `-c` override.
|
||||
func codexTOMLBasicString(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\\':
|
||||
b.WriteString(`\\`)
|
||||
case r == '"':
|
||||
b.WriteString(`\"`)
|
||||
case r < 0x20 || r == 0x7f:
|
||||
fmt.Fprintf(&b, `\u%04X`, r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func containsTOMLControl(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetAgentHooks no longer installs workspace files — Codex never loads them
|
||||
// from AO's worktrees (see the package comment above); the hooks ride the
|
||||
// launch command instead. It still strips hook entries that older AO versions
|
||||
// wrote into the worktree-local .codex/hooks.json so reused or restored
|
||||
// worktrees don't keep dead AO config, preserving user-defined hooks.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.WorkspacePath) == "" {
|
||||
return errors.New("codex.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
if err := removeLegacyWorkspaceHooks(cfg.WorkspacePath); err != nil {
|
||||
return fmt.Errorf("codex.GetAgentHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's legacy Codex hooks from the workspace-local
|
||||
// .codex/hooks.json file, leaving user-defined hooks untouched. A missing file
|
||||
// is a no-op.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return errors.New("codex.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
if err := removeLegacyWorkspaceHooks(workspacePath); err != nil {
|
||||
return fmt.Errorf("codex.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeLegacyWorkspaceHooks strips AO-owned entries from a workspace-local
|
||||
// hooks.json left behind by older AO versions. Files without one are untouched.
|
||||
func removeLegacyWorkspaceHooks(workspacePath string) error {
|
||||
hooksPath := codexHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readCodexHooks(hooksPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for event, raw := range rawHooks {
|
||||
var groups []codexMatcherGroup
|
||||
if err := json.Unmarshal(raw, &groups); err != nil {
|
||||
return fmt.Errorf("parse %s hooks: %w", event, err)
|
||||
}
|
||||
kept := removeCodexManagedHooks(groups)
|
||||
if countCodexHooks(kept) == countCodexHooks(groups) {
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
if len(kept) == 0 {
|
||||
delete(rawHooks, event)
|
||||
continue
|
||||
}
|
||||
data, err := json.Marshal(kept)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s hooks: %w", event, err)
|
||||
}
|
||||
rawHooks[event] = data
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return writeCodexHooks(hooksPath, topLevel, rawHooks)
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any legacy AO Codex hook is still present
|
||||
// in the workspace-local hooks file. A missing file means none are installed.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, errors.New("codex.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := codexHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readCodexHooks(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("codex.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for event, raw := range rawHooks {
|
||||
var groups []codexMatcherGroup
|
||||
if err := json.Unmarshal(raw, &groups); err != nil {
|
||||
return false, fmt.Errorf("codex.AreHooksInstalled: parse %s hooks: %w", event, err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if isCodexManagedHook(hook.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func codexHooksPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, codexHooksDirName, codexHooksFileName)
|
||||
}
|
||||
|
||||
// readCodexHooks loads the hooks file into a top-level raw map plus the decoded
|
||||
// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty
|
||||
// file yields empty maps.
|
||||
func readCodexHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) {
|
||||
topLevel = map[string]json.RawMessage{}
|
||||
rawHooks = map[string]json.RawMessage{}
|
||||
|
||||
data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err)
|
||||
}
|
||||
if hooksRaw, ok := topLevel["hooks"]; ok {
|
||||
if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err)
|
||||
}
|
||||
}
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
|
||||
// writeCodexHooks folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeCodexHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
|
||||
if len(rawHooks) == 0 {
|
||||
delete(topLevel, "hooks")
|
||||
} else {
|
||||
hooksJSON, err := json.Marshal(rawHooks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hooks: %w", err)
|
||||
}
|
||||
topLevel["hooks"] = hooksJSON
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
|
||||
return fmt.Errorf("create hook dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(topLevel, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", hooksPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCodexManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, codexHookCommandPrefix)
|
||||
}
|
||||
|
||||
// countCodexHooks totals the hook entries across groups so the legacy cleanup
|
||||
// can tell whether stripping AO entries changed anything, including removals
|
||||
// inside a group that survives.
|
||||
func countCodexHooks(groups []codexMatcherGroup) int {
|
||||
total := 0
|
||||
for _, group := range groups {
|
||||
total += len(group.Hooks)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// removeCodexManagedHooks strips AO hook entries from every group,
|
||||
// dropping any group left without hooks.
|
||||
func removeCodexManagedHooks(groups []codexMatcherGroup) []codexMatcherGroup {
|
||||
result := make([]codexMatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]codexHookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !isCodexManagedHook(hook.Command) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
// Package continueagent implements the Continue CLI agent adapter.
|
||||
//
|
||||
// Continue (https://docs.continue.dev/guides/cli) is Continue's terminal coding
|
||||
// agent. Its binary is "cn" (npm package @continuedev/cli) and the AO harness /
|
||||
// manifest id is the string "continue". The Go package and directory are named
|
||||
// "continueagent" because "continue" is a reserved keyword.
|
||||
//
|
||||
// Tier B (Claude Code-compatible hooks): the Continue CLI natively reads Claude
|
||||
// Code hook settings (.claude/settings.json and .claude/settings.local.json) and
|
||||
// dispatches Claude-format hook events (SessionStart, UserPromptSubmit,
|
||||
// PreToolUse, PostToolUse, Stop, Notification) with the standard hook payload
|
||||
// (session_id, hook_event_name, hookSpecificOutput, permissionDecision,
|
||||
// additionalContext). So we reuse the claudecode hook installer and route hook
|
||||
// callbacks through the existing "ao hooks claude-code <evt>" dispatcher — no
|
||||
// Continue-specific native hook config or activity deriver is needed.
|
||||
//
|
||||
// Launch is headless via `cn --print [--auto|--readonly] <prompt>`; the prompt
|
||||
// is the positional argument (in-command delivery). Restore continues a specific
|
||||
// native session by id with `cn --fork <sessionId>` (Continue's `--resume` only
|
||||
// continues the *last* session, so it cannot target a particular AO session).
|
||||
package continueagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// adapterID is the AO harness / manifest id. It is the string "continue"
|
||||
// (NOT the Go package name "continueagent").
|
||||
const adapterID = "continue"
|
||||
|
||||
// Plugin is the Continue CLI agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Continue adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description. ID is "continue".
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Continue",
|
||||
Description: "Run Continue CLI worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds `cn --print [--auto|--readonly] <prompt>`.
|
||||
//
|
||||
// `--print` runs Continue in non-interactive (headless) mode. The prompt is the
|
||||
// positional argument and is delivered in-command. Permission flags map AO's 4
|
||||
// modes onto Continue's two booleans (--auto / --readonly); Default and
|
||||
// AcceptEdits emit no flag so Continue resolves behavior from the user's config.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.continueBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary, "--print"}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks reuses the Claude Code hook installer because the Continue CLI
|
||||
// natively reads Claude Code hook settings.
|
||||
//
|
||||
// The installed commands are "ao hooks claude-code <evt>", so the existing CLI
|
||||
// hook dispatcher routes them to the claude derive logic. The Continue CLI reads
|
||||
// .claude/settings.local.json from the worktree and fires Claude-format events
|
||||
// (SessionStart / UserPromptSubmit / Stop / Notification), giving AO
|
||||
// title/summary/agentSessionId + activity for free without a Continue-specific
|
||||
// hook implementation or code duplication.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return (&claudecode.Plugin{}).GetAgentHooks(ctx, cfg)
|
||||
}
|
||||
|
||||
// GetRestoreCommand builds `cn --print [--auto|--readonly] --fork <agentSessionId>`
|
||||
// when a hook-captured native session id is available. ok=false otherwise (the
|
||||
// manager falls back to a fresh launch). `--fork <id>` continues a specific
|
||||
// session by id; Continue's `--resume` only continues the last session and so
|
||||
// cannot target a particular AO session.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.continueBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 4)
|
||||
cmd = append(cmd, binary, "--print")
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
cmd = append(cmd, "--fork", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo reads hook-derived metadata. Since hook install is delegated to
|
||||
// the claude hooks (via Continue's compat layer), the metadata keys are the
|
||||
// claude ones ("title", "summary", "agentSessionId").
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[ports.MetadataKeyTitle],
|
||||
Summary: session.Metadata[ports.MetadataKeySummary],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then
|
||||
// common npm/global install locations. It returns "cn" as a last resort so
|
||||
// callers get the shell's normal command-not-found behavior if Continue is
|
||||
// absent.
|
||||
func ResolveContinueBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"cn.cmd", "cn.exe", "cn"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "cn.cmd"),
|
||||
filepath.Join(appData, "npm", "cn.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("cn"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/cn",
|
||||
"/opt/homebrew/bin/cn",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".npm-global", "bin", "cn"),
|
||||
filepath.Join(home, ".local", "bin", "cn"),
|
||||
filepath.Join(home, ".npm", "bin", "cn"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) continueBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveContinueBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's 4 permission modes onto Continue's two boolean
|
||||
// flags. Continue exposes only `--readonly` (plan mode, read-only tools) and
|
||||
// `--auto` (all tools allowed); there is no separate yolo/bypass beyond --auto,
|
||||
// and the two flags are mutually exclusive. Default and AcceptEdits emit no flag
|
||||
// so Continue defers to the user's own config / default behavior.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Continue config / default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
// Continue has no granular "accept edits only" mode; defer to config.
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--auto")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--auto")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
package continueagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
m := (&Plugin{}).Manifest()
|
||||
if m.ID != "continue" {
|
||||
t.Fatalf("ID = %q, want continue", m.ID)
|
||||
}
|
||||
if m.Name != "Continue" {
|
||||
t.Fatalf("Name = %q, want Continue", m.Name)
|
||||
}
|
||||
hasAgent := false
|
||||
for _, c := range m.Capabilities {
|
||||
if c == adapters.CapabilityAgent {
|
||||
hasAgent = true
|
||||
}
|
||||
}
|
||||
if !hasAgent {
|
||||
t.Fatal("missing CapabilityAgent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecEmpty(t *testing.T) {
|
||||
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("expected no fields, got %d", len(spec.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if s != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("strategy = %q, want in_command", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBypass(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "do the thing",
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"cn", "--print", "--auto", "--", "do the thing"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAuto(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "refactor auth",
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"cn", "--print", "--auto", "--", "refactor auth"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandDefaultPerms(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "fix it",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"cn", "--print", "--", "fix it"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
joined := strings.Join(cmd, " ")
|
||||
if strings.Contains(joined, "--auto") || strings.Contains(joined, "--readonly") {
|
||||
t.Fatal("should not emit a permission flag for default perms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAcceptEditsNoFlag(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "tidy up",
|
||||
Permissions: ports.PermissionModeAcceptEdits,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"cn", "--print", "--", "tidy up"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v (accept-edits should emit no flag)", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandNoPrompt(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"cn", "--print"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandContextCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
// Force binary resolution (unset cache) so ctx.Err() is hit.
|
||||
_, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from canceled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "sess-abc123",
|
||||
},
|
||||
},
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok=false, want true")
|
||||
}
|
||||
want := []string{"cn", "--print", "--auto", "--fork", "sess-abc123"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandDefaultPerms(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "sess-xyz",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok=false, want true")
|
||||
}
|
||||
want := []string{"cn", "--print", "--fork", "sess-xyz"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandNoID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
_, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{Metadata: map[string]string{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ok=true with no agentSessionId, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandWhitespaceID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
_, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: " ",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ok=true with whitespace agentSessionId, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "cn-ses-1",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok=false, want true")
|
||||
}
|
||||
if info.AgentSessionID != "cn-ses-1" {
|
||||
t.Fatalf("AgentSessionID = %q, want cn-ses-1", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q", info.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok=true with empty metadata, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksDelegates(t *testing.T) {
|
||||
// We don't exercise the full hook merge here (claude tests cover it); just
|
||||
// ensure delegation is wired and succeeds against a temp workspace.
|
||||
plugin := &Plugin{resolvedBinary: "cn"}
|
||||
ws := t.TempDir()
|
||||
if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{
|
||||
WorkspacePath: ws,
|
||||
SessionID: "continue-test-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("GetAgentHooks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContinueBinaryContextCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := ResolveContinueBinary(ctx); err == nil {
|
||||
t.Fatal("expected error from canceled context, got nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package copilot
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Copilot CLI hook event onto an AO activity state.
|
||||
// The bool is false when the event carries no activity signal.
|
||||
//
|
||||
// event is the AO hook sub-command name installed in copilotManagedHooks
|
||||
// ("session-start", "user-prompt-submit", "permission-request", "stop"), NOT the
|
||||
// native Copilot event name. Keeping this beside hooks.go means the events AO
|
||||
// installs and what they mean live in one place.
|
||||
//
|
||||
// Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT
|
||||
// fire in non-interactive `-p` mode, while preToolUse fires before every tool
|
||||
// invocation (including ones that would prompt the user for approval) and is
|
||||
// the most reliable signal in CLI pipe mode (-p). AO still installs every event
|
||||
// so interactive resume and future modes report activity; the
|
||||
// permission-request → waiting_input mapping (driven by preToolUse) is the one
|
||||
// that always fires under AO's headless launch.
|
||||
//
|
||||
// TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's
|
||||
// sessionEnd/agentStop hook proves reliable in `-p` mode, map a real
|
||||
// session-end here. Until then, the lifecycle reaper marks a dead Copilot
|
||||
// runtime exited even when the last hook signal was sticky waiting_input.
|
||||
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
|
||||
switch event {
|
||||
case "session-start":
|
||||
return domain.ActivityActive, true
|
||||
case "user-prompt-submit":
|
||||
return domain.ActivityActive, true
|
||||
case "stop":
|
||||
return domain.ActivityIdle, true
|
||||
case "permission-request":
|
||||
return domain.ActivityWaitingInput, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
// Package copilot implements the GitHub Copilot CLI agent adapter: launching new
|
||||
// headless sessions, resuming hook-tracked sessions, installing workspace-local
|
||||
// hooks, and reading hook-derived session info.
|
||||
//
|
||||
// This adapter targets the standalone agentic GitHub Copilot CLI (binary
|
||||
// "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot`
|
||||
// suggest/explain extension.
|
||||
//
|
||||
// Launch runs the CLI in non-interactive ("programmatic") mode with `-p
|
||||
// <prompt>` so it executes the task and exits. Permission modes map onto the
|
||||
// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`).
|
||||
// Restore continues an existing session via `--resume <agentSessionId>`; the
|
||||
// native session id (a UUID under ~/.copilot/session-state/) is captured by the
|
||||
// SessionStart hook AO installs (see hooks.go).
|
||||
//
|
||||
// AO-managed sessions derive native session identity and display metadata from
|
||||
// Copilot hooks instead of transcript/cache scans.
|
||||
package copilot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "copilot"
|
||||
|
||||
copilotTitleMetadataKey = "title"
|
||||
copilotSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the GitHub Copilot CLI agent adapter. It is safe for concurrent use;
|
||||
// the binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Copilot adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "GitHub Copilot",
|
||||
Description: "Run GitHub Copilot CLI worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Copilot exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new headless Copilot session:
|
||||
//
|
||||
// copilot [permission flags] [-p <prompt>]
|
||||
//
|
||||
// The prompt is delivered with `-p`, which runs the prompt in non-interactive
|
||||
// mode and exits when done. Copilot CLI does not have a documented
|
||||
// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.copilotBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "-p", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the
|
||||
// launch command itself (via `-p`).
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Copilot
|
||||
// session: `copilot [permission flags] --resume <agentSessionId> [-p <prompt>]`.
|
||||
// ok is false when the hook-derived native session id has not landed yet, so
|
||||
// callers can fall back to fresh launch behavior.
|
||||
//
|
||||
// ports.RestoreConfig carries no Prompt field, so resume is issued without a new
|
||||
// `-p`; the manager re-sends the prompt through its own delivery path when one is
|
||||
// needed.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.copilotBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 8)
|
||||
cmd = append(cmd, binary)
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
cmd = append(cmd, "--resume", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces Copilot hook-derived metadata. Metadata is intentionally
|
||||
// nil for Copilot: callers get the normalized fields directly.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[copilotTitleMetadataKey],
|
||||
Summary: session.Metadata[copilotSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// ResolveCopilotBinary returns the path to the copilot binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (npm global,
|
||||
// Homebrew). Returns "copilot" as a last-ditch fallback so callers see a clear
|
||||
// "command not found" rather than an empty argv.
|
||||
func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"copilot.cmd", "copilot.exe", "copilot"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "copilot.cmd"),
|
||||
filepath.Join(appData, "npm", "copilot.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("copilot"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/copilot",
|
||||
"/opt/homebrew/bin/copilot",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".copilot", "bin", "copilot"),
|
||||
filepath.Join(home, ".npm", "bin", "copilot"),
|
||||
filepath.Join(home, ".local", "bin", "copilot"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) copilotBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveCopilotBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's 4 permission modes onto Copilot CLI approval
|
||||
// flags (https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-programmatic-reference):
|
||||
//
|
||||
// default → no flag (defer to ~/.copilot config / per-tool prompts)
|
||||
// accept-edits → --allow-tool 'write' (auto-approve file edits only)
|
||||
// auto → --allow-all-tools (auto-approve every tool, still scoped paths/urls)
|
||||
// bypass-permissions → --allow-all (full bypass: tools, paths, urls)
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's ~/.copilot config / interactive prompts.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--allow-tool", "write")
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--allow-all-tools")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--allow-all")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
package copilot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifestID(t *testing.T) {
|
||||
got := New().Manifest()
|
||||
if got.ID != "copilot" {
|
||||
t.Fatalf("Manifest().ID = %q, want %q", got.ID, "copilot")
|
||||
}
|
||||
if got.Name != "GitHub Copilot" {
|
||||
t.Fatalf("Manifest().Name = %q, want %q", got.Name, "GitHub Copilot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBuildsArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-fix this",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"copilot", "--allow-all", "-p", "-fix this"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(cmd, "-p") {
|
||||
t.Fatalf("command %#v unexpectedly contains -p", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission ports.PermissionMode
|
||||
want []string
|
||||
notExpected []string
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
permission: ports.PermissionModeDefault,
|
||||
notExpected: []string{"--allow-tool", "--allow-all-tools", "--allow-all"},
|
||||
},
|
||||
{
|
||||
name: "accept-edits",
|
||||
permission: ports.PermissionModeAcceptEdits,
|
||||
want: []string{"--allow-tool", "write"},
|
||||
},
|
||||
{
|
||||
name: "auto",
|
||||
permission: ports.PermissionModeAuto,
|
||||
want: []string{"--allow-all-tools"},
|
||||
},
|
||||
{
|
||||
name: "bypass-permissions",
|
||||
permission: ports.PermissionModeBypassPermissions,
|
||||
want: []string{"--allow-all"},
|
||||
},
|
||||
{
|
||||
name: "empty falls back to default",
|
||||
permission: "",
|
||||
notExpected: []string{"--allow-tool", "--allow-all-tools", "--allow-all"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) {
|
||||
t.Fatalf("command %#v does not contain %#v", cmd, tt.want)
|
||||
}
|
||||
for _, ne := range tt.notExpected {
|
||||
if contains(cmd, ne) {
|
||||
t.Fatalf("command %#v unexpectedly contains %q", cmd, ne)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) {
|
||||
plugin := New()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "hi"}); err == nil {
|
||||
t.Fatal("GetLaunchCommand with canceled context: err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("unexpected config fields: %#v", spec.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{"copilot", "--allow-all-tools", "--resume", "uuid-123"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ref ports.SessionRef
|
||||
}{
|
||||
{"empty session ref", ports.SessionRef{}},
|
||||
{"empty metadata", ports.SessionRef{Metadata: map[string]string{}}},
|
||||
{"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
|
||||
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: tc.ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("cmd = %#v, want nil", cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "uuid-123",
|
||||
copilotTitleMetadataKey: "Fix login redirect",
|
||||
copilotSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("ok = false, want true")
|
||||
}
|
||||
if info.AgentSessionID != "uuid-123" {
|
||||
t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q, want hook title", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q, want hook summary", info.Summary)
|
||||
}
|
||||
if info.Metadata != nil {
|
||||
t.Fatalf("Metadata = %#v, want nil for Copilot", info.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero value", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksInstallsCopilotHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
workspace := t.TempDir()
|
||||
|
||||
hooksPath := copilotHooksPath(workspace)
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed a user-owned agentStop hook plus an unrelated top-level field; both
|
||||
// must survive install.
|
||||
existing := `{"version":1,"disableAllHooks":false,"hooks":{"agentStop":[{"type":"command","bash":"custom stop hook","powershell":"custom stop hook"}]}}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second install must not duplicate AO hook commands.
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var file copilotHookFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file.Version != copilotHooksVersion {
|
||||
t.Fatalf("version = %d, want %d", file.Version, copilotHooksVersion)
|
||||
}
|
||||
if file.DisableAllHooks == nil || *file.DisableAllHooks {
|
||||
t.Fatalf("disableAllHooks not preserved: %#v", file.DisableAllHooks)
|
||||
}
|
||||
for _, spec := range copilotManagedHooks {
|
||||
command := copilotHookCommandPrefix + spec.Command
|
||||
if count := countCopilotHookCommand(file.Hooks[spec.Event], command); count != 1 {
|
||||
t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, file.Hooks[spec.Event])
|
||||
}
|
||||
}
|
||||
if countCopilotHookCommand(file.Hooks["agentStop"], "custom stop hook") != 1 {
|
||||
t.Fatalf("existing agentStop hook was not preserved: %#v", file.Hooks["agentStop"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesCopilotHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := copilotHooksPath(workspace)
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
|
||||
|
||||
// Pre-seed a user's own agentStop hook; it must survive uninstall.
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{"version":1,"hooks":{"agentStop":[{"type":"command","bash":"custom stop hook","powershell":"custom stop hook"}]}}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed {
|
||||
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
|
||||
}
|
||||
|
||||
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var file copilotHookFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range copilotManagedHooks {
|
||||
command := copilotHookCommandPrefix + spec.Command
|
||||
if got := countCopilotHookCommand(file.Hooks[spec.Event], command); got != 0 {
|
||||
t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, command, got)
|
||||
}
|
||||
}
|
||||
if countCopilotHookCommand(file.Hooks["agentStop"], "custom stop hook") != 1 {
|
||||
t.Fatalf("user agentStop hook not preserved: %#v", file.Hooks["agentStop"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreHooksInstalledMissingFile(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
installed, err := plugin.AreHooksInstalled(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed {
|
||||
t.Fatal("AreHooksInstalled on empty workspace = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookMethodsRequireWorkspacePath(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil {
|
||||
t.Fatal("GetAgentHooks with empty WorkspacePath: err = nil, want non-nil")
|
||||
}
|
||||
if err := plugin.UninstallHooks(ctx, ""); err == nil {
|
||||
t.Fatal("UninstallHooks with empty path: err = nil, want non-nil")
|
||||
}
|
||||
if _, err := plugin.AreHooksInstalled(ctx, ""); err == nil {
|
||||
t.Fatal("AreHooksInstalled with empty path: err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopilotManagedHooksUseDocumentedEventNames pins the JSON keys AO writes
|
||||
// into .github/hooks/ao.json to the camelCase names Copilot CLI documents
|
||||
// (https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks).
|
||||
// Drifting back to lowercase-dashed or any other casing silently disables the
|
||||
// hooks, so this is a tripwire for that class of regression.
|
||||
func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) {
|
||||
wantEventByCommand := map[string]string{
|
||||
"session-start": "sessionStart",
|
||||
"user-prompt-submit": "userPromptSubmitted",
|
||||
"permission-request": "preToolUse",
|
||||
"stop": "agentStop",
|
||||
}
|
||||
if len(copilotManagedHooks) != len(wantEventByCommand) {
|
||||
t.Fatalf("copilotManagedHooks length = %d, want %d", len(copilotManagedHooks), len(wantEventByCommand))
|
||||
}
|
||||
for _, spec := range copilotManagedHooks {
|
||||
want, ok := wantEventByCommand[spec.Command]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected AO sub-command %q in copilotManagedHooks", spec.Command)
|
||||
}
|
||||
if spec.Event != want {
|
||||
t.Fatalf("command %q event = %q, want %q (Copilot CLI documented camelCase)", spec.Command, spec.Event, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
event string
|
||||
wantState domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"session-start", domain.ActivityActive, true},
|
||||
{"user-prompt-submit", domain.ActivityActive, true},
|
||||
{"stop", domain.ActivityIdle, true},
|
||||
{"permission-request", domain.ActivityWaitingInput, true},
|
||||
{"unknown", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.event, func(t *testing.T) {
|
||||
state, ok := DeriveActivityState(tt.event, nil)
|
||||
if state != tt.wantState || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSubsequence(values []string, needle []string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
}
|
||||
for start := range values {
|
||||
if start+len(needle) > len(values) {
|
||||
return false
|
||||
}
|
||||
ok := true
|
||||
for offset, want := range needle {
|
||||
if values[start+offset] != want {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countCopilotHookCommand(entries []copilotHookEntry, command string) int {
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.Bash == command || entry.Powershell == command {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
package copilot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
// copilotHooksDir is the repository-scope hooks directory Copilot CLI reads
|
||||
// (.github/hooks/*.json). AO writes a single dedicated file there so it never
|
||||
// disturbs other hook files the user or repo may ship.
|
||||
copilotHooksDir = ".github/hooks"
|
||||
copilotHooksFileName = "ao.json"
|
||||
|
||||
// copilotHooksVersion is the schema version of the hooks file (Copilot uses 1).
|
||||
copilotHooksVersion = 1
|
||||
|
||||
// copilotHookCommandPrefix identifies the hook commands AO owns, so install
|
||||
// skips duplicates and uninstall recognizes AO entries by prefix without an
|
||||
// embedded template to diff against. The CLI dispatcher routes
|
||||
// `ao hooks copilot <event>` to DeriveActivityState.
|
||||
copilotHookCommandPrefix = "ao hooks copilot "
|
||||
copilotHookTimeoutSec = 30
|
||||
)
|
||||
|
||||
// copilotHookFile is the on-disk shape of .github/hooks/ao.json. AO owns this
|
||||
// dedicated file outright, so it only models the keys it manages (version,
|
||||
// disableAllHooks, hooks); user-defined hooks live in their own .github/hooks/*
|
||||
// files and are never touched.
|
||||
type copilotHookFile struct {
|
||||
Version int `json:"version"`
|
||||
DisableAllHooks *bool `json:"disableAllHooks,omitempty"`
|
||||
Hooks map[string][]copilotHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
// copilotHookEntry is one hook command. Copilot entries carry separate bash and
|
||||
// powershell command strings (both required for cross-platform), a type, an
|
||||
// optional working dir, and a timeout in seconds.
|
||||
type copilotHookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Bash string `json:"bash,omitempty"`
|
||||
Powershell string `json:"powershell,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
TimeoutSec int `json:"timeoutSec,omitempty"`
|
||||
}
|
||||
|
||||
// copilotHookSpec describes one hook AO installs, defined in code rather than
|
||||
// read from an embedded settings file.
|
||||
type copilotHookSpec struct {
|
||||
// Event is the native Copilot camelCase event name (sessionStart, ...).
|
||||
Event string
|
||||
// Command is the AO sub-command suffix (session-start, ...). It is appended
|
||||
// to copilotHookCommandPrefix to form both the bash and powershell command,
|
||||
// and is the value DeriveActivityState switches on.
|
||||
Command string
|
||||
}
|
||||
|
||||
// copilotManagedHooks is the source of truth for the hooks AO installs. The AO
|
||||
// sub-command names (session-start, user-prompt-submit, permission-request,
|
||||
// stop) are exactly what DeriveActivityState in activity.go switches on.
|
||||
//
|
||||
// Native event names use Copilot's camelCase form, taken verbatim from
|
||||
// https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks
|
||||
// (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse,
|
||||
// errorOccurred, agentStop). Copilot does not document a "permissionRequest"
|
||||
// event — the closest signal that AO's permission-request sub-command can
|
||||
// piggyback on is preToolUse, which fires before any tool invocation, including
|
||||
// the ones that would otherwise prompt the user for approval. This is a
|
||||
// many-to-one collapse: every preToolUse currently produces ActivityWaitingInput
|
||||
// via the permission-request sub-command. agentStop is the per-turn completion
|
||||
// signal and maps to the "stop" sub-command (turn end → idle).
|
||||
var copilotManagedHooks = []copilotHookSpec{
|
||||
{Event: "sessionStart", Command: "session-start"},
|
||||
{Event: "userPromptSubmitted", Command: "user-prompt-submit"},
|
||||
{Event: "preToolUse", Command: "permission-request"},
|
||||
{Event: "agentStop", Command: "stop"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Copilot hooks into the worktree-local
|
||||
// .github/hooks/ao.json file (the repository-scope hooks config Copilot CLI
|
||||
// reads). The hooks report normalized activity-state signals back into AO's
|
||||
// store. Existing AO entries are not duplicated and any unrelated keys are
|
||||
// preserved, so the install is idempotent.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.WorkspacePath) == "" {
|
||||
return errors.New("copilot.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := copilotHooksPath(cfg.WorkspacePath)
|
||||
file, err := readCopilotHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copilot.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
if file.Hooks == nil {
|
||||
file.Hooks = map[string][]copilotHookEntry{}
|
||||
}
|
||||
for _, spec := range copilotManagedHooks {
|
||||
command := copilotHookCommandPrefix + spec.Command
|
||||
if copilotHookCommandExists(file.Hooks[spec.Event], command) {
|
||||
continue
|
||||
}
|
||||
file.Hooks[spec.Event] = append(file.Hooks[spec.Event], copilotHookEntry{
|
||||
Type: "command",
|
||||
Bash: command,
|
||||
Powershell: command,
|
||||
TimeoutSec: copilotHookTimeoutSec,
|
||||
})
|
||||
}
|
||||
|
||||
if err := writeCopilotHooks(hooksPath, file); err != nil {
|
||||
return fmt.Errorf("copilot.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), copilotHooksFileName); err != nil {
|
||||
return fmt.Errorf("copilot.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Copilot hooks from the workspace-local
|
||||
// .github/hooks/ao.json file, leaving user-defined hooks and unrelated keys
|
||||
// untouched. A missing file is a no-op.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return errors.New("copilot.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := copilotHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
file, err := readCopilotHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copilot.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
for event, entries := range file.Hooks {
|
||||
kept := removeCopilotManagedHooks(entries)
|
||||
if len(kept) == 0 {
|
||||
delete(file.Hooks, event)
|
||||
continue
|
||||
}
|
||||
file.Hooks[event] = kept
|
||||
}
|
||||
|
||||
if err := writeCopilotHooks(hooksPath, file); err != nil {
|
||||
return fmt.Errorf("copilot.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Copilot hook is present in the
|
||||
// workspace-local hooks file. A missing file means none are installed.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, errors.New("copilot.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := copilotHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
file, err := readCopilotHooks(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("copilot.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for _, entries := range file.Hooks {
|
||||
for _, entry := range entries {
|
||||
if isCopilotManagedHook(entry) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func copilotHooksPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, filepath.FromSlash(copilotHooksDir), copilotHooksFileName)
|
||||
}
|
||||
|
||||
// readCopilotHooks loads the hooks file. A missing or empty file yields an empty
|
||||
// file struct with a nil hooks map (and the AO schema version, used on write).
|
||||
func readCopilotHooks(hooksPath string) (copilotHookFile, error) {
|
||||
file := copilotHookFile{Version: copilotHooksVersion}
|
||||
|
||||
data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return file, nil
|
||||
}
|
||||
if err != nil {
|
||||
return copilotHookFile{}, fmt.Errorf("read %s: %w", hooksPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return file, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return copilotHookFile{}, fmt.Errorf("parse %s: %w", hooksPath, err)
|
||||
}
|
||||
if file.Version == 0 {
|
||||
file.Version = copilotHooksVersion
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// writeCopilotHooks writes the file. An empty hooks map still writes a valid
|
||||
// (versioned) file so AreHooksInstalled and re-install see a consistent shape.
|
||||
func writeCopilotHooks(hooksPath string, file copilotHookFile) error {
|
||||
if file.Version == 0 {
|
||||
file.Version = copilotHooksVersion
|
||||
}
|
||||
if file.Hooks == nil {
|
||||
file.Hooks = map[string][]copilotHookEntry{}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
|
||||
return fmt.Errorf("create hooks dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := atomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", hooksPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to path via a temp file in the same directory
|
||||
// followed by a rename, so a crash or signal mid-write can't leave a truncated
|
||||
// or empty file that Copilot then fails to parse (silently disabling hooks).
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
// isCopilotManagedHook reports whether an entry is one AO owns, recognized by the
|
||||
// command prefix on either the bash or powershell command.
|
||||
func isCopilotManagedHook(entry copilotHookEntry) bool {
|
||||
return strings.HasPrefix(entry.Bash, copilotHookCommandPrefix) ||
|
||||
strings.HasPrefix(entry.Powershell, copilotHookCommandPrefix)
|
||||
}
|
||||
|
||||
func copilotHookCommandExists(entries []copilotHookEntry, command string) bool {
|
||||
for _, entry := range entries {
|
||||
if entry.Bash == command || entry.Powershell == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// removeCopilotManagedHooks strips AO hook entries from a slice, preserving
|
||||
// user-defined entries in order.
|
||||
func removeCopilotManagedHooks(entries []copilotHookEntry) []copilotHookEntry {
|
||||
kept := make([]copilotHookEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !isCopilotManagedHook(entry) {
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package crush
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Crush hook event onto an AO activity state.
|
||||
// Currently a no-op since Crush doesn't have full hooks support like Claude Code and Codex.
|
||||
// The bool is false to indicate no activity signal is available.
|
||||
//
|
||||
// TODO(crush): Implement activity state mapping once Crush has native hook support.
|
||||
// Until then, runtime exit falls back to the reaper.
|
||||
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
|
||||
// No-op for now since Crush doesn't have full hooks support
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package crush
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveActivityStateReturnsFalse(t *testing.T) {
|
||||
state, ok := DeriveActivityState("some-event", []byte("payload"))
|
||||
if ok {
|
||||
t.Fatalf("unexpected ok: got true, want false (DeriveActivityState is a no-op for Crush)")
|
||||
}
|
||||
if state != "" {
|
||||
t.Fatalf("unexpected non-empty state: got %q", state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
// Package crush implements the Crush agent adapter: launching new sessions,
|
||||
// resuming sessions by native ID, and reading session info.
|
||||
//
|
||||
// Crush differs from other agents in that it doesn't have full hooks support,
|
||||
// so GetAgentHooks and SessionInfo are no-ops for now. Session tracking is
|
||||
// done through basic session ID management only.
|
||||
package crush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
// adapterID is the registry id and the value users pass to
|
||||
// `ao spawn --agent`. It matches domain.HarnessCrush.
|
||||
adapterID = "crush"
|
||||
)
|
||||
|
||||
// Plugin is the Crush agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Crush adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: adapterID,
|
||||
Name: "Crush",
|
||||
Description: "Run Crush worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Crush exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start an interactive Crush session.
|
||||
// Shape:
|
||||
//
|
||||
// crush [--cwd <WorkspacePath>] [--yolo] [-- <Prompt>]
|
||||
//
|
||||
// The session runs in the worktree (cwd is set by the runtime). Crush doesn't
|
||||
// have native system prompt support, so cfg.SystemPrompt / SystemPromptFile are
|
||||
// intentionally ignored. The initial task prompt is delivered as a positional
|
||||
// argument after `--`. The --yolo flag corresponds to bypass-permissions mode.
|
||||
//
|
||||
// We intentionally do not pass --session on launch: cfg.SessionID is the
|
||||
// AO-internal id, not a Crush-native session id. Letting Crush mint its own
|
||||
// native session id (captured by hooks into session metadata) keeps launch
|
||||
// consistent with GetRestoreCommand, which resumes using that native id.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.crushBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
|
||||
// Crush uses --cwd to set working directory
|
||||
if cfg.WorkspacePath != "" {
|
||||
cmd = append(cmd, "--cwd", cfg.WorkspacePath)
|
||||
}
|
||||
|
||||
// Handle permission modes
|
||||
if cfg.Permissions == ports.PermissionModeBypassPermissions {
|
||||
cmd = append(cmd, "--yolo")
|
||||
}
|
||||
|
||||
// Prompt is passed after `--` so a leading "-" is not read as a flag
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Crush receives its prompt in the
|
||||
// launch command itself as a positional argument.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Crush session:
|
||||
// `crush [--cwd <WorkspacePath>] [--yolo] --session <agentSessionId>`.
|
||||
// It re-applies the permission flag but not the prompt, which the session
|
||||
// already carries. ok is false when the native session id is not available.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.crushBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
|
||||
if cfg.Session.WorkspacePath != "" {
|
||||
cmd = append(cmd, "--cwd", cfg.Session.WorkspacePath)
|
||||
}
|
||||
|
||||
if cfg.Permissions == ports.PermissionModeBypassPermissions {
|
||||
cmd = append(cmd, "--yolo")
|
||||
}
|
||||
|
||||
cmd = append(cmd, "--session", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces Crush session metadata. Currently a no-op since Crush
|
||||
// doesn't have full hooks support like Claude Code and Codex. Returns false
|
||||
// to indicate no metadata is available.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
// No-op for now since Crush doesn't have full hooks support
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
|
||||
// ResolveCrushBinary returns the path to the crush binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations.
|
||||
// Returns "crush" as a last-ditch fallback.
|
||||
func ResolveCrushBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"crush.cmd", "crush.exe", "crush"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "crush.cmd"),
|
||||
filepath.Join(appData, "npm", "crush.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "crush.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("crush"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/crush",
|
||||
"/opt/homebrew/bin/crush",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "crush"),
|
||||
filepath.Join(home, ".cargo", "bin", "crush"),
|
||||
filepath.Join(home, ".npm", "bin", "crush"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) crushBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveCrushBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
package crush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "crush"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "fix this",
|
||||
WorkspacePath: "/tmp/workspace",
|
||||
SessionID: "test-session-id",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// cfg.SessionID is the AO-internal id and must NOT be passed as --session on
|
||||
// launch; Crush mints its own native id, which GetRestoreCommand resumes by.
|
||||
want := []string{
|
||||
"crush",
|
||||
"--cwd", "/tmp/workspace",
|
||||
"--yolo",
|
||||
"--", "fix this",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission ports.PermissionMode
|
||||
want []string
|
||||
notExpected string
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
permission: ports.PermissionModeDefault,
|
||||
notExpected: "--yolo",
|
||||
},
|
||||
{
|
||||
name: "accept-edits",
|
||||
permission: ports.PermissionModeAcceptEdits,
|
||||
want: nil, // Crush doesn't have granular permission modes
|
||||
notExpected: "--yolo",
|
||||
},
|
||||
{
|
||||
name: "auto",
|
||||
permission: ports.PermissionModeAuto,
|
||||
want: nil, // Crush doesn't have granular permission modes
|
||||
notExpected: "--yolo",
|
||||
},
|
||||
{
|
||||
name: "bypass-permissions",
|
||||
permission: ports.PermissionModeBypassPermissions,
|
||||
want: []string{"--yolo"},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
permission: "",
|
||||
notExpected: "--yolo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "crush"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) {
|
||||
t.Fatalf("command %#v does not contain %#v", cmd, tt.want)
|
||||
}
|
||||
if tt.notExpected != "" && contains(cmd, tt.notExpected) {
|
||||
t.Fatalf("command %#v contains %q", cmd, tt.notExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "crush"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected prompt delivery strategy: got %v, want %v", got, ports.PromptDeliveryInCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "crush"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentSessionID string
|
||||
workspacePath string
|
||||
permission ports.PermissionMode
|
||||
wantOk bool
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: "restore with session id",
|
||||
agentSessionID: "crush-session-123",
|
||||
workspacePath: "/tmp/workspace",
|
||||
permission: ports.PermissionModeDefault,
|
||||
wantOk: true,
|
||||
wantContains: []string{"--cwd", "/tmp/workspace", "--session", "crush-session-123"},
|
||||
},
|
||||
{
|
||||
name: "restore with bypass permissions",
|
||||
agentSessionID: "crush-session-456",
|
||||
workspacePath: "/tmp/workspace",
|
||||
permission: ports.PermissionModeBypassPermissions,
|
||||
wantOk: true,
|
||||
wantContains: []string{"--cwd", "/tmp/workspace", "--yolo", "--session", "crush-session-456"},
|
||||
},
|
||||
{
|
||||
name: "no session id",
|
||||
agentSessionID: "",
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{"agentSessionId": tt.agentSessionID},
|
||||
WorkspacePath: tt.workspacePath,
|
||||
},
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok != tt.wantOk {
|
||||
t.Fatalf("unexpected ok: got %v, want %v", ok, tt.wantOk)
|
||||
}
|
||||
if tt.wantOk && len(tt.wantContains) > 0 && !containsSubsequence(cmd, tt.wantContains) {
|
||||
t.Fatalf("command %#v does not contain %#v", cmd, tt.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReturnsFalse(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
ID: "session-123",
|
||||
Metadata: map[string]string{"agentSessionId": "crush-session-123"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("unexpected ok: got true, want false (SessionInfo is a no-op for Crush)")
|
||||
}
|
||||
if info.AgentSessionID != "" || info.Title != "" || info.Summary != "" {
|
||||
t.Fatalf("unexpected non-empty info: got %#v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
manifest := plugin.Manifest()
|
||||
if manifest.ID != adapterID {
|
||||
t.Fatalf("unexpected manifest ID: got %q, want %q", manifest.ID, adapterID)
|
||||
}
|
||||
if manifest.Name != "Crush" {
|
||||
t.Fatalf("unexpected manifest name: got %q, want \"Crush\"", manifest.Name)
|
||||
}
|
||||
if len(manifest.Capabilities) != 1 {
|
||||
t.Fatalf("unexpected capabilities count: got %d, want 1", len(manifest.Capabilities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecReturnsEmpty(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("unexpected config spec fields: got %d, want 0", len(spec.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksIsNoOp(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{
|
||||
WorkspacePath: "/tmp/workspace",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from GetAgentHooks (no-op): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksIsNoOp(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
err := plugin.UninstallHooks(context.Background(), "/tmp/workspace")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from UninstallHooks (no-op): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreHooksInstalledReturnsFalse(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
installed, err := plugin.AreHooksInstalled(context.Background(), "/tmp/workspace")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from AreHooksInstalled (no-op): %v", err)
|
||||
}
|
||||
if installed {
|
||||
t.Fatalf("unexpected installed status: got true, want false (hooks are no-op for Crush)")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions from codex_test.go
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSubsequence(haystack, needle []string) bool {
|
||||
for i := 0; i <= len(haystack)-len(needle); i++ {
|
||||
match := true
|
||||
for j, n := range needle {
|
||||
if haystack[i+j] != n {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package crush
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// GetAgentHooks is a no-op for Crush since it doesn't have full hooks support
|
||||
// like Claude Code and Codex. Crush doesn't have a native hook configuration system
|
||||
// that AO can integrate with for session metadata tracking.
|
||||
//
|
||||
// TODO(crush): Implement hook installation once Crush has native hook support.
|
||||
// Until then, session metadata tracking is not available.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// No-op for now since Crush doesn't have full hooks support
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks is a no-op for Crush.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
// No-op for now since Crush doesn't have full hooks support
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled is a no-op for Crush.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// No-op for now since Crush doesn't have full hooks support
|
||||
return false, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package cursor
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Cursor hook event onto an AO activity state. The
|
||||
// bool is false when the event carries no activity signal.
|
||||
//
|
||||
// event is the AO hook sub-command name installed in cursorManagedHooks
|
||||
// ("session-start", "user-prompt-submit", "stop", "permission-request"), not
|
||||
// the native Cursor event name. Cursor currently has no SessionEnd/Notification
|
||||
// equivalent in the adapter, so runtime exit still falls back to the reaper.
|
||||
//
|
||||
// TODO(cursor): ActivityExited is still runtime-observation-owned. If Cursor
|
||||
// adds a native session/process-end hook, map that hook to ActivityExited here.
|
||||
// Until then, make sure the lifecycle reaper can still mark a dead Cursor
|
||||
// runtime as exited even when the last hook signal was sticky waiting_input.
|
||||
func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) {
|
||||
switch event {
|
||||
case "session-start":
|
||||
return domain.ActivityActive, true
|
||||
case "user-prompt-submit":
|
||||
return domain.ActivityActive, true
|
||||
case "stop":
|
||||
return domain.ActivityIdle, true
|
||||
case "permission-request":
|
||||
return domain.ActivityWaitingInput, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeriveActivityState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
want domain.ActivityState
|
||||
wantOK bool
|
||||
}{
|
||||
{"session start -> active", "session-start", domain.ActivityActive, true},
|
||||
{"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true},
|
||||
{"stop -> idle", "stop", domain.ActivityIdle, true},
|
||||
{"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true},
|
||||
{"unknown event -> no signal", "frobnicate", "", false},
|
||||
{"native event name -> no signal", "beforeShellExecution", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
// Package cursor implements the Cursor CLI agent adapter: launching new
|
||||
// sessions, resuming hook-tracked sessions, installing workspace-local hooks,
|
||||
// and reading hook-derived session info.
|
||||
//
|
||||
// AO-managed sessions derive native session identity and display
|
||||
// metadata from Cursor hooks instead of transcript/cache scans. The driven
|
||||
// binary is `cursor-agent` (not the `cursor` editor binary).
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
cursorTitleMetadataKey = "title"
|
||||
cursorSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Cursor agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Cursor adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: "cursor",
|
||||
Name: "Cursor",
|
||||
Description: "Run Cursor CLI agent worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Cursor exposes none yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds the argv to start a new Cursor CLI session:
|
||||
//
|
||||
// cursor-agent -p --output-format stream-json --trust [permission flags] <prompt>
|
||||
//
|
||||
// `-p` runs print/non-interactive mode, `--output-format stream-json` emits the
|
||||
// machine-readable event stream AO consumes, and `--trust` skips the
|
||||
// workspace-trust prompt. The prompt is positional and must come last, so a
|
||||
// leading "-" is not read as a flag.
|
||||
//
|
||||
// Cursor has no inline/file system-prompt flag: it reads workspace rule files
|
||||
// (AGENTS.md, .cursor/rules, CLAUDE.md). SystemPrompt/SystemPromptFile are
|
||||
// therefore not injected via a launch flag here.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.cursorBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary, "-p", "--output-format", "stream-json", "--trust"}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
// Prompt is positional and must be last. The `--` sentinel ends option
|
||||
// parsing so a leading "-" in the prompt is not read as a flag.
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Cursor receives its prompt in the
|
||||
// launch command itself.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Cursor CLI
|
||||
// session:
|
||||
//
|
||||
// cursor-agent -p --output-format stream-json --trust [perm flags] --resume <id>
|
||||
//
|
||||
// ok is false when the hook-derived native session id has not landed yet, so
|
||||
// callers can fall back to fresh launch behavior. ports.RestoreConfig carries no
|
||||
// prompt, so none is appended.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.cursorBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 10)
|
||||
cmd = append(cmd, binary, "-p", "--output-format", "stream-json", "--trust")
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
cmd = append(cmd, "--resume", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo surfaces Cursor hook-derived metadata. Metadata is intentionally
|
||||
// nil for Cursor: callers get the normalized fields directly.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[cursorTitleMetadataKey],
|
||||
Summary: session.Metadata[cursorSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// ResolveCursorBinary returns the path to the cursor-agent binary on this
|
||||
// machine, searching PATH then a handful of well-known install locations.
|
||||
// Returns "cursor-agent" as a last-ditch fallback so callers see a clear
|
||||
// "command not found" rather than an empty argv.
|
||||
func ResolveCursorBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"cursor-agent.exe", "cursor-agent.cmd", "cursor-agent"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cursor: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("cursor-agent"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".local", "bin", "cursor-agent"))
|
||||
}
|
||||
candidates = append(candidates,
|
||||
"/usr/local/bin/cursor-agent",
|
||||
"/opt/homebrew/bin/cursor-agent",
|
||||
)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("cursor: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) cursorBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveCursorBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Cursor config approvalMode.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
// No dedicated accept-edits flag exists; cursor has no accept-edits
|
||||
// flag, it is governed by .cursor/cli.json permissions.
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--force")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--yolo")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestGetLaunchCommandBuildsArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
Prompt: "-fix this",
|
||||
SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"),
|
||||
SystemPrompt: "ignored",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// System prompt is never injected via a flag for cursor; the prompt is
|
||||
// positional and last, guarded by a `--` end-of-options sentinel so a
|
||||
// leading "-" is not parsed as a flag.
|
||||
want := []string{
|
||||
"cursor-agent",
|
||||
"-p", "--output-format", "stream-json", "--trust",
|
||||
"--yolo",
|
||||
"--", "-fix this",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: ports.PermissionModeDefault,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{"cursor-agent", "-p", "--output-format", "stream-json", "--trust"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission ports.PermissionMode
|
||||
want []string
|
||||
notExpected []string
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
permission: ports.PermissionModeDefault,
|
||||
notExpected: []string{"--force", "--yolo"},
|
||||
},
|
||||
{
|
||||
name: "accept-edits",
|
||||
permission: ports.PermissionModeAcceptEdits,
|
||||
notExpected: []string{"--force", "--yolo"},
|
||||
},
|
||||
{
|
||||
name: "auto",
|
||||
permission: ports.PermissionModeAuto,
|
||||
want: []string{"--force"},
|
||||
notExpected: []string{"--yolo"},
|
||||
},
|
||||
{
|
||||
name: "bypass-permissions",
|
||||
permission: ports.PermissionModeBypassPermissions,
|
||||
want: []string{"--yolo"},
|
||||
notExpected: []string{"--force"},
|
||||
},
|
||||
{
|
||||
name: "unknown falls back to default",
|
||||
permission: "",
|
||||
notExpected: []string{"--force", "--yolo"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Permissions: tt.permission,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) {
|
||||
t.Fatalf("command %#v does not contain %#v", cmd, tt.want)
|
||||
}
|
||||
for _, ne := range tt.notExpected {
|
||||
if contains(cmd, ne) {
|
||||
t.Fatalf("command %#v unexpectedly contains %q", cmd, ne)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("unexpected config fields: %#v", spec.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "chat-123"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{
|
||||
"cursor-agent",
|
||||
"-p", "--output-format", "stream-json", "--trust",
|
||||
"--force",
|
||||
"--resume", "chat-123",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ref ports.SessionRef
|
||||
}{
|
||||
{"empty session ref", ports.SessionRef{}},
|
||||
{"empty metadata", ports.SessionRef{Metadata: map[string]string{}}},
|
||||
{"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}},
|
||||
{"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
Session: tc.ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatalf("cmd = %#v, want nil", cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "chat-123",
|
||||
cursorTitleMetadataKey: "Fix login redirect",
|
||||
cursorSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("ok = false, want true")
|
||||
}
|
||||
if info.AgentSessionID != "chat-123" {
|
||||
t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q, want hook title", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q, want hook summary", info.Summary)
|
||||
}
|
||||
if info.Metadata != nil {
|
||||
t.Fatalf("Metadata = %#v, want nil for Cursor", info.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok = true, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero value", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellationPerMethod(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := plugin.GetConfigSpec(ctx); err == nil {
|
||||
t.Fatal("GetConfigSpec: want context error")
|
||||
}
|
||||
// GetLaunchCommand surfaces ctx cancellation only via binary resolution; with
|
||||
// a cached binary it short-circuits, so it is not asserted here (mirrors codex).
|
||||
if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil {
|
||||
t.Fatal("GetPromptDeliveryStrategy: want context error")
|
||||
}
|
||||
if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{
|
||||
Session: ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "chat-123"}},
|
||||
}); err == nil {
|
||||
t.Fatal("GetRestoreCommand: want context error")
|
||||
}
|
||||
if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil {
|
||||
t.Fatal("SessionInfo: want context error")
|
||||
}
|
||||
if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err == nil {
|
||||
t.Fatal("GetAgentHooks: want context error")
|
||||
}
|
||||
if err := plugin.UninstallHooks(ctx, t.TempDir()); err == nil {
|
||||
t.Fatal("UninstallHooks: want context error")
|
||||
}
|
||||
if _, err := plugin.AreHooksInstalled(ctx, t.TempDir()); err == nil {
|
||||
t.Fatal("AreHooksInstalled: want context error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksInstallsCursorHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
workspace := t.TempDir()
|
||||
hooksDir := filepath.Join(workspace, ".cursor")
|
||||
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hooksPath := filepath.Join(hooksDir, "hooks.json")
|
||||
// Pre-existing user hook on an event AO also manages, plus a non-AO field.
|
||||
existing := `{"version":1,"customField":"keep me","hooks":{"stop":[{"command":"custom stop hook"}]}}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second install must not duplicate AO hook commands.
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config cursorHookFile
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Hooks == nil {
|
||||
t.Fatalf("hooks config missing hooks object: %#v", config)
|
||||
}
|
||||
if config.Version != 1 {
|
||||
t.Fatalf("version = %d, want 1", config.Version)
|
||||
}
|
||||
for _, spec := range cursorManagedHooks {
|
||||
entries := config.Hooks[spec.Event]
|
||||
if count := countCursorHookCommand(entries, spec.Command); count != 1 {
|
||||
t.Fatalf("%s command %q count = %d, want 1 in %#v", spec.Event, spec.Command, count, entries)
|
||||
}
|
||||
}
|
||||
stopEntries := config.Hooks["stop"]
|
||||
if countCursorHookCommand(stopEntries, "custom stop hook") != 1 {
|
||||
t.Fatalf("existing stop hook was not preserved: %#v", stopEntries)
|
||||
}
|
||||
// Unmanaged top-level fields must be preserved.
|
||||
if !strings.Contains(string(data), "keep me") {
|
||||
t.Fatalf("unmanaged field 'customField' was dropped: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesOnlyAOHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := filepath.Join(workspace, ".cursor", "hooks.json")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace}
|
||||
|
||||
// Pre-seed a user's own stop hook; it must survive uninstall.
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{"version":1,"hooks":{"stop":[{"command":"custom stop hook"}]}}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := plugin.GetAgentHooks(ctx, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed {
|
||||
t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err)
|
||||
}
|
||||
|
||||
if err := plugin.UninstallHooks(ctx, workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed {
|
||||
t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hooksPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config cursorHookFile
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range cursorManagedHooks {
|
||||
if got := countCursorHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 {
|
||||
t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got)
|
||||
}
|
||||
}
|
||||
if countCursorHookCommand(config.Hooks["stop"], "custom stop hook") != 1 {
|
||||
t.Fatalf("user stop hook not preserved: %#v", config.Hooks["stop"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreHooksInstalledFalseWhenNoFile(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
workspace := t.TempDir()
|
||||
|
||||
installed, err := plugin.AreHooksInstalled(context.Background(), workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if installed {
|
||||
t.Fatal("installed = true, want false for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{}); err == nil {
|
||||
t.Fatal("want error for empty WorkspacePath")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSubsequence(values []string, needle []string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for start := range values {
|
||||
if start+len(needle) > len(values) {
|
||||
return false
|
||||
}
|
||||
ok := true
|
||||
for offset, want := range needle {
|
||||
if values[start+offset] != want {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func countCursorHookCommand(entries []cursorHookEntry, command string) int {
|
||||
count := 0
|
||||
for _, hook := range entries {
|
||||
if hook.Command == command {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
package cursor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
cursorHooksDirName = ".cursor"
|
||||
cursorHooksFileName = "hooks.json"
|
||||
|
||||
// cursorHooksSchemaVersion is the version Cursor's hooks.json declares. AO
|
||||
// only sets it when creating a fresh file; an existing version is preserved.
|
||||
cursorHooksSchemaVersion = 1
|
||||
|
||||
// cursorHookCommandPrefix identifies the hook commands AO owns, so
|
||||
// install skips duplicates and uninstall recognizes AO entries by
|
||||
// prefix without an embedded template to diff against.
|
||||
cursorHookCommandPrefix = "ao hooks cursor "
|
||||
)
|
||||
|
||||
// cursorHookFile is the on-disk shape of .cursor/hooks.json. It is used by tests
|
||||
// to decode the written file. Cursor keys hooks by camelCase native event name;
|
||||
// each value is an array of objects carrying a "command" string.
|
||||
type cursorHookFile struct {
|
||||
Version int `json:"version"`
|
||||
Hooks map[string][]cursorHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type cursorHookEntry struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// cursorHookSpec describes one hook AO installs, defined in code rather than
|
||||
// read from an embedded hooks file. Event is Cursor's native camelCase event
|
||||
// name; Command is the AO sub-command dispatched when the hook fires.
|
||||
type cursorHookSpec struct {
|
||||
Event string
|
||||
Command string
|
||||
}
|
||||
|
||||
// cursorManagedHooks is the source of truth for the hooks AO installs. The
|
||||
// native-event → AO-subcommand contract is FIXED: the orchestrator's CLI hook
|
||||
// dispatch and activity.go agree on the sub-command names.
|
||||
var cursorManagedHooks = []cursorHookSpec{
|
||||
{Event: "sessionStart", Command: cursorHookCommandPrefix + "session-start"},
|
||||
{Event: "beforeSubmitPrompt", Command: cursorHookCommandPrefix + "user-prompt-submit"},
|
||||
{Event: "stop", Command: cursorHookCommandPrefix + "stop"},
|
||||
{Event: "beforeShellExecution", Command: cursorHookCommandPrefix + "permission-request"},
|
||||
{Event: "beforeMCPExecution", Command: cursorHookCommandPrefix + "permission-request"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Cursor hooks into the worktree-local
|
||||
// .cursor/hooks.json file. Existing hook entries are preserved and duplicate
|
||||
// AO commands are not appended.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.WorkspacePath) == "" {
|
||||
return errors.New("cursor.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := cursorHooksPath(cfg.WorkspacePath)
|
||||
topLevel, rawHooks, err := readCursorHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cursor.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
for event, specs := range groupCursorHooksByEvent() {
|
||||
var existing []cursorHookEntry
|
||||
if err := parseCursorHookEvent(rawHooks, event, &existing); err != nil {
|
||||
return fmt.Errorf("cursor.GetAgentHooks: %w", err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !cursorHookCommandExists(existing, spec.Command) {
|
||||
existing = append(existing, cursorHookEntry{Command: spec.Command})
|
||||
}
|
||||
}
|
||||
if err := marshalCursorHookEvent(rawHooks, event, existing); err != nil {
|
||||
return fmt.Errorf("cursor.GetAgentHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeCursorHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("cursor.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), cursorHooksFileName); err != nil {
|
||||
return fmt.Errorf("cursor.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Cursor hooks from the workspace-local
|
||||
// .cursor/hooks.json file, leaving user-defined hooks untouched. A missing file
|
||||
// is a no-op.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return errors.New("cursor.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := cursorHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readCursorHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cursor.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range cursorManagedEvents() {
|
||||
var entries []cursorHookEntry
|
||||
if err := parseCursorHookEvent(rawHooks, event, &entries); err != nil {
|
||||
return fmt.Errorf("cursor.UninstallHooks: %w", err)
|
||||
}
|
||||
entries = removeCursorManagedHooks(entries)
|
||||
if err := marshalCursorHookEvent(rawHooks, event, entries); err != nil {
|
||||
return fmt.Errorf("cursor.UninstallHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeCursorHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("cursor.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Cursor hook is present in the
|
||||
// workspace-local hooks file. A missing file means none are installed.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, errors.New("cursor.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := cursorHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readCursorHooks(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cursor.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range cursorManagedEvents() {
|
||||
var entries []cursorHookEntry
|
||||
if err := parseCursorHookEvent(rawHooks, event, &entries); err != nil {
|
||||
return false, fmt.Errorf("cursor.AreHooksInstalled: %w", err)
|
||||
}
|
||||
for _, hook := range entries {
|
||||
if isCursorManagedHook(hook.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func cursorHooksPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, cursorHooksDirName, cursorHooksFileName)
|
||||
}
|
||||
|
||||
// readCursorHooks loads the hooks file into a top-level raw map plus the decoded
|
||||
// "hooks" sub-map, preserving keys AO doesn't manage (e.g. "version"). A missing
|
||||
// or empty file yields empty maps.
|
||||
func readCursorHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) {
|
||||
topLevel = map[string]json.RawMessage{}
|
||||
rawHooks = map[string]json.RawMessage{}
|
||||
|
||||
data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err)
|
||||
}
|
||||
if hooksRaw, ok := topLevel["hooks"]; ok {
|
||||
if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err)
|
||||
}
|
||||
}
|
||||
return topLevel, rawHooks, nil
|
||||
}
|
||||
|
||||
// writeCursorHooks folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely. A "version" key is ensured so
|
||||
// a freshly created file declares the schema version Cursor expects, while an
|
||||
// existing version (preserved in topLevel) is left untouched.
|
||||
func writeCursorHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
|
||||
if len(rawHooks) == 0 {
|
||||
delete(topLevel, "hooks")
|
||||
} else {
|
||||
hooksJSON, err := json.Marshal(rawHooks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode hooks: %w", err)
|
||||
}
|
||||
topLevel["hooks"] = hooksJSON
|
||||
if _, ok := topLevel["version"]; !ok {
|
||||
versionJSON, err := json.Marshal(cursorHooksSchemaVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode version: %w", err)
|
||||
}
|
||||
topLevel["version"] = versionJSON
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
|
||||
return fmt.Errorf("create hook dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(topLevel, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", hooksPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupCursorHooksByEvent groups the managed hook specs by their Cursor event so
|
||||
// each event's array is rewritten once.
|
||||
func groupCursorHooksByEvent() map[string][]cursorHookSpec {
|
||||
byEvent := map[string][]cursorHookSpec{}
|
||||
for _, spec := range cursorManagedHooks {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
// cursorManagedEvents returns the distinct Cursor events AO manages, in the
|
||||
// order they first appear in cursorManagedHooks.
|
||||
func cursorManagedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(cursorManagedHooks))
|
||||
for _, spec := range cursorManagedHooks {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func isCursorManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, cursorHookCommandPrefix)
|
||||
}
|
||||
|
||||
// removeCursorManagedHooks strips AO hook entries from an event's array,
|
||||
// preserving user-defined entries.
|
||||
func removeCursorManagedHooks(entries []cursorHookEntry) []cursorHookEntry {
|
||||
kept := make([]cursorHookEntry, 0, len(entries))
|
||||
for _, hook := range entries {
|
||||
if !isCursorManagedHook(hook.Command) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func parseCursorHookEvent(rawHooks map[string]json.RawMessage, event string, target *[]cursorHookEntry) error {
|
||||
data, ok := rawHooks[event]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return fmt.Errorf("parse %s hooks: %w", event, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalCursorHookEvent(rawHooks map[string]json.RawMessage, event string, entries []cursorHookEntry) error {
|
||||
if len(entries) == 0 {
|
||||
delete(rawHooks, event)
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s hooks: %w", event, err)
|
||||
}
|
||||
rawHooks[event] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func cursorHookCommandExists(entries []cursorHookEntry, command string) bool {
|
||||
for _, hook := range entries {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
// Package devin implements the Devin ("Devin for Terminal", Cognition) agent
|
||||
// adapter.
|
||||
//
|
||||
// Devin for Terminal (binary "devin") is Cognition's terminal coding agent. It
|
||||
// has a documented Claude Code compatibility layer: it imports `.claude/`
|
||||
// configuration (commands, subagents, and Claude Code lifecycle hooks), storing
|
||||
// the converted hooks in `.devin/hooks.v1.json`. Because of this, AO reuses the
|
||||
// Claude Code hook installer (which writes .claude/settings.local.json with AO
|
||||
// hook commands) and Devin picks them up via its compat layer. This makes Devin
|
||||
// a Tier B (Claude-compat) adapter, mirroring the grok adapter.
|
||||
//
|
||||
// Launch uses `-p <prompt>` for the initial task in non-interactive/print mode
|
||||
// (in-command delivery). Permission handling uses `--permission-mode`, whose
|
||||
// valid values are `normal` (aliases: auto) and `dangerous` (aliases: yolo,
|
||||
// bypass). AO's four permission modes are mapped onto these two: Default emits
|
||||
// no flag (defer to the user's ~/.config/devin/config.json), AcceptEdits/Auto
|
||||
// map to `auto`, and BypassPermissions maps to `dangerous`.
|
||||
//
|
||||
// Restore prefers the hook-captured native session id via `-r <id>`. Devin
|
||||
// session ids are listed by `devin list --format json`; AO captures the native
|
||||
// id through the Claude-compat hook payloads (SessionStart) into session
|
||||
// metadata, the same path grok uses.
|
||||
package devin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
devinTitleMetadataKey = "title"
|
||||
devinSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Devin for Terminal agent adapter.
|
||||
type Plugin struct {
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
||||
// New returns a ready-to-register Devin adapter.
|
||||
func New() *Plugin {
|
||||
return &Plugin{}
|
||||
}
|
||||
|
||||
var _ adapters.Adapter = (*Plugin)(nil)
|
||||
var _ ports.Agent = (*Plugin)(nil)
|
||||
|
||||
// Manifest returns the adapter's static self-description.
|
||||
func (p *Plugin) Manifest() adapters.Manifest {
|
||||
return adapters.Manifest{
|
||||
ID: "devin",
|
||||
Name: "Devin",
|
||||
Description: "Run Cognition Devin for Terminal worker sessions.",
|
||||
Version: "0.0.1",
|
||||
Capabilities: []adapters.Capability{
|
||||
adapters.CapabilityAgent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys yet.
|
||||
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.ConfigSpec{}, err
|
||||
}
|
||||
return ports.ConfigSpec{}, nil
|
||||
}
|
||||
|
||||
// GetLaunchCommand builds `devin [--permission-mode <mode>] -p <prompt>`.
|
||||
// Prompt is delivered via -p (in command, non-interactive print mode).
|
||||
//
|
||||
// Permission values come from `devin --permission-mode -h`:
|
||||
// `normal` (alias auto) and `dangerous` (aliases yolo, bypass). Default omits
|
||||
// the flag so Devin uses its config (default mode is auto/normal).
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.devinBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "-p", cfg.Prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks reuses the Claude Code hook installer because Devin for Terminal
|
||||
// has a documented Claude Code compatibility layer.
|
||||
//
|
||||
// Official docs (https://docs.devin.ai/cli, Configuration Import / Extensibility):
|
||||
// Devin reads configuration from `.claude/` including "Commands, custom
|
||||
// subagents, hooks"; its "Lifecycle hooks (Claude Code compatible)" are stored
|
||||
// in `.devin/hooks.v1.json`. The binary itself ships a
|
||||
// `config-importers/.../claude` + `agent-ext/hooks/importers/claude` layer that
|
||||
// converts Claude hooks (SessionStart, UserPromptSubmit, Stop, PermissionRequest,
|
||||
// SessionEnd, ...) on load.
|
||||
//
|
||||
// This means Devin picks up the .claude/settings.local.json (and the AO hook
|
||||
// commands we install there) in the worktree. The installed commands are
|
||||
// "ao hooks claude-code <evt>", so the existing CLI hook dispatcher routes them
|
||||
// to claude derive logic (Devin is grouped with claude-code in cli/hooks.go).
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return (&claudecode.Plugin{}).GetAgentHooks(ctx, cfg)
|
||||
}
|
||||
|
||||
// GetRestoreCommand builds `devin [--permission-mode <mode>] -r <agentSessionId>`
|
||||
// when we have a hook-captured native id. ok=false otherwise (fall back to fresh
|
||||
// launch in the manager).
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID])
|
||||
if agentSessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
binary, err := p.devinBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 5)
|
||||
cmd = append(cmd, binary)
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
cmd = append(cmd, "-r", agentSessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo reads hook-derived metadata. Since we delegate hook install to
|
||||
// claude hooks (via compat), the keys in the metadata map are the claude ones
|
||||
// ("title", "summary", "agentSessionId"). We surface them under the normalized
|
||||
// SessionInfo.
|
||||
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[devinTitleMetadataKey],
|
||||
Summary: session.Metadata[devinSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// ResolveDevinBinary finds the `devin` binary (Cognition Devin for Terminal CLI).
|
||||
func ResolveDevinBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"devin.cmd", "devin.exe", "devin"} {
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
candidates := []string{}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".devin", "bin", "devin.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("devin"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/devin",
|
||||
"/opt/homebrew/bin/devin",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".devin", "bin", "devin"),
|
||||
filepath.Join(home, ".local", "bin", "devin"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
func (p *Plugin) devinBinary(ctx context.Context) (string, error) {
|
||||
p.binaryMu.Lock()
|
||||
defer p.binaryMu.Unlock()
|
||||
|
||||
if p.resolvedBinary != "" {
|
||||
return p.resolvedBinary, nil
|
||||
}
|
||||
|
||||
binary, err := ResolveDevinBinary(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's four permission modes onto Devin's two native
|
||||
// permission values (`auto`/normal and `dangerous`/bypass), per
|
||||
// `devin --permission-mode -h`.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to ~/.config/devin/config.json (default mode is auto).
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
// Devin has no dedicated accept-edits flag; auto prompts for writes,
|
||||
// which is the safest non-default mapping.
|
||||
*cmd = append(*cmd, "--permission-mode", "auto")
|
||||
case ports.PermissionModeAuto:
|
||||
*cmd = append(*cmd, "--permission-mode", "auto")
|
||||
case ports.PermissionModeBypassPermissions:
|
||||
*cmd = append(*cmd, "--permission-mode", "dangerous")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
package devin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestManifest(t *testing.T) {
|
||||
m := (&Plugin{}).Manifest()
|
||||
if m.ID != "devin" {
|
||||
t.Fatalf("ID = %q, want devin", m.ID)
|
||||
}
|
||||
if m.Name != "Devin" {
|
||||
t.Fatalf("Name = %q", m.Name)
|
||||
}
|
||||
hasAgent := false
|
||||
for _, c := range m.Capabilities {
|
||||
if c == adapters.CapabilityAgent {
|
||||
hasAgent = true
|
||||
}
|
||||
}
|
||||
if !hasAgent {
|
||||
t.Fatal("missing CapabilityAgent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecEmpty(t *testing.T) {
|
||||
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(spec.Fields) != 0 {
|
||||
t.Fatalf("expected no fields, got %d", len(spec.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecCtxCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := (&Plugin{}).GetConfigSpec(ctx); err == nil {
|
||||
t.Fatal("expected ctx error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||
s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if s != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("strategy = %q, want in_command", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBypass(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "do the thing",
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"devin", "--permission-mode", "dangerous", "-p", "do the thing"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandDefaultPerms(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "fix it",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"devin", "-p", "fix it"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
if strings.Contains(strings.Join(cmd, " "), "permission-mode") {
|
||||
t.Fatal("should not have --permission-mode for default perms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAcceptEdits(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "refactor auth",
|
||||
Permissions: ports.PermissionModeAcceptEdits,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"devin", "--permission-mode", "auto", "-p", "refactor auth"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandAuto(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "ship it",
|
||||
Permissions: ports.PermissionModeAuto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"devin", "--permission-mode", "auto", "-p", "ship it"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandNoPrompt(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := []string{"devin"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandCtxCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "x"}); err == nil {
|
||||
t.Fatal("expected ctx error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "sess-abc123",
|
||||
},
|
||||
},
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok=false, want true")
|
||||
}
|
||||
want := []string{"devin", "--permission-mode", "dangerous", "-r", "sess-abc123"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("cmd = %#v, want %#v", cmd, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandNoID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
_, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{Metadata: map[string]string{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ok=true with no agentSessionId, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandWhitespaceID(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
_, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: " ",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ok=true with whitespace agentSessionId, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "devin-ses-1",
|
||||
devinTitleMetadataKey: "Fix login redirect",
|
||||
devinSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ok=false, want true")
|
||||
}
|
||||
if info.AgentSessionID != "devin-ses-1" {
|
||||
t.Fatalf("AgentSessionID = %q, want devin-ses-1", info.AgentSessionID)
|
||||
}
|
||||
if info.Title != "Fix login redirect" {
|
||||
t.Fatalf("Title = %q", info.Title)
|
||||
}
|
||||
if info.Summary != "Updated the auth callback and tests." {
|
||||
t.Fatalf("Summary = %q", info.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("ok=true with empty metadata, want false")
|
||||
}
|
||||
if !reflect.DeepEqual(info, ports.SessionInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksDelegates(t *testing.T) {
|
||||
// We don't exercise the full hook merge here (claude tests cover it);
|
||||
// just ensure it doesn't blow up on a temp workspace and that the
|
||||
// method is wired (real hook install is exercised via claude delegation).
|
||||
plugin := &Plugin{resolvedBinary: "devin"}
|
||||
ws := t.TempDir()
|
||||
if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{
|
||||
WorkspacePath: ws,
|
||||
SessionID: "devin-test-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("GetAgentHooks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksCtxCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil {
|
||||
t.Fatal("expected ctx error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevinBinaryFallback(t *testing.T) {
|
||||
// When the binary is not on PATH or any well-known location, the resolver
|
||||
// MUST surface ports.ErrAgentBinaryNotFound rather than a silent string
|
||||
// fallback that lets a missing CLI launch into an empty zellij pane.
|
||||
bin, err := ResolveDevinBinary(context.Background())
|
||||
if err != nil {
|
||||
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
|
||||
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if bin == "" {
|
||||
t.Fatal("ResolveDevinBinary returned empty path with no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDevinBinaryCtxCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := ResolveDevinBinary(ctx); err == nil {
|
||||
t.Fatal("expected ctx error, got nil")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue