Compare commits
11 Commits
v0.10.3-ni
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
aae38fd5cd | |
|
|
d51ddd6130 | |
|
|
71fc914afa | |
|
|
0f2295c0c0 | |
|
|
860fa5a242 | |
|
|
21294e6481 | |
|
|
608333bd54 | |
|
|
cbaffd2cb4 | |
|
|
6cc7cc3e4e | |
|
|
821cda64a8 | |
|
|
2c08597ee6 |
|
|
@ -11,7 +11,7 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
important:
|
||||
description: 'Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set.'
|
||||
description: "Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set."
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Generated — never hand-edit; regenerated by `npm run api` / sqlc / openapi-typescript
|
||||
frontend/src/api/schema.ts
|
||||
frontend/src/renderer/routeTree.gen.ts
|
||||
backend/internal/httpd/apispec/openapi.yaml
|
||||
|
||||
# Build outputs
|
||||
|
|
|
|||
285
README.md
|
|
@ -1,6 +1,5 @@
|
|||
<div align="center">
|
||||
|
||||
<p style="text-align: center;"><img src="ao-logo.svg" alt="Agent Orchestrator" width="200" height="200" style="max-width: 100%; height: auto; margin-left: 50px;" /></p>
|
||||
<img src="ao-logo.svg" alt="Agent Orchestrator" width="160" height="160" />
|
||||
|
||||
# Agent Orchestrator
|
||||
|
||||
|
|
@ -10,12 +9,11 @@
|
|||
[](https://github.com/AgentWrapper/agent-orchestrator/graphs/contributors)
|
||||
[](https://x.com/aoagents)
|
||||
[](https://discord.com/invite/UZv7JjxbwG)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
[](LICENSE)
|
||||
|
||||
An Agentic IDE that supervises parallel AI coding agents in isolated workspaces, with complete control and automatic feedback loops from CI failures, review comments, and merge conflicts.
|
||||
|
||||

|
||||
|
||||
<img src="docs/assets/readme/dashboard.png" alt="Agent Orchestrator dashboard showing parallel coding agent sessions" width="100%" />
|
||||
</div>
|
||||
|
||||
---
|
||||
|
|
@ -26,8 +24,6 @@ Agent Orchestrator is a meta-harness agent IDE for running AI coding agents in p
|
|||
|
||||
The agents still do the coding. AO provides the harness around them: isolated workspaces, live terminal access, session state, PR awareness, and automatic loops that send CI failures, review comments, and merge conflicts back to the right agent. Instead of manually coordinating a pile of agent terminals, AO turns parallel agent work into a managed workflow.
|
||||
|
||||
---
|
||||
|
||||
## Why Agent Orchestrator?
|
||||
|
||||
AI coding agents become much more useful when they can work in parallel, but parallel work gets messy quickly. Branches overlap, terminals get lost, CI failures need follow-up, review comments need replies, and merge conflicts have to reach the right worker.
|
||||
|
|
@ -40,8 +36,6 @@ Agent Orchestrator is built to keep that loop visible and manageable. It helps y
|
|||
- Route CI failures, review comments, and merge conflicts back to the right session
|
||||
- Use different agent CLIs through one common supervisor
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
At a high level, Agent Orchestrator follows a simple loop:
|
||||
|
|
@ -55,207 +49,116 @@ At a high level, Agent Orchestrator follows a simple loop:
|
|||
|
||||
The result is a local control layer for agentic coding: agents still do the coding, while Agent Orchestrator keeps their workspaces, status, terminals, and feedback loops organized.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### Witness AO's Journey on X
|
||||
|
||||
<table border="1" style="border-collapse: collapse; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
|
||||
<a href="https://x.com/agent_wrapper/status/2026329204405723180"><img src="screenshots/first.png" alt="First" width="400"></a><br><br>
|
||||
<a href="https://x.com/agent_wrapper/status/2026329204405723180">Visit</a>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
|
||||
<a href="https://x.com/agent_wrapper/status/2025986105485733945"><img src="screenshots/second.png" alt="Second" width="400"></a><br><br>
|
||||
<a href="https://x.com/agent_wrapper/status/2025986105485733945">Visit</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
|
||||
<a href="https://x.com/agent_wrapper/status/2064157228400341312"><img src="screenshots/third.png" alt="Third" width="400"></a><br><br>
|
||||
<a href="https://x.com/agent_wrapper/status/2064157228400341312">Visit</a>
|
||||
</td>
|
||||
<td style="padding: 10px; text-align: center; border: 1px solid #ddd;">
|
||||
<a href="https://x.com/agent_wrapper/status/2024885035774738700?s=20"><img src="screenshots/image.png" alt="Fourth" width="400"></a><br><br>
|
||||
<a href="https://x.com/agent_wrapper/status/2024885035774738700?s=20">Visit</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
[What is Agent Orchestrator?](#what-is-agent-orchestrator) • [Why Agent Orchestrator?](#why-agent-orchestrator) • [How it works](#how-it-works) • [Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Agent-Agnostic Platform** | 23+ agent adapters including [Claude Code](https://code.claude.com/docs/en/overview), [OpenAI Codex](https://openai.com/), [Cursor](https://cursor.com/), [OpenCode](https://opencode.ai/), [Aider](https://aider.chat/), [Amp](https://ampcode.com/manual), [Goose](https://goose-docs.ai/), [GitHub Copilot](https://github.com/features/copilot), [Grok](https://x.ai/grok), [Qwen Code](https://github.com/QwenLM/qwen-code), [Kimi Code](https://www.kimi.com/code), [Cline](https://cline.bot/), [Continue](https://www.continue.dev/), [Kiro](https://kiro.dev/), and more |
|
||||
| **Isolated Workspaces** | Each session spawns into its own git worktree with dedicated runtime |
|
||||
| **Platform-Native Runtimes** | tmux on Darwin/Linux, conpty on Windows for optimal performance |
|
||||
| **Live PR Observation** | Provider-neutral SCM observer with automatic feedback routing |
|
||||
| **Automatic Feedback Routing** | CI failures, review comments, and merge conflicts routed to the owning agent |
|
||||
| **Durable Facts Storage** | SQLite persists immutable facts with display status derived at read time |
|
||||
| **CDC Broadcasting** | DB triggers append changes to change_log, broadcasted via SSE |
|
||||
| **Desktop Experience** | Native Electron app with React UI and live terminal streaming |
|
||||
| **Loopback-Only Daemon** | HTTP control over 127.0.0.1 with no auth, CORS, or TLS by design |
|
||||
The desktop app is the main control surface: projects on the left, active sessions in the center, and the selected session's terminal, pull request state, review runs, and browser preview in the inspector.
|
||||
|
||||
### Supported Agents
|
||||
<table>
|
||||
<tr>
|
||||
<td width="36%">
|
||||
<h3>Parallel agent sessions</h3>
|
||||
<p>Start multiple coding agents from the same project without mixing files, branches, terminals, or pull request state.</p>
|
||||
</td>
|
||||
<td width="64%">
|
||||
<img src="docs/assets/readme/dashboard.png" alt="Agent Orchestrator board with multiple parallel sessions" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="36%">
|
||||
<h3>Live terminal control</h3>
|
||||
<p>Open any session and attach to the worker terminal while keeping session summary, PR state, and follow-up actions in view.</p>
|
||||
</td>
|
||||
<td width="64%">
|
||||
<img src="docs/assets/readme/session-terminal.png" alt="Session terminal inside Agent Orchestrator" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="36%">
|
||||
<h3>Review feedback loop</h3>
|
||||
<p>Run reviewer agents, inspect review status, and route requested changes back to the right worker session.</p>
|
||||
</td>
|
||||
<td width="64%">
|
||||
<img src="docs/assets/readme/reviews-tab.png" alt="Reviews tab showing reviewer runs and actions" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="36%">
|
||||
<h3>In-app browser preview</h3>
|
||||
<p>Preview a session's local app beside the terminal so UI work, browser state, and agent output stay together.</p>
|
||||
</td>
|
||||
<td width="64%">
|
||||
<img src="docs/assets/readme/browser-preview.png" alt="Browser preview tab showing a local app preview" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code.
|
||||
## Supported Agents
|
||||
|
||||
AO ships adapters for 23 worker agent harnesses:
|
||||
|
||||
<img src="frontend/src/landing/public/docs/logos/claude-code.svg" width="16" valign="middle" /> `claude-code` · <img src="frontend/src/landing/public/docs/logos/codex.svg" width="16" valign="middle" /> `codex` · <img src="frontend/src/landing/public/docs/logos/aider.png" width="16" valign="middle" /> `aider` · <img src="frontend/src/landing/public/docs/logos/opencode.svg" width="16" valign="middle" /> `opencode` · <img src="frontend/src/landing/public/docs/logos/grok.png" width="16" valign="middle" /> `grok` · <img src="frontend/src/landing/public/docs/logos/droid.png" width="16" valign="middle" /> `droid` · `amp` · `agy` · <img src="frontend/src/landing/public/docs/logos/crush.png" width="16" valign="middle" /> `crush` · <img src="frontend/src/landing/public/docs/logos/cursor.svg" width="16" valign="middle" /> `cursor` · <img src="frontend/src/landing/public/docs/logos/qwen.png" width="16" valign="middle" /> `qwen` · <img src="frontend/src/landing/public/docs/logos/copilot.png" width="16" valign="middle" /> `copilot` · <img src="frontend/src/landing/public/docs/logos/goose.png" width="16" valign="middle" /> `goose` · `auggie` · <img src="frontend/src/landing/public/docs/logos/continue.png" width="16" valign="middle" /> `continue` · <img src="frontend/src/landing/public/docs/logos/devin.png" width="16" valign="middle" /> `devin` · `cline` · <img src="frontend/src/landing/public/docs/logos/kimi.png" width="16" valign="middle" /> `kimi` · <img src="frontend/src/landing/public/docs/logos/kiro.png" width="16" valign="middle" /> `kiro` · <img src="frontend/src/landing/public/docs/logos/kilocode.png" width="16" valign="middle" /> `kilocode` · <img src="frontend/src/landing/public/docs/logos/vibe.png" width="16" valign="middle" /> `vibe` · <img src="frontend/src/landing/public/docs/logos/pi.png" width="16" valign="middle" /> `pi` · `autohand`
|
||||
|
||||
Reviewer agents are configured separately. The current reviewer harnesses are:
|
||||
|
||||
<img src="frontend/src/landing/public/docs/logos/claude-code.svg" width="16" valign="middle" /> `claude-code` · <img src="frontend/src/landing/public/docs/logos/codex.svg" width="16" valign="middle" /> `codex` · <img src="frontend/src/landing/public/docs/logos/opencode.svg" width="16" valign="middle" /> `opencode`
|
||||
|
||||
**If it runs in a terminal, it runs on Agent Orchestrator.**
|
||||
|
||||
---
|
||||
## Install
|
||||
|
||||
## Quick Start
|
||||
The fastest path is the same flow used by the installation docs:
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
npm install -g @aoagents/ao
|
||||
ao start
|
||||
```
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
| ----------- | ------- | ----------- |
|
||||
| Go | 1.25+ | Latest |
|
||||
| Node.js | 20+ | Latest LTS |
|
||||
| Git | Any | Latest |
|
||||
| pnpm | Any | Latest |
|
||||
Run `ao start` from the repository you want AO to manage. See the [installation guide](https://aoagents.dev/docs/installation) for pnpm, yarn, source installs, agent CLI setup, and troubleshooting.
|
||||
|
||||
**Optional:**
|
||||
You can also download the latest desktop build for your platform:
|
||||
|
||||
- `tmux` (Darwin/Linux) - For Unix runtime
|
||||
- `gh` (GitHub CLI) - For authenticated GitHub API calls
|
||||
| Platform | Download |
|
||||
| -------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Windows | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||
| macOS | [Agent Orchestrator.dmg](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||
| Linux | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||
|
||||
### Installation
|
||||
## Witness AO's Journey on X
|
||||
|
||||
Download the latest release for your platform:
|
||||
|
||||
| Platform | Download |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Windows** | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||
| **macOS** | [Agent Orchestrator.dmg](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||
| **Linux** | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||
|
||||
**Direct Download:** [Latest Release](https://github.com/AgentWrapper/agent-orchestrator/releases/latest)
|
||||
|
||||
---
|
||||
|
||||
## Telemetry
|
||||
|
||||
Agent Orchestrator collects minimal telemetry for reliability and product understanding. Data is stored locally by default; remote transmission is opt-in via environment variables. [Read the full telemetry policy](docs/telemetry.md).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Agent Orchestrator is a long-running Go daemon built around **inbound/outbound port contracts** with swappable adapters.
|
||||
|
||||
**Core mental model:** OBSERVE external facts → UPDATE durable facts → DERIVE display status / ACT
|
||||
|
||||
**Key components:**
|
||||
|
||||
- **Frontend** - Electron + React UI with TanStack Router/Query and shadcn/ui
|
||||
- **Backend Daemon** - Go-based HTTP server with controllers, services, and adapters
|
||||
- **Runtime** - Platform-specific: `tmux` on Darwin/Linux, `conpty` on Windows
|
||||
- **Storage** - SQLite with change-data-capture (CDC) for real-time updates
|
||||
- **Adapters** - 23+ agent adapters, git worktree workspace, GitHub SCM integration
|
||||
|
||||
For detailed architecture diagrams, data flows, and load-bearing rules, see [architecture.md](docs/architecture.md).
|
||||
|
||||
---
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" align="center">
|
||||
<a href="https://x.com/agent_wrapper/status/2026329204405723180">
|
||||
<img src="screenshots/tweet2.png" height="330" alt="Agent Orchestrator journey screenshot one" />
|
||||
</a>
|
||||
</td>
|
||||
<td width="37.5%" align="center">
|
||||
<a href="https://x.com/agent_wrapper/status/2025986105485733945">
|
||||
<img src="screenshots/tweet1.png" height="330" alt="Agent Orchestrator journey screenshot two" />
|
||||
</a>
|
||||
</td>
|
||||
<td width="29.5%" align="center">
|
||||
<a href="https://x.com/agent_wrapper/status/2024885035774738700">
|
||||
<img src="screenshots/tweet3.png" height="330" alt="Agent Orchestrator journey screenshot three" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules |
|
||||
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
|
||||
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
||||
| Document | Start here when you need |
|
||||
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| [docs/architecture.md](docs/architecture.md) | Backend mental model, lifecycle, persistence, CDC, status derivation, and daemon boundaries. |
|
||||
| [docs/backend-code-structure.md](docs/backend-code-structure.md) | Package ownership and where each backend concern belongs. |
|
||||
| [docs/cli/README.md](docs/cli/README.md) | CLI behavior and daemon route mapping. |
|
||||
| [docs/STATUS.md](docs/STATUS.md) | What currently ships on `main` and what remains in flight. |
|
||||
| [docs/stack.md](docs/stack.md) | Library, runtime, and dependency decisions. |
|
||||
|
||||
---
|
||||
## Telemetry
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
go test -race ./...
|
||||
|
||||
# Frontend tests
|
||||
cd frontend
|
||||
pnpm test
|
||||
|
||||
# Full CI validation locally
|
||||
npx @redwoodjs/agent-ci run --all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is environment-driven. The daemon takes no config file.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --------------------- | -------------------- | --------------------------- |
|
||||
| `AO_PORT` | `3001` | HTTP bind port |
|
||||
| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout |
|
||||
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap |
|
||||
| `AO_RUN_FILE` | `~/.ao/running.json` | PID/port handshake |
|
||||
| `AO_DATA_DIR` | `~/.ao/data` | SQLite data directory |
|
||||
| `AO_AGENT` | `claude-code` | Compatibility agent adapter |
|
||||
| `GITHUB_TOKEN` | - | GitHub auth token |
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
curl localhost:3001/healthz # Liveness probe
|
||||
curl localhost:3001/readyz # Readiness probe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We love contributions! Join our community on Discord to get started.
|
||||
|
||||
### Join us on Discord
|
||||
|
||||
[](https://discord.com/invite/UZv7JjxbwG)
|
||||
|
||||
**Daily contributor sync:** Every day at **10:00 PM IST**
|
||||
|
||||
Get your issues verified by core contributors, ask questions, share progress, and learn from the community. New contributors are always welcome!
|
||||
|
||||
**Why join Discord?**
|
||||
|
||||
- Get your issues and PRs verified by core contributors before investing time
|
||||
- Learn from experienced contributors in daily sync calls
|
||||
- Share your progress and get feedback
|
||||
- Get help troubleshooting in real-time
|
||||
- Stay updated on the latest developments and roadmap
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Join the Discord** - Connect with the community and get guidance
|
||||
2. **Read the contributor contract** - See [AGENTS.md](AGENTS.md) for repo layout, daemon/API boundaries, and coding conventions
|
||||
3. **Pick a focused problem** - Browse [open issues](https://github.com/AgentWrapper/agent-orchestrator/issues) and choose one small enough for a focused PR
|
||||
4. **Open a clear PR** - Keep changes narrow, explain user-visible impact, link issues, include tests
|
||||
5. **Iterate with contributors** - Use review feedback to tighten the PR until verified
|
||||
|
||||
---
|
||||
Agent Orchestrator's Electron renderer sends anonymous usage events to PostHog for reliability and product understanding, and PostHog session recording is enabled with local paths and local URLs redacted before transmission. Set `VITE_AO_POSTHOG_KEY` to an empty string before building to disable transmission. See [docs/telemetry.md](docs/telemetry.md).
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 - see [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[Star us on GitHub](https://github.com/AgentWrapper/agent-orchestrator)** • **[Report Issues](https://github.com/AgentWrapper/agent-orchestrator/issues)** • **[Discussions](https://github.com/AgentWrapper/agent-orchestrator/discussions)**
|
||||
|
||||
Made with love by the Agent Orchestrator community
|
||||
|
||||
</div>
|
||||
Apache License 2.0. See [LICENSE](LICENSE).
|
||||
|
|
|
|||
|
|
@ -9,19 +9,12 @@
|
|||
package activitydispatch
|
||||
|
||||
import (
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate"
|
||||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -32,19 +25,21 @@ 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{
|
||||
// Adapters that parse hook payloads for finer-grained state keep their own
|
||||
// deriver; the rest share the name-only StandardDeriveActivityState.
|
||||
"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,
|
||||
"opencode": opencode.DeriveActivityState,
|
||||
"goose": activitystate.StandardDeriveActivityState,
|
||||
"cursor": activitystate.StandardDeriveActivityState,
|
||||
"qwen": activitystate.StandardDeriveActivityState,
|
||||
"copilot": activitystate.StandardDeriveActivityState,
|
||||
"cline": activitystate.StandardDeriveActivityState,
|
||||
"kiro": activitystate.StandardDeriveActivityState,
|
||||
"kilocode": activitystate.StandardDeriveActivityState,
|
||||
"autohand": activitystate.StandardDeriveActivityState,
|
||||
}
|
||||
|
||||
// Derive looks up the deriver for an agent token and applies it. ok=false when
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
// Package activitystate holds the standard mapping from an AO hook sub-command
|
||||
// name onto an activity state. Most adapters install the same
|
||||
// session-start/user-prompt-submit/stop/permission-request callbacks and derive
|
||||
// activity identically from the event name alone; they share this deriver rather
|
||||
// than each carrying a copy. Adapters that inspect the hook payload for finer
|
||||
// grained state (claude-code, codex, droid) keep their own deriver.
|
||||
package activitystate
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// StandardDeriveActivityState maps a hook sub-command name onto an AO activity
|
||||
// state. The bool is false when the event carries no activity signal. The
|
||||
// payload is ignored: this is the name-only mapping shared by adapters whose
|
||||
// hooks report activity purely through which callback fired.
|
||||
//
|
||||
// - session-start / user-prompt-submit → active
|
||||
// - stop → idle
|
||||
// - permission-request → waiting_input
|
||||
func StandardDeriveActivityState(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,28 @@
|
|||
package activitystate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestStandardDeriveActivityState(t *testing.T) {
|
||||
cases := []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 _, tc := range cases {
|
||||
got, ok := StandardDeriveActivityState(tc.event, []byte("ignored"))
|
||||
if got != tc.want || ok != tc.wantOK {
|
||||
t.Errorf("event %q: got (%q, %v), want (%q, %v)", tc.event, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// Package agentbase supplies the defaults an agent adapter would otherwise
|
||||
// hand-copy. Most adapters implement several ports.Agent methods identically:
|
||||
// no config keys, prompt delivered in the launch command, and (for the simpler
|
||||
// harnesses) no hooks, no resume, no session metadata. Embedding Base gives an
|
||||
// adapter those defaults so it only writes the methods it actually customizes.
|
||||
package agentbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// Base provides no-op defaults for the optional ports.Agent methods. Embed it in
|
||||
// a Plugin struct (`agentbase.Base`) and override only what the harness needs.
|
||||
// Every method honors ctx cancellation and otherwise does nothing, matching what
|
||||
// the adapters previously wrote by hand.
|
||||
type Base struct{}
|
||||
|
||||
// GetConfigSpec reports no agent-specific config keys.
|
||||
func (Base) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
||||
return ports.ConfigSpec{}, ctx.Err()
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that the agent receives its prompt in the
|
||||
// launch command itself, which is true for every shipped adapter.
|
||||
func (Base) GetPromptDeliveryStrategy(ctx context.Context, _ ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetAgentHooks is a no-op for harnesses without a native hook surface.
|
||||
func (Base) GetAgentHooks(ctx context.Context, _ ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand reports that no existing native session can be continued.
|
||||
func (Base) GetRestoreCommand(ctx context.Context, _ ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// SessionInfo reports no agent-owned session metadata.
|
||||
func (Base) SessionInfo(ctx context.Context, _ ports.SessionRef) (ports.SessionInfo, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
|
||||
// StandardSessionInfo returns the normalized session metadata (native session
|
||||
// id, title, summary) an adapter's hooks persisted under the shared
|
||||
// ports.MetadataKey* keys. ok is false when none of the three is present. An
|
||||
// adapter whose SessionInfo just reads those keys delegates here.
|
||||
func StandardSessionInfo(session ports.SessionRef) (ports.SessionInfo, bool) {
|
||||
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
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
|
@ -5,30 +5,34 @@ 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/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "agy"
|
||||
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"
|
||||
)
|
||||
var agyBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "agy",
|
||||
Names: []string{"agy"},
|
||||
WinNames: []string{"agy.cmd", "agy.exe", "agy"},
|
||||
UnixPaths: []string{"/usr/local/bin/agy", "/opt/homebrew/bin/agy"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "agy"}, {".cargo", "bin", "agy"}, {".npm", "bin", "agy"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "agy.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.RWMutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -54,14 +58,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
//
|
||||
|
|
@ -89,15 +85,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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) {
|
||||
|
|
@ -134,84 +121,15 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, 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.
|
||||
// searching PATH then a handful of well-known install locations. It returns a
|
||||
// wrapped ports.ErrAgentBinaryNotFound when agy is absent.
|
||||
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)
|
||||
return binaryutil.ResolveBinary(ctx, agyBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) agyBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -238,8 +156,3 @@ func (p *Plugin) agyBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func TestGetLaunchCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategy(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "agy"}
|
||||
plugin := &Plugin{}
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -26,6 +28,7 @@ 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -51,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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>]
|
||||
|
|
@ -91,40 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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 {
|
||||
|
|
@ -190,7 +151,7 @@ func ResolveAiderBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
|
@ -216,8 +177,3 @@ func (p *Plugin) aiderBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ func TestGetLaunchCommandInlineSystemPromptIsDropped(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetRestoreCommandAlwaysFalse(t *testing.T) {
|
||||
p := &Plugin{resolvedBinary: "aider"}
|
||||
p := &Plugin{}
|
||||
cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Session: ports.SessionRef{
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"},
|
||||
|
|
|
|||
|
|
@ -8,15 +8,12 @@ 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/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -25,6 +22,7 @@ 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -50,14 +48,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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>]
|
||||
|
|
@ -87,21 +77,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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.
|
||||
|
|
@ -126,14 +101,6 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
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:
|
||||
|
|
@ -145,66 +112,22 @@ func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) {
|
|||
}
|
||||
}
|
||||
|
||||
var ampBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "amp",
|
||||
Names: []string{"amp"},
|
||||
WinNames: []string{"amp.cmd", "amp.exe", "amp"},
|
||||
UnixPaths: []string{"/usr/local/bin/amp", "/opt/homebrew/bin/amp"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "amp"}, {".npm", "bin", "amp"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when 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)
|
||||
return binaryutil.ResolveBinary(ctx, ampBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) ampBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -222,8 +145,3 @@ func (p *Plugin) ampBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//
|
||||
// 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
|
||||
// `--print` (alias `-p`) which runs a single instruction and exits -- the mode AO
|
||||
// uses to drive it unattended.
|
||||
//
|
||||
// Launch shape:
|
||||
|
|
@ -38,15 +38,12 @@ 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/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -55,6 +52,7 @@ 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -80,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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>]
|
||||
|
|
@ -116,22 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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:
|
||||
//
|
||||
|
|
@ -156,81 +130,28 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
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.
|
||||
|
||||
var auggieBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "auggie",
|
||||
Names: []string{"auggie"},
|
||||
WinNames: []string{"auggie.cmd", "auggie.exe", "auggie"},
|
||||
UnixPaths: []string{"/usr/local/bin/auggie", "/opt/homebrew/bin/auggie"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "auggie"}, {".npm", "bin", "auggie"}, {".npm-global", "bin", "auggie"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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)
|
||||
return binaryutil.ResolveBinary(ctx, auggieBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) auggieBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -248,8 +169,3 @@ func (p *Plugin) auggieBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -6,34 +6,27 @@
|
|||
// 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.
|
||||
// activity states. See hooks.go for hook installation.
|
||||
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/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "autohand"
|
||||
|
||||
autohandTitleMetadataKey = "title"
|
||||
autohandSummaryMetadataKey = "summary"
|
||||
)
|
||||
const adapterID = "autohand"
|
||||
|
||||
// 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 {
|
||||
agentbase.Base
|
||||
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -59,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -98,15 +83,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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
|
||||
|
|
@ -139,15 +115,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// appendWorkspaceFlag scopes the run to the given workspace path via --path.
|
||||
|
|
@ -160,10 +129,10 @@ func appendWorkspaceFlag(cmd *[]string, workspacePath string) {
|
|||
// 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.
|
||||
// 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) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Autohand config/default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -175,85 +144,23 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
var autohandBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "autohand",
|
||||
Names: []string{"autohand"},
|
||||
WinNames: []string{"autohand.cmd", "autohand.exe", "autohand"},
|
||||
UnixPaths: []string{"/usr/local/bin/autohand", "/opt/homebrew/bin/autohand"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "autohand"}, {".npm", "bin", "autohand"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "autohand.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
// ResolveAutohandBinary returns the path to the autohand binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
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)
|
||||
return binaryutil.ResolveBinary(ctx, autohandBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) autohandBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -277,8 +184,3 @@ func (p *Plugin) autohandBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
|
@ -125,7 +126,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -137,7 +138,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "autohand"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -208,8 +209,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "sess-123",
|
||||
autohandTitleMetadataKey: "Fix login redirect",
|
||||
autohandSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -356,9 +357,9 @@ func TestDeriveActivityState(t *testing.T) {
|
|||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := DeriveActivityState(tt.event, []byte(`{}`))
|
||||
got, ok := activitystate.StandardDeriveActivityState(tt.event, []byte(`{}`))
|
||||
if got != tt.want || ok != tt.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)",
|
||||
tt.event, got, ok, tt.want, tt.wantOK)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -294,35 +295,12 @@ func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]jso
|
|||
return fmt.Errorf("encode %s: %w", configPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := atomicWriteFile(configPath, data, 0o600); err != nil {
|
||||
if err := hookutil.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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
// Package binaryutil centralizes the "find an agent's CLI binary" search that
|
||||
// every adapter otherwise reimplements. Adapters differ only in the binary
|
||||
// name(s) and the well-known install locations to probe, so they describe those
|
||||
// with a BinarySpec and share the identical PATH-then-candidates iteration.
|
||||
package binaryutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
// BinarySpec describes where one agent's CLI binary can live. ResolveBinary
|
||||
// searches PATH (via the platform's name list) first, then the platform's
|
||||
// candidate install paths in order, returning the first hit.
|
||||
//
|
||||
// Path components are given as string slices joined onto their base directory,
|
||||
// so a spec stays OS-agnostic and never hard-codes a separator. env-derived
|
||||
// bases (APPDATA, LOCALAPPDATA, home) that are unset simply skip their
|
||||
// candidates.
|
||||
type BinarySpec struct {
|
||||
// Label prefixes the ErrAgentBinaryNotFound error, e.g. "claude".
|
||||
Label string
|
||||
|
||||
// Names are the binary names looked up on PATH on non-Windows, in order.
|
||||
Names []string
|
||||
// WinNames are the binary names looked up on PATH on Windows, in order.
|
||||
// Empty means the Windows branch does no PATH lookup.
|
||||
WinNames []string
|
||||
|
||||
// UnixPaths are absolute candidate paths probed on non-Windows, in order.
|
||||
UnixPaths []string
|
||||
// UnixHomePaths are candidate paths under the user's home dir on
|
||||
// non-Windows; each entry is the components to join onto $HOME.
|
||||
UnixHomePaths [][]string
|
||||
|
||||
// WinPaths are candidate paths probed on Windows, in the exact order given.
|
||||
// Each entry names the base directory (%APPDATA%, %LOCALAPPDATA%, or home) it
|
||||
// is joined onto. Order is significant: a native installer location listed
|
||||
// before an npm shim wins when both are present, so it is spelled out here
|
||||
// rather than assumed. Entries whose base env is unset are skipped.
|
||||
WinPaths []WinPath
|
||||
}
|
||||
|
||||
// WinBase names the base directory a Windows candidate path is joined onto.
|
||||
type WinBase int
|
||||
|
||||
// The base directories a Windows candidate path can resolve against.
|
||||
const (
|
||||
WinAppData WinBase = iota // %APPDATA%
|
||||
WinLocalAppData // %LOCALAPPDATA%
|
||||
WinHome // the user's home directory
|
||||
)
|
||||
|
||||
// WinPath is one Windows candidate: Parts joined onto Base's directory.
|
||||
type WinPath struct {
|
||||
Base WinBase
|
||||
Parts []string
|
||||
}
|
||||
|
||||
// ResolveBinary returns the path to spec's binary, searching PATH then the
|
||||
// platform's candidate install locations. It returns a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when nothing matches, so callers surface a clear
|
||||
// "command not found" rather than launching an empty argv. ctx cancellation is
|
||||
// honored between probes.
|
||||
func ResolveBinary(ctx context.Context, spec BinarySpec) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
names := spec.Names
|
||||
var candidates []string
|
||||
if runtime.GOOS == "windows" {
|
||||
names = spec.WinNames
|
||||
home, _ := os.UserHomeDir()
|
||||
appData := os.Getenv("APPDATA")
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
for _, wp := range spec.WinPaths {
|
||||
var base string
|
||||
switch wp.Base {
|
||||
case WinAppData:
|
||||
base = appData
|
||||
case WinLocalAppData:
|
||||
base = localAppData
|
||||
case WinHome:
|
||||
base = home
|
||||
}
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, filepath.Join(append([]string{base}, wp.Parts...)...))
|
||||
}
|
||||
} else {
|
||||
candidates = append(candidates, spec.UnixPaths...)
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, joinAll(home, spec.UnixHomePaths)...)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path, err := exec.LookPath(name); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%s: %w", spec.Label, ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
// joinAll joins each component slice onto base into an absolute candidate path.
|
||||
func joinAll(base string, entries [][]string) []string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, parts := range entries {
|
||||
out = append(out, filepath.Join(append([]string{base}, parts...)...))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package binaryutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
func TestResolveBinaryPrefersPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("PATH lookup shape differs on windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "widget")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
got, err := ResolveBinary(context.Background(), BinarySpec{Label: "widget", Names: []string{"widget"}})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got != bin {
|
||||
t.Fatalf("got %q, want %q", got, bin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBinaryFallsBackToHomeCandidate(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix home candidate shape")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("PATH", t.TempDir()) // empty of the binary
|
||||
bin := filepath.Join(home, ".local", "bin", "widget")
|
||||
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ResolveBinary(context.Background(), BinarySpec{
|
||||
Label: "widget",
|
||||
Names: []string{"widget"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "widget"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got != bin {
|
||||
t.Fatalf("got %q, want %q", got, bin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBinaryNotFound(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
_, err := ResolveBinary(context.Background(), BinarySpec{
|
||||
Label: "widget",
|
||||
Names: []string{"widget-does-not-exist"},
|
||||
WinNames: []string{"widget-does-not-exist.exe"},
|
||||
})
|
||||
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
|
||||
t.Fatalf("want ErrAgentBinaryNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBinaryHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := ResolveBinary(ctx, BinarySpec{Label: "widget", Names: []string{"widget"}})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("want context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -32,6 +31,8 @@ import (
|
|||
"github.com/google/uuid"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -51,6 +52,7 @@ var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4
|
|||
// 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -177,15 +179,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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
|
||||
|
|
@ -261,15 +254,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// AuthStatus checks Claude Code's local authentication state without starting a
|
||||
|
|
@ -409,7 +395,7 @@ func resolveSystemPrompt(cfg ports.LaunchConfig) (string, error) {
|
|||
//
|
||||
// Empty/unrecognized normalizes to default, so no flag is emitted.
|
||||
func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's settings.json defaultMode.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -435,74 +421,24 @@ func appendToolFlags(cmd *[]string, allowed, disallowed []string) {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// claudeBinarySpec locates the claude binary: PATH first, then the native
|
||||
// installer's locations, npm global, Homebrew, and the claude-managed dir.
|
||||
var claudeBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "claude",
|
||||
Names: []string{"claude"},
|
||||
WinNames: []string{"claude.cmd", "claude.exe", "claude"},
|
||||
UnixPaths: []string{"/usr/local/bin/claude", "/opt/homebrew/bin/claude"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "claude"}, {".npm", "bin", "claude"}, {".claude", "local", "claude"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
// ResolveClaudeBinary returns the path to the claude binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
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)
|
||||
return binaryutil.ResolveBinary(ctx, claudeBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) claudeBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -609,8 +545,3 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -183,8 +184,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
var config struct {
|
||||
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -260,8 +261,8 @@ func TestUninstallHooksRemovesClaudeHooks(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
var config struct {
|
||||
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
|
||||
Permissions json.RawMessage `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -343,7 +344,7 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
|||
|
||||
// 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 {
|
||||
func countClaudeHookCommand(groups []hooksjson.MatcherGroup, command string) int {
|
||||
count := 0
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
|
|
@ -357,7 +358,7 @@ func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int {
|
|||
|
||||
// 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 {
|
||||
func matcherForCommand(groups []hooksjson.MatcherGroup, command string) *string {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
|
|
|
|||
|
|
@ -2,64 +2,25 @@ 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/adapters/agent/hooksjson"
|
||||
"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.
|
||||
claudeSettingsDirName = ".claude"
|
||||
claudeSettingsFileName = "settings.local.json"
|
||||
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{
|
||||
// claudeManagedHooks is the source of truth for the hooks AO installs.
|
||||
var claudeManagedHooks = []hooksjson.HookSpec{
|
||||
{Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"},
|
||||
{Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"},
|
||||
{Event: "Stop", Command: claudeHookCommandPrefix + "stop"},
|
||||
|
|
@ -67,282 +28,31 @@ var claudeManagedHooks = []claudeHookSpec{
|
|||
{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
|
||||
// claudeHooks manages AO's hooks in the workspace-local
|
||||
// .claude/settings.local.json file.
|
||||
var claudeHooks = hooksjson.Manager{
|
||||
Label: "claude-code",
|
||||
CommandPrefix: claudeHookCommandPrefix,
|
||||
Timeout: claudeHookTimeout,
|
||||
Path: claudeSettingsPath,
|
||||
Managed: claudeManagedHooks,
|
||||
}
|
||||
|
||||
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
|
||||
// GetAgentHooks installs AO's Claude Code hooks, preserving user-defined hooks and unrelated settings.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return claudeHooks.Install(ctx, cfg.WorkspacePath)
|
||||
}
|
||||
|
||||
// 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
|
||||
// UninstallHooks removes AO's Claude Code hooks, leaving user-defined hooks untouched.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
return claudeHooks.Uninstall(ctx, workspacePath)
|
||||
}
|
||||
|
||||
// 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
|
||||
// AreHooksInstalled reports whether any AO Claude Code hook is present.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
return claudeHooks.AreInstalled(ctx, workspacePath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -16,26 +16,19 @@ 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/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -61,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 Cline session. Prompted
|
||||
// launches request machine-readable NDJSON output (`--json`). Promptless
|
||||
// launches stay interactive because Cline's JSON output mode requires a prompt
|
||||
|
|
@ -97,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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 [approval flags] --id <agentSessionId>`. Resumes are interactive
|
||||
// because no prompt is supplied here. ok is false when the hook-derived native
|
||||
|
|
@ -138,82 +114,27 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
var clineBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "cline",
|
||||
Names: []string{"cline"},
|
||||
WinNames: []string{"cline.cmd", "cline.exe", "cline"},
|
||||
UnixPaths: []string{"/usr/local/bin/cline", "/opt/homebrew/bin/cline"},
|
||||
UnixHomePaths: [][]string{{".npm-global", "bin", "cline"}, {".npm", "bin", "cline"}, {".local", "bin", "cline"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
// searching PATH then a handful of well-known install locations (Homebrew, npm
|
||||
// global). It returns a wrapped ports.ErrAgentBinaryNotFound when cline is absent.
|
||||
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)
|
||||
return binaryutil.ResolveBinary(ctx, clineBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) clineBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -233,7 +154,7 @@ func (p *Plugin) clineBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Cline config/default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -249,20 +170,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -126,7 +126,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cline"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -245,11 +245,11 @@ func TestUninstallHooksRemovesClineHooks(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, spec := range clineManagedHooks {
|
||||
if fileExists(filepath.Join(hooksDir, spec.Event)) {
|
||||
if hookutil.FileExists(filepath.Join(hooksDir, spec.Event)) {
|
||||
t.Fatalf("%s still present after uninstall", spec.Event)
|
||||
}
|
||||
}
|
||||
if !fileExists(userHook) {
|
||||
if !hookutil.FileExists(userHook) {
|
||||
t.Fatal("user PostToolUse hook was removed by uninstall")
|
||||
}
|
||||
}
|
||||
|
|
@ -324,8 +324,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "session-123",
|
||||
clineTitleMetadataKey: "Fix login redirect",
|
||||
clineSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -367,29 +367,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -83,11 +83,11 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
|
|||
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) {
|
||||
if hookutil.FileExists(scriptPath) && !isManagedClineHook(scriptPath) {
|
||||
continue
|
||||
}
|
||||
script := renderClineHookScript(spec.Subcommand)
|
||||
if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil {
|
||||
if err := hookutil.AtomicWriteFile(scriptPath, []byte(script), 0o700); err != nil {
|
||||
return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err)
|
||||
}
|
||||
written = append(written, spec.Event)
|
||||
|
|
@ -116,7 +116,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error
|
|||
|
||||
for _, spec := range clineManagedHooks {
|
||||
scriptPath := filepath.Join(hooksDir, spec.Event)
|
||||
if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) {
|
||||
if !hookutil.FileExists(scriptPath) || !isManagedClineHook(scriptPath) {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
|
|
@ -143,7 +143,7 @@ func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (b
|
|||
|
||||
for _, spec := range clineManagedHooks {
|
||||
scriptPath := filepath.Join(hooksDir, spec.Event)
|
||||
if fileExists(scriptPath) && isManagedClineHook(scriptPath) {
|
||||
if hookutil.FileExists(scriptPath) && isManagedClineHook(scriptPath) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -177,26 +177,3 @@ func isManagedClineHook(scriptPath string) bool {
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -51,14 +53,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -92,15 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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
|
||||
|
|
@ -138,15 +123,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// AuthStatus checks Codex's local login state without making a model call.
|
||||
|
|
@ -340,7 +318,7 @@ func appendTerminalCompatibilityFlags(cmd *[]string) {
|
|||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.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
|
||||
|
|
@ -355,18 +333,7 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
||||
switch mode {
|
||||
case ports.PermissionModeDefault,
|
||||
ports.PermissionModeAcceptEdits,
|
||||
ports.PermissionModeAuto,
|
||||
ports.PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
// fileExists is a package var so tests can stub it to scope candidate probing.
|
||||
var fileExists = func(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ func TestCodexTOMLBasicStringEscapes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -245,7 +245,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "codex"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -22,15 +22,12 @@ 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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
|
@ -39,9 +36,22 @@ import (
|
|||
// (NOT the Go package name "continueagent").
|
||||
const adapterID = "continue"
|
||||
|
||||
var continueBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "cn",
|
||||
Names: []string{"cn"},
|
||||
WinNames: []string{"cn.cmd", "cn.exe", "cn"},
|
||||
UnixPaths: []string{"/usr/local/bin/cn", "/opt/homebrew/bin/cn"},
|
||||
UnixHomePaths: [][]string{{".npm-global", "bin", "cn"}, {".local", "bin", "cn"}, {".npm", "bin", "cn"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -67,14 +77,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -97,14 +99,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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.
|
||||
//
|
||||
|
|
@ -154,15 +148,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then
|
||||
|
|
@ -170,63 +157,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
// 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)
|
||||
return binaryutil.ResolveBinary(ctx, continueBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) continueBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -251,7 +182,7 @@ func (p *Plugin) continueBinary(ctx context.Context) (string, error) {
|
|||
// 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) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Continue config / default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -262,20 +193,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -28,19 +28,17 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "copilot"
|
||||
|
||||
copilotTitleMetadataKey = "title"
|
||||
copilotSummaryMetadataKey = "summary"
|
||||
)
|
||||
const adapterID = "copilot"
|
||||
|
||||
// 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -66,14 +64,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 interactive Copilot session:
|
||||
//
|
||||
// copilot [permission flags]
|
||||
|
|
@ -95,7 +85,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the
|
||||
// interactive process starts.
|
||||
// interactive process starts. This overrides the agentbase.Base default
|
||||
// (in-command) because Copilot's `-p` programmatic mode exits when done, which
|
||||
// would leave AO's terminal pane dead.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
|
|
@ -138,21 +130,16 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, 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.
|
||||
// Homebrew, the VS Code extension's bundled CLI). When the resolved path is the
|
||||
// npm-loader shim, the platform-native binary is returned instead. This resolver
|
||||
// stays hand-rolled (rather than binaryutil.ResolveBinary) because of that
|
||||
// native-loader indirection.
|
||||
func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
|
|
@ -179,7 +166,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
|||
candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
|
@ -211,7 +198,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
if native := copilotNativeBinaryForLoader(candidate); native != "" {
|
||||
return native, nil
|
||||
}
|
||||
|
|
@ -238,7 +225,7 @@ func copilotNativeBinaryForLoader(path string) string {
|
|||
platform = "darwin"
|
||||
}
|
||||
native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH)
|
||||
if fileExists(native) {
|
||||
if hookutil.FileExists(native) {
|
||||
return native
|
||||
}
|
||||
return ""
|
||||
|
|
@ -263,12 +250,12 @@ func (p *Plugin) copilotBinary(ctx context.Context) (string, error) {
|
|||
// 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)
|
||||
// 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) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's ~/.copilot config / interactive prompts.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -279,20 +266,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -118,7 +117,7 @@ func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -130,7 +129,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "copilot"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -309,8 +308,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "uuid-123",
|
||||
copilotTitleMetadataKey: "Fix login redirect",
|
||||
copilotSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -507,29 +506,6 @@ func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -238,40 +238,12 @@ func writeCopilotHooks(hooksPath string, file copilotHookFile) error {
|
|||
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := atomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
if err := hookutil.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 {
|
||||
|
|
|
|||
|
|
@ -8,15 +8,12 @@ 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/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -29,6 +26,7 @@ const (
|
|||
// 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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
//
|
||||
|
|
@ -102,15 +92,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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
|
||||
|
|
@ -143,83 +124,24 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
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
|
||||
var crushBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "crush",
|
||||
Names: []string{"crush"},
|
||||
WinNames: []string{"crush.cmd", "crush.exe", "crush"},
|
||||
UnixPaths: []string{"/usr/local/bin/crush", "/opt/homebrew/bin/crush"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "crush"}, {".cargo", "bin", "crush"}, {".npm", "bin", "crush"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "crush.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// 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.
|
||||
// searching PATH then a handful of well-known install locations. It returns a
|
||||
// wrapped ports.ErrAgentBinaryNotFound when crush is absent.
|
||||
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)
|
||||
return binaryutil.ResolveBinary(ctx, crushBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) crushBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -237,8 +159,3 @@ func (p *Plugin) crushBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "crush"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -18,17 +18,15 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"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 {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
|
@ -92,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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:
|
||||
//
|
||||
|
|
@ -136,15 +117,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// ResolveCursorBinary returns the path to the cursor-agent binary on this
|
||||
|
|
@ -183,7 +157,7 @@ func ResolveCursorBinary(ctx context.Context) (string, error) {
|
|||
)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
|
@ -211,7 +185,7 @@ func (p *Plugin) cursorBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Cursor config approvalMode.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -223,20 +197,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -125,7 +125,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "cursor-agent"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -200,8 +200,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "chat-123",
|
||||
cursorTitleMetadataKey: "Fix login redirect",
|
||||
cursorSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,26 +24,30 @@ 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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
devinTitleMetadataKey = "title"
|
||||
devinSummaryMetadataKey = "summary"
|
||||
)
|
||||
var devinBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "devin",
|
||||
Names: []string{"devin"},
|
||||
WinNames: []string{"devin.cmd", "devin.exe", "devin"},
|
||||
UnixPaths: []string{"/usr/local/bin/devin", "/opt/homebrew/bin/devin"},
|
||||
UnixHomePaths: [][]string{{".devin", "bin", "devin"}, {".local", "bin", "devin"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinHome, Parts: []string{".devin", "bin", "devin.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin is the Devin for Terminal agent adapter.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -69,14 +73,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
//
|
||||
|
|
@ -99,14 +95,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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.
|
||||
//
|
||||
|
|
@ -161,74 +149,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, 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)
|
||||
return binaryutil.ResolveBinary(ctx, devinBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) devinBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -251,7 +178,7 @@ func (p *Plugin) devinBinary(ctx context.Context) (string, error) {
|
|||
// permission values (`auto`/normal and `dangerous`/bypass), per
|
||||
// `devin --permission-mode -h`.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to ~/.config/devin/config.json (default mode is auto).
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -264,20 +191,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,8 +195,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
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.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -23,28 +23,21 @@ import (
|
|||
"encoding/json"
|
||||
"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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
// Normalized session-metadata keys the hooks persist into the AO session
|
||||
// store and SessionInfo reads back. Shared vocabulary with the Codex, Grok,
|
||||
// and opencode adapters so the dashboard treats every agent uniformly.
|
||||
droidTitleMetadataKey = "title"
|
||||
droidSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Droid agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -70,14 +63,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 Droid session:
|
||||
//
|
||||
// droid [--settings <path>] [--append-system-prompt[-file] <x>] [prompt]
|
||||
|
|
@ -115,15 +100,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Droid receives its prompt in the launch
|
||||
// command itself (the positional prompt 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 Droid session:
|
||||
// `droid [--settings <path>] -r <agentSessionId>`. It re-applies the permission
|
||||
// autonomy (resume otherwise reverts to the configured default) but not the
|
||||
|
|
@ -161,15 +137,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[droidTitleMetadataKey],
|
||||
Summary: session.Metadata[droidSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// droidAutonomyLevel maps an AO permission mode onto Droid's
|
||||
|
|
@ -182,7 +151,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
// bypass-permissions → high (max interactive autonomy; Droid's interactive
|
||||
// TUI has no exec-style --skip-permissions-unsafe)
|
||||
func droidAutonomyLevel(mode ports.PermissionMode) string {
|
||||
switch normalizePermissionMode(mode) {
|
||||
switch ports.NormalizePermissionMode(mode) {
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
return "low"
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
@ -249,73 +218,24 @@ func sanitizeSessionID(id string) string {
|
|||
return b.String()
|
||||
}
|
||||
|
||||
// ResolveDroidBinary finds the `droid` binary (Factory Droid CLI), searching
|
||||
// PATH then a handful of well-known install locations. Returns "droid" as a
|
||||
// last-ditch fallback so callers see a clear "command not found" rather than an
|
||||
// empty argv.
|
||||
var droidBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "droid",
|
||||
Names: []string{"droid"},
|
||||
WinNames: []string{"droid.cmd", "droid.exe", "droid"},
|
||||
UnixPaths: []string{"/usr/local/bin/droid", "/opt/homebrew/bin/droid"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "droid"}, {".factory", "bin", "droid"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "droid.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".factory", "bin", "droid.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveDroidBinary returns the path to the droid binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveDroidBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"droid.cmd", "droid.exe", "droid"} {
|
||||
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", "droid.cmd"),
|
||||
filepath.Join(appData, "npm", "droid.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "droid.exe"),
|
||||
filepath.Join(home, ".factory", "bin", "droid.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("droid"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/droid",
|
||||
"/opt/homebrew/bin/droid",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "droid"),
|
||||
filepath.Join(home, ".factory", "bin", "droid"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, droidBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) droidBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -333,21 +253,3 @@ func (p *Plugin) droidBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
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 Droid's own settings (no flag).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "droid-ses-1",
|
||||
droidTitleMetadataKey: "Fix login redirect",
|
||||
droidSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -2,15 +2,9 @@ package droid
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -18,49 +12,20 @@ const (
|
|||
droidSettingsDirName = ".factory"
|
||||
droidHooksFileName = "hooks.json"
|
||||
|
||||
// droidHookCommandPrefix 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. The CLI dispatcher routes `ao hooks droid <event>` to the Droid
|
||||
// activity deriver.
|
||||
// droidHookCommandPrefix identifies the hook commands AO owns, so install
|
||||
// skips duplicates and uninstall recognizes AO entries by prefix.
|
||||
droidHookCommandPrefix = "ao hooks droid "
|
||||
droidHookTimeout = 30
|
||||
)
|
||||
|
||||
type droidMatcherGroup struct {
|
||||
// Matcher is a pointer so it round-trips exactly: SessionStart serializes
|
||||
// with its "startup" matcher; UserPromptSubmit/Stop/Notification/SessionEnd
|
||||
// omit it (Droid ignores matcher for those events). omitempty drops a nil
|
||||
// matcher on write.
|
||||
Matcher *string `json:"matcher,omitempty"`
|
||||
Hooks []droidHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type droidHookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// droidHookSpec describes one hook AO installs, defined in code rather than read
|
||||
// from an embedded settings file.
|
||||
type droidHookSpec struct {
|
||||
Event string
|
||||
Matcher *string
|
||||
Command string
|
||||
}
|
||||
|
||||
// droidStartupMatcher is referenced by pointer so SessionStart serializes with
|
||||
// its "startup" source matcher.
|
||||
var droidStartupMatcher = "startup"
|
||||
|
||||
// droidManagedHooks is the source of truth for the hooks AO installs:
|
||||
// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop,
|
||||
// Notification, and SessionEnd. They report normalized activity-state signals
|
||||
// back into AO's store (see DeriveActivityState). The non-SessionStart events
|
||||
// carry no matcher: each installs once and fires for every sub-type, and the
|
||||
// handler filters on the payload where it must.
|
||||
var droidManagedHooks = []droidHookSpec{
|
||||
// Notification, and SessionEnd.
|
||||
var droidManagedHooks = []hooksjson.HookSpec{
|
||||
{Event: "SessionStart", Matcher: &droidStartupMatcher, Command: droidHookCommandPrefix + "session-start"},
|
||||
{Event: "UserPromptSubmit", Command: droidHookCommandPrefix + "user-prompt-submit"},
|
||||
{Event: "Stop", Command: droidHookCommandPrefix + "stop"},
|
||||
|
|
@ -68,287 +33,30 @@ var droidManagedHooks = []droidHookSpec{
|
|||
{Event: "SessionEnd", Command: droidHookCommandPrefix + "session-end"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Droid hooks into the worktree-local
|
||||
// .factory/hooks.json file (the project-scope hooks config Droid reads). The
|
||||
// hooks report normalized activity-state signals back into AO's store. Existing
|
||||
// hooks and unrelated keys 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("droid.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := droidHooksPath(cfg.WorkspacePath)
|
||||
topLevel, rawHooks, err := readDroidHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("droid.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
byEvent := groupDroidHooksByEvent()
|
||||
events := make([]string, 0, len(byEvent))
|
||||
for event := range byEvent {
|
||||
events = append(events, event)
|
||||
}
|
||||
sort.Strings(events)
|
||||
for _, event := range events {
|
||||
specs := byEvent[event]
|
||||
var existingGroups []droidMatcherGroup
|
||||
if err := parseDroidHookType(rawHooks, event, &existingGroups); err != nil {
|
||||
return fmt.Errorf("droid.GetAgentHooks: %w", err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !droidHookCommandExists(existingGroups, spec.Command) {
|
||||
entry := droidHookEntry{Type: "command", Command: spec.Command, Timeout: droidHookTimeout}
|
||||
existingGroups = addDroidHook(existingGroups, entry, spec.Matcher)
|
||||
}
|
||||
}
|
||||
if err := marshalDroidHookType(rawHooks, event, existingGroups); err != nil {
|
||||
return fmt.Errorf("droid.GetAgentHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("droid.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), droidHooksFileName); err != nil {
|
||||
return fmt.Errorf("droid.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Droid hooks from the workspace-local
|
||||
// .factory/hooks.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("droid.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := droidHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readDroidHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("droid.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range droidManagedEvents() {
|
||||
var groups []droidMatcherGroup
|
||||
if err := parseDroidHookType(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("droid.UninstallHooks: %w", err)
|
||||
}
|
||||
groups = removeDroidManagedHooks(groups)
|
||||
if err := marshalDroidHookType(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("droid.UninstallHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("droid.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Droid 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("droid.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := droidHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readDroidHooks(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("droid.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range droidManagedEvents() {
|
||||
var groups []droidMatcherGroup
|
||||
if err := parseDroidHookType(rawHooks, event, &groups); err != nil {
|
||||
return false, fmt.Errorf("droid.AreHooksInstalled: %w", err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if isDroidManagedHook(hook.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
// droidHooks manages AO's hooks in the workspace-local .factory/hooks.json file.
|
||||
var droidHooks = hooksjson.Manager{
|
||||
Label: "droid",
|
||||
CommandPrefix: droidHookCommandPrefix,
|
||||
Timeout: droidHookTimeout,
|
||||
Path: droidHooksPath,
|
||||
Managed: droidManagedHooks,
|
||||
}
|
||||
|
||||
func droidHooksPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, droidSettingsDirName, droidHooksFileName)
|
||||
}
|
||||
|
||||
// readDroidHooks loads the hooks 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 readDroidHooks(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
|
||||
// GetAgentHooks installs AO's Droid hooks, preserving user-defined hooks.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return droidHooks.Install(ctx, cfg.WorkspacePath)
|
||||
}
|
||||
|
||||
// writeDroidHooks folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeDroidHooks(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 hooks 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
|
||||
// UninstallHooks removes AO's Droid hooks, leaving user-defined hooks untouched.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
return droidHooks.Uninstall(ctx, workspacePath)
|
||||
}
|
||||
|
||||
// groupDroidHooksByEvent groups the managed hook specs by their Droid event so
|
||||
// each event's array is rewritten once.
|
||||
func groupDroidHooksByEvent() map[string][]droidHookSpec {
|
||||
byEvent := map[string][]droidHookSpec{}
|
||||
for _, spec := range droidManagedHooks {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
// droidManagedEvents returns the distinct Droid events AO manages, in the order
|
||||
// they first appear in droidManagedHooks.
|
||||
func droidManagedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(droidManagedHooks))
|
||||
for _, spec := range droidManagedHooks {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func isDroidManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, droidHookCommandPrefix)
|
||||
}
|
||||
|
||||
// removeDroidManagedHooks strips AO hook entries from every group, dropping any
|
||||
// group left without hooks so the event array doesn't accumulate empty matcher
|
||||
// objects.
|
||||
func removeDroidManagedHooks(groups []droidMatcherGroup) []droidMatcherGroup {
|
||||
result := make([]droidMatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]droidHookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !isDroidManagedHook(hook.Command) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseDroidHookType(rawHooks map[string]json.RawMessage, event string, target *[]droidMatcherGroup) 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 marshalDroidHookType(rawHooks map[string]json.RawMessage, event string, groups []droidMatcherGroup) 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 droidHookCommandExists(groups []droidMatcherGroup, command string) bool {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addDroidHook 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 addDroidHook(groups []droidMatcherGroup, hook droidHookEntry, matcher *string) []droidMatcherGroup {
|
||||
for i, group := range groups {
|
||||
if matchersEqual(group.Matcher, matcher) {
|
||||
groups[i].Hooks = append(groups[i].Hooks, hook)
|
||||
return groups
|
||||
}
|
||||
}
|
||||
return append(groups, droidMatcherGroup{Matcher: matcher, Hooks: []droidHookEntry{hook}})
|
||||
}
|
||||
|
||||
func matchersEqual(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
// AreHooksInstalled reports whether any AO Droid hook is present.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
return droidHooks.AreInstalled(ctx, workspacePath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
package goose
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Goose 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 gooseManagedHooks
|
||||
// ("session-start", "user-prompt-submit", "stop", "permission-request"), not
|
||||
// the native Goose event name.
|
||||
//
|
||||
// Goose's native hook surface (as of 2026-05) emits SessionStart /
|
||||
// UserPromptSubmit / Stop / SessionEnd plus the tool-use events, but has no
|
||||
// dedicated permission/approval event yet, so AO does not install a
|
||||
// "permission-request" hook today. The case is kept here so that, if a future
|
||||
// Goose release adds an approval lifecycle event, mapping it to waiting_input is
|
||||
// a one-line hooks.go change with no deriver edit needed.
|
||||
//
|
||||
// TODO(goose): ActivityExited is still runtime-observation-owned. Goose has a
|
||||
// native SessionEnd hook; if AO starts installing it, map it to ActivityExited
|
||||
// here. Until then, the lifecycle reaper marks a dead Goose runtime as exited.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package goose
|
||||
|
||||
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},
|
||||
{"empty event -> no signal", "", "", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -25,22 +25,18 @@ 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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
adapterID = "goose"
|
||||
|
||||
gooseTitleMetadataKey = "title"
|
||||
gooseSummaryMetadataKey = "summary"
|
||||
|
||||
// gooseModeEnvVar is the only permission-control surface Goose honors: the
|
||||
// approval mode is read from this process env var, not from any CLI flag.
|
||||
gooseModeEnvVar = "GOOSE_MODE"
|
||||
|
|
@ -49,6 +45,7 @@ const (
|
|||
// Plugin is the Goose agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -74,14 +71,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Goose 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 Goose session:
|
||||
//
|
||||
// [env GOOSE_MODE=<mode>] goose run [--system <text>] -t <prompt>
|
||||
|
|
@ -117,15 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Goose receives its prompt in the launch
|
||||
// command itself (via `-t`).
|
||||
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 Goose session:
|
||||
//
|
||||
// [env GOOSE_MODE=<mode>] goose run --resume --session-id <agentSessionId>
|
||||
|
|
@ -156,15 +136,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[gooseTitleMetadataKey],
|
||||
Summary: session.Metadata[gooseSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// systemPromptText returns the system instructions to inject. Goose's `--system`
|
||||
|
|
@ -210,7 +183,7 @@ func gooseModeEnvPrefix(mode ports.PermissionMode) []string {
|
|||
// - bypass-permissions → auto: Goose's fully-autonomous mode is the nearest
|
||||
// equivalent to bypass.
|
||||
func gooseMode(mode ports.PermissionMode) string {
|
||||
switch normalizePermissionMode(mode) {
|
||||
switch ports.NormalizePermissionMode(mode) {
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
return "smart_approve"
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
@ -222,90 +195,26 @@ func gooseMode(mode ports.PermissionMode) string {
|
|||
}
|
||||
}
|
||||
|
||||
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 Goose's own config (no env).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
// gooseBinarySpec locates the goose binary: PATH first, then the install
|
||||
// script's ~/.local/bin, Homebrew, Cargo, and npm global locations.
|
||||
var gooseBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "goose",
|
||||
Names: []string{"goose"},
|
||||
WinNames: []string{"goose.cmd", "goose.exe", "goose"},
|
||||
UnixPaths: []string{"/usr/local/bin/goose", "/opt/homebrew/bin/goose"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "goose"}, {".cargo", "bin", "goose"}, {".npm", "bin", "goose"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.exe"}},
|
||||
{Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "goose", "goose.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "goose.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveGooseBinary returns the path to the goose binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (the install
|
||||
// script's ~/.local/bin, Homebrew, Cargo, npm global). Returns "goose" as a
|
||||
// last-ditch fallback so callers see a clear "command not found" rather than an
|
||||
// empty argv.
|
||||
// ResolveGooseBinary returns the path to the goose binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveGooseBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"goose.cmd", "goose.exe", "goose"} {
|
||||
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", "goose.cmd"),
|
||||
filepath.Join(appData, "npm", "goose.exe"),
|
||||
)
|
||||
}
|
||||
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||
candidates = append(candidates, filepath.Join(localAppData, "Programs", "goose", "goose.exe"))
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "goose.exe"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("goose"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/goose",
|
||||
"/opt/homebrew/bin/goose",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "goose"),
|
||||
filepath.Join(home, ".cargo", "bin", "goose"),
|
||||
filepath.Join(home, ".npm", "bin", "goose"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, gooseBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) gooseBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -323,8 +232,3 @@ func (p *Plugin) gooseBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "goose"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -153,7 +154,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "goose"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -419,8 +420,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "thread-123",
|
||||
gooseTitleMetadataKey: "Fix login redirect",
|
||||
gooseSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -511,7 +512,13 @@ func containsSubsequence(values []string, needle []string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func countGooseHookCommand(entries []gooseMatcherGroup, command string) int {
|
||||
// gooseHookFile is the on-disk shape of the hooks file, used to decode and
|
||||
// assert on what GetAgentHooks wrote.
|
||||
type gooseHookFile struct {
|
||||
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
|
||||
}
|
||||
|
||||
func countGooseHookCommand(entries []hooksjson.MatcherGroup, command string) int {
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
for _, hook := range entry.Hooks {
|
||||
|
|
|
|||
|
|
@ -2,22 +2,16 @@ package goose
|
|||
|
||||
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/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
// goosePluginDirName is the AO plugin directory under a workspace's
|
||||
// .agents/plugins/. Goose auto-discovers any plugin dir containing a
|
||||
// hooks/hooks.json at startup; unlike Codex there is no separate feature
|
||||
// flag to toggle, so installing the file is sufficient.
|
||||
// Goose auto-discovers any plugin dir containing a hooks/hooks.json at
|
||||
// startup; unlike Codex there is no separate feature flag to toggle, so
|
||||
// installing the file is sufficient.
|
||||
gooseHooksRootDirName = ".agents"
|
||||
goosePluginsDirName = "plugins"
|
||||
goosePluginName = "ao"
|
||||
|
|
@ -25,332 +19,45 @@ const (
|
|||
gooseHooksFileName = "hooks.json"
|
||||
|
||||
// gooseHookCommandPrefix identifies the hook commands AO owns, so install
|
||||
// skips duplicates and uninstall recognizes AO entries by prefix without an
|
||||
// embedded template to diff against.
|
||||
// skips duplicates and uninstall recognizes AO entries by prefix.
|
||||
gooseHookCommandPrefix = "ao hooks goose "
|
||||
gooseHookTimeout = 30
|
||||
)
|
||||
|
||||
// gooseHookFile is the on-disk shape of .agents/plugins/ao/hooks/hooks.json. It
|
||||
// is used by tests to decode the written file.
|
||||
type gooseHookFile struct {
|
||||
Hooks map[string][]gooseMatcherGroup `json:"hooks"`
|
||||
}
|
||||
|
||||
type gooseMatcherGroup struct {
|
||||
Matcher *string `json:"matcher,omitempty"`
|
||||
Hooks []gooseHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type gooseHookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// gooseHookSpec describes one hook AO installs, defined in code rather than read
|
||||
// from an embedded hooks file.
|
||||
type gooseHookSpec struct {
|
||||
Event string
|
||||
Command string
|
||||
}
|
||||
|
||||
// gooseManagedHooks is the source of truth for the hooks AO installs. Goose
|
||||
// groups every hook under the nil matcher. Goose has no permission/approval
|
||||
// lifecycle event yet, so AO installs only the session/prompt/stop signals.
|
||||
var gooseManagedHooks = []gooseHookSpec{
|
||||
var gooseManagedHooks = []hooksjson.HookSpec{
|
||||
{Event: "SessionStart", Command: gooseHookCommandPrefix + "session-start"},
|
||||
{Event: "UserPromptSubmit", Command: gooseHookCommandPrefix + "user-prompt-submit"},
|
||||
{Event: "Stop", Command: gooseHookCommandPrefix + "stop"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Goose hooks into the worktree-local
|
||||
// .agents/plugins/ao/hooks/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("goose.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := gooseHooksPath(cfg.WorkspacePath)
|
||||
topLevel, rawHooks, err := readGooseHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
for event, specs := range groupGooseHooksByEvent() {
|
||||
var existingGroups []gooseMatcherGroup
|
||||
if err := parseGooseHookType(rawHooks, event, &existingGroups); err != nil {
|
||||
return fmt.Errorf("goose.GetAgentHooks: %w", err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !gooseHookCommandExists(existingGroups, spec.Command) {
|
||||
entry := gooseHookEntry{Type: "command", Command: spec.Command, Timeout: gooseHookTimeout}
|
||||
existingGroups = addGooseHook(existingGroups, entry)
|
||||
}
|
||||
}
|
||||
if err := marshalGooseHookType(rawHooks, event, existingGroups); err != nil {
|
||||
return fmt.Errorf("goose.GetAgentHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("goose.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), gooseHooksFileName); err != nil {
|
||||
return fmt.Errorf("goose.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Goose hooks from the workspace-local
|
||||
// .agents/plugins/ao/hooks/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("goose.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := gooseHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readGooseHooks(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range gooseManagedEvents() {
|
||||
var groups []gooseMatcherGroup
|
||||
if err := parseGooseHookType(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("goose.UninstallHooks: %w", err)
|
||||
}
|
||||
groups = removeGooseManagedHooks(groups)
|
||||
if err := marshalGooseHookType(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("goose.UninstallHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("goose.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Goose 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("goose.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
hooksPath := gooseHooksPath(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readGooseHooks(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("goose.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range gooseManagedEvents() {
|
||||
var groups []gooseMatcherGroup
|
||||
if err := parseGooseHookType(rawHooks, event, &groups); err != nil {
|
||||
return false, fmt.Errorf("goose.AreHooksInstalled: %w", err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if isGooseManagedHook(hook.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
// gooseHooks manages AO's hooks in the workspace-local
|
||||
// .agents/plugins/ao/hooks/hooks.json file.
|
||||
var gooseHooks = hooksjson.Manager{
|
||||
Label: "goose",
|
||||
CommandPrefix: gooseHookCommandPrefix,
|
||||
Timeout: gooseHookTimeout,
|
||||
Path: gooseHooksPath,
|
||||
Managed: gooseManagedHooks,
|
||||
}
|
||||
|
||||
func gooseHooksPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, gooseHooksRootDirName, goosePluginsDirName, goosePluginName, gooseHooksSubDirName, gooseHooksFileName)
|
||||
}
|
||||
|
||||
// readGooseHooks 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 readGooseHooks(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
|
||||
// GetAgentHooks installs AO's Goose hooks, preserving user-defined hooks.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return gooseHooks.Install(ctx, cfg.WorkspacePath)
|
||||
}
|
||||
|
||||
// writeGooseHooks folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeGooseHooks(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 := atomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", hooksPath, err)
|
||||
}
|
||||
return nil
|
||||
// UninstallHooks removes AO's Goose hooks, leaving user-defined hooks untouched.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
return gooseHooks.Uninstall(ctx, workspacePath)
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
|
||||
// write can't leave a truncated/empty file that Goose 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)
|
||||
}
|
||||
|
||||
// groupGooseHooksByEvent groups the managed hook specs by their Goose event so
|
||||
// each event's array is rewritten once.
|
||||
func groupGooseHooksByEvent() map[string][]gooseHookSpec {
|
||||
byEvent := map[string][]gooseHookSpec{}
|
||||
for _, spec := range gooseManagedHooks {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
// gooseManagedEvents returns the distinct Goose events AO manages, in the order
|
||||
// they first appear in gooseManagedHooks.
|
||||
func gooseManagedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(gooseManagedHooks))
|
||||
for _, spec := range gooseManagedHooks {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func isGooseManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, gooseHookCommandPrefix)
|
||||
}
|
||||
|
||||
// removeGooseManagedHooks strips AO hook entries from every group, dropping any
|
||||
// group left without hooks.
|
||||
func removeGooseManagedHooks(groups []gooseMatcherGroup) []gooseMatcherGroup {
|
||||
result := make([]gooseMatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]gooseHookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !isGooseManagedHook(hook.Command) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseGooseHookType(rawHooks map[string]json.RawMessage, event string, target *[]gooseMatcherGroup) 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 marshalGooseHookType(rawHooks map[string]json.RawMessage, event string, groups []gooseMatcherGroup) 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 gooseHookCommandExists(groups []gooseMatcherGroup, command string) bool {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addGooseHook(groups []gooseMatcherGroup, hook gooseHookEntry) []gooseMatcherGroup {
|
||||
for i, group := range groups {
|
||||
if group.Matcher == nil {
|
||||
groups[i].Hooks = append(groups[i].Hooks, hook)
|
||||
return groups
|
||||
}
|
||||
}
|
||||
return append(groups, gooseMatcherGroup{
|
||||
Matcher: nil,
|
||||
Hooks: []gooseHookEntry{hook},
|
||||
})
|
||||
// AreHooksInstalled reports whether any AO Goose hook is present.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
return gooseHooks.AreInstalled(ctx, workspacePath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,21 +16,32 @@ package grok
|
|||
|
||||
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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
var grokBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "grok",
|
||||
Names: []string{"grok"},
|
||||
WinNames: []string{"grok.cmd", "grok.exe", "grok"},
|
||||
UnixPaths: []string{"/usr/local/bin/grok", "/opt/homebrew/bin/grok"},
|
||||
UnixHomePaths: [][]string{{".grok", "bin", "grok"}, {".local", "bin", "grok"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".grok", "bin", "grok.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Plugin is the Grok Build agent adapter.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -56,14 +67,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 `grok --no-auto-update [--permission-mode <mode>] -p <prompt>`.
|
||||
// Prompt is delivered via -p (in command).
|
||||
//
|
||||
|
|
@ -85,14 +88,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
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 Grok Build
|
||||
// has a full Claude Code compatibility layer.
|
||||
//
|
||||
|
|
@ -166,81 +161,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
// The keys written by claude hooks (which we install for grok too).
|
||||
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
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// ResolveGrokBinary finds the `grok` binary (xAI Grok Build CLI).
|
||||
func ResolveGrokBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"grok.cmd", "grok.exe", "grok"} {
|
||||
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", "grok.cmd"),
|
||||
filepath.Join(appData, "npm", "grok.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".grok", "bin", "grok.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("grok"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/grok",
|
||||
"/opt/homebrew/bin/grok",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".grok", "bin", "grok"),
|
||||
filepath.Join(home, ".local", "bin", "grok"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, grokBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) grokBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -260,7 +187,7 @@ func (p *Plugin) grokBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's ~/.grok/config.toml (or default behavior).
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -271,20 +198,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*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:
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
// Package hooksjson implements the matcher-group hooks file that several agents
|
||||
// (claude-code, goose, qwen, agy, droid) share byte-for-byte in shape. Each such
|
||||
// file is a JSON object with a "hooks" sub-map keyed by native event name, whose
|
||||
// values are matcher groups ({matcher?, hooks:[{type,command,timeout}]}). The
|
||||
// adapters differed only in the file path, the AO command prefix, the per-hook
|
||||
// timeout, and which events they install, so they describe those with a Manager
|
||||
// and share the install/uninstall/detect logic here.
|
||||
//
|
||||
// The read/write path preserves every top-level key and every user-defined hook
|
||||
// AO does not own, and writes atomically, so installing AO's hooks never clobbers
|
||||
// unrelated settings.
|
||||
package hooksjson
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
)
|
||||
|
||||
// HookEntry is one command hook inside a matcher group.
|
||||
type HookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// MatcherGroup is a set of hooks sharing one matcher. Matcher is a pointer so it
|
||||
// round-trips exactly: events that require a matcher (e.g. claude SessionStart's
|
||||
// "startup") carry one; events that omit it serialize without the key.
|
||||
type MatcherGroup struct {
|
||||
Matcher *string `json:"matcher,omitempty"`
|
||||
Hooks []HookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
// HookSpec describes one hook AO installs: the native event it attaches to, its
|
||||
// optional matcher, and the command to run. Adapters define these in code rather
|
||||
// than reading an embedded template.
|
||||
type HookSpec struct {
|
||||
Event string
|
||||
Matcher *string
|
||||
Command string
|
||||
}
|
||||
|
||||
// Manager installs, removes, and detects AO's hooks in one agent's matcher-group
|
||||
// hooks file. Construct one per adapter with its file path, command prefix,
|
||||
// per-hook timeout, and managed hook set.
|
||||
type Manager struct {
|
||||
// Label prefixes error messages, e.g. "claude-code" or "goose", so the
|
||||
// wrapped error reads "<label>.GetAgentHooks: ...".
|
||||
Label string
|
||||
// CommandPrefix identifies AO-owned hook commands, e.g. "ao hooks goose ".
|
||||
// Install skips commands already present and uninstall/detect match on it.
|
||||
CommandPrefix string
|
||||
// Timeout is written into each installed hook entry.
|
||||
Timeout int
|
||||
// Path returns the hooks file path for a workspace.
|
||||
Path func(workspacePath string) string
|
||||
// Managed is the set of hooks AO installs.
|
||||
Managed []HookSpec
|
||||
}
|
||||
|
||||
// Install merges AO's managed hooks into the workspace's hooks file, preserving
|
||||
// user-defined hooks and unrelated settings, and is idempotent (a command
|
||||
// already present is not appended). It also writes a self-ignoring .gitignore
|
||||
// covering the hooks file so it does not block worktree teardown.
|
||||
func (m Manager) Install(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return fmt.Errorf("%s.GetAgentHooks: WorkspacePath is required", m.Label)
|
||||
}
|
||||
|
||||
hooksPath := m.Path(workspacePath)
|
||||
topLevel, rawHooks, err := readHooksFile(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
|
||||
for event, specs := range m.groupByEvent() {
|
||||
var groups []MatcherGroup
|
||||
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !commandExists(groups, spec.Command) {
|
||||
entry := HookEntry{Type: "command", Command: spec.Command, Timeout: m.Timeout}
|
||||
groups = addHook(groups, entry, spec.Matcher)
|
||||
}
|
||||
}
|
||||
if err := marshalEvent(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), filepath.Base(hooksPath)); err != nil {
|
||||
return fmt.Errorf("%s.GetAgentHooks: gitignore: %w", m.Label, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall removes AO's hooks from the workspace's hooks file, leaving
|
||||
// user-defined hooks and unrelated settings untouched. A missing file is a no-op.
|
||||
func (m Manager) Uninstall(ctx context.Context, workspacePath string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return fmt.Errorf("%s.UninstallHooks: workspacePath is required", m.Label)
|
||||
}
|
||||
|
||||
hooksPath := m.Path(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readHooksFile(hooksPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
|
||||
for _, event := range m.managedEvents() {
|
||||
var groups []MatcherGroup
|
||||
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
groups = removeManaged(groups, m.CommandPrefix)
|
||||
if err := marshalEvent(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreInstalled reports whether any AO hook is present in the workspace's hooks
|
||||
// file. A missing file means none are installed.
|
||||
func (m Manager) AreInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(workspacePath) == "" {
|
||||
return false, fmt.Errorf("%s.AreHooksInstalled: workspacePath is required", m.Label)
|
||||
}
|
||||
|
||||
hooksPath := m.Path(workspacePath)
|
||||
if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readHooksFile(hooksPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
|
||||
}
|
||||
|
||||
for _, event := range m.managedEvents() {
|
||||
var groups []MatcherGroup
|
||||
if err := parseEvent(rawHooks, event, &groups); err != nil {
|
||||
return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if strings.HasPrefix(hook.Command, m.CommandPrefix) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// groupByEvent groups the managed specs by event so each event array is
|
||||
// rewritten once.
|
||||
func (m Manager) groupByEvent() map[string][]HookSpec {
|
||||
byEvent := map[string][]HookSpec{}
|
||||
for _, spec := range m.Managed {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
// managedEvents returns the distinct managed events, in first-seen order.
|
||||
func (m Manager) managedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(m.Managed))
|
||||
for _, spec := range m.Managed {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// readHooksFile loads the 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 readHooksFile(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
|
||||
}
|
||||
|
||||
// writeHooksFile folds rawHooks back into topLevel and writes the file
|
||||
// atomically. An empty hooks map drops the "hooks" key entirely.
|
||||
func writeHooksFile(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 parseEvent(rawHooks map[string]json.RawMessage, event string, target *[]MatcherGroup) 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 marshalEvent(rawHooks map[string]json.RawMessage, event string, groups []MatcherGroup) 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 commandExists(groups []MatcherGroup, command string) bool {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addHook appends hook to the group with a matching matcher, creating that group
|
||||
// if none matches.
|
||||
func addHook(groups []MatcherGroup, hook HookEntry, matcher *string) []MatcherGroup {
|
||||
for i, group := range groups {
|
||||
if matchersEqual(group.Matcher, matcher) {
|
||||
groups[i].Hooks = append(groups[i].Hooks, hook)
|
||||
return groups
|
||||
}
|
||||
}
|
||||
return append(groups, MatcherGroup{Matcher: matcher, Hooks: []HookEntry{hook}})
|
||||
}
|
||||
|
||||
// removeManaged strips AO hook entries (matched by command prefix) from every
|
||||
// group, dropping any group left without hooks so the event array doesn't
|
||||
// accumulate empty matcher objects.
|
||||
func removeManaged(groups []MatcherGroup, prefix string) []MatcherGroup {
|
||||
result := make([]MatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]HookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !strings.HasPrefix(hook.Command, prefix) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func matchersEqual(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
|
@ -53,6 +53,14 @@ func EnsureWorkspaceGitignore(dir string, names ...string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// FileExists reports whether path names an existing regular file (not a
|
||||
// directory). Adapters use it when probing well-known install locations for an
|
||||
// agent binary.
|
||||
func FileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// 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 the agent then fails to parse (silently disabling hooks).
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
package kilocode
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Kilo Code plugin 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 the installed plugin shells via
|
||||
// `ao hooks kilocode <event>` (see kilocodeManagedEvents in hooks.go), not a
|
||||
// native Kilo event name. The plugin reports:
|
||||
// - "session-start" → a Kilo session was created (turn begins).
|
||||
// - "user-prompt-submit" → the user submitted a prompt (turn begins).
|
||||
// - "permission-request" → Kilo is asking the user to approve a tool call.
|
||||
// - "stop" → the current turn went idle/finished.
|
||||
//
|
||||
// Kilo has no native session/process-end plugin event the adapter maps to
|
||||
// ActivityExited, 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
|
||||
}
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
|
|||
if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil {
|
||||
return fmt.Errorf("kilocode.GetAgentHooks: create plugin dir: %w", err)
|
||||
}
|
||||
if err := atomicWriteFile(pluginPath, []byte(kilocodePluginSource), 0o600); err != nil {
|
||||
if err := hookutil.AtomicWriteFile(pluginPath, []byte(kilocodePluginSource), 0o600); err != nil {
|
||||
return fmt.Errorf("kilocode.GetAgentHooks: write plugin: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(pluginPath), kilocodePluginFileName); err != nil {
|
||||
|
|
@ -160,31 +160,3 @@ func isAOManagedPlugin(path string) (bool, error) {
|
|||
}
|
||||
return strings.Contains(string(data), kilocodePluginSentinel), nil
|
||||
}
|
||||
|
||||
// atomicWriteFile writes data to path via a temp file + rename, so a crash mid-
|
||||
// write can't leave a truncated plugin file that Kilo then fails to import
|
||||
// (silently disabling activity reporting).
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,12 @@ package kilocode
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -40,18 +37,12 @@ const (
|
|||
// adapterID is the registry id and the value users pass to
|
||||
// `ao spawn --agent`. It matches domain.HarnessKilocode.
|
||||
adapterID = "kilocode"
|
||||
|
||||
// Normalized session-metadata keys the Kilo plugin persists into the AO
|
||||
// session store and SessionInfo reads back. Shared vocabulary with the Codex
|
||||
// and opencode adapters so the dashboard treats every agent uniformly. The
|
||||
// agent-session-id key is the shared ports.MetadataKeyAgentSessionID.
|
||||
kilocodeTitleMetadataKey = "title"
|
||||
kilocodeSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Kilo Code agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -77,16 +68,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Kilo Code exposes none
|
||||
// yet: model and agent selection are read from Kilo's own config
|
||||
// (kilo.json / ~/.config/kilo), exactly as a normal launch.
|
||||
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 Kilo Code session.
|
||||
// Shape:
|
||||
//
|
||||
|
|
@ -112,15 +93,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Kilo Code receives its prompt in the
|
||||
// launch command itself (via --prompt).
|
||||
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 Kilo Code
|
||||
// session: `[env KILO_CONFIG_CONTENT=<json>] kilocode --session <agentSessionId>`.
|
||||
// It re-applies the permission env (resume otherwise reverts to the configured
|
||||
|
|
@ -152,15 +124,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[kilocodeTitleMetadataKey],
|
||||
Summary: session.Metadata[kilocodeSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// kilocodePermissionEnvVar is the env var Kilo deep-merges as the
|
||||
|
|
@ -176,15 +141,15 @@ const kilocodePermissionEnvVar = "KILO_CONFIG_CONTENT"
|
|||
// config (tool -> action, values "ask"/"allow"/"deny", verified via
|
||||
// `kilocode config check`). Tools left unset fall back to Kilo's own default
|
||||
// action ("ask"), so each mode only names the tools it relaxes:
|
||||
// - default → nil: no env; Kilo's config decides every prompt.
|
||||
// - accept-edits → edits ("write"/"edit"/"patch" gate on the "edit"
|
||||
// - default -> nil: no env; Kilo's config decides every prompt.
|
||||
// - accept-edits -> edits ("write"/"edit"/"patch" gate on the "edit"
|
||||
// key) auto-approved; bash and everything else still prompt.
|
||||
// - auto → edits + bash auto-approved; network/other still prompt.
|
||||
// - auto -> edits + bash auto-approved; network/other still prompt.
|
||||
// Kilo has no classifier/reviewer gate (unlike Claude Code's "auto"), so
|
||||
// this is the closest analog its flat allow/ask/deny config can express.
|
||||
// - bypass-permissions → "*" wildcard-allows every tool: nothing prompts.
|
||||
// - bypass-permissions -> "*" wildcard-allows every tool: nothing prompts.
|
||||
func kilocodePermissionConfig(mode ports.PermissionMode) map[string]string {
|
||||
switch normalizePermissionMode(mode) {
|
||||
switch ports.NormalizePermissionMode(mode) {
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
return map[string]string{"edit": "allow"}
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
@ -224,81 +189,22 @@ func kilocodePermissionEnvPrefix(mode ports.PermissionMode) []string {
|
|||
return []string{"env", kilocodePermissionEnvVar + "=" + string(blob)}
|
||||
}
|
||||
|
||||
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 Kilo's own config (no flag).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
var kilocodeBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "kilocode",
|
||||
Names: []string{"kilocode"},
|
||||
WinNames: []string{"kilocode.cmd", "kilocode.exe", "kilocode"},
|
||||
UnixPaths: []string{"/usr/local/bin/kilocode", "/opt/homebrew/bin/kilocode"},
|
||||
UnixHomePaths: [][]string{{".npm-global", "bin", "kilocode"}, {".npm", "bin", "kilocode"}, {".local", "bin", "kilocode"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kilocode.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kilocode.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveKilocodeBinary returns the path to the kilocode binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (npm global
|
||||
// bin, Homebrew). Returns "kilocode" as a last-ditch fallback so callers see a
|
||||
// clear "command not found" rather than an empty argv.
|
||||
// ResolveKilocodeBinary returns the path to the kilocode binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveKilocodeBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"kilocode.cmd", "kilocode.exe", "kilocode"} {
|
||||
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", "kilocode.cmd"),
|
||||
filepath.Join(appData, "npm", "kilocode.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("kilocode: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("kilocode"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/kilocode",
|
||||
"/opt/homebrew/bin/kilocode",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".npm-global", "bin", "kilocode"),
|
||||
filepath.Join(home, ".npm", "bin", "kilocode"),
|
||||
filepath.Join(home, ".local", "bin", "kilocode"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("kilocode: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, kilocodeBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) kilocodeBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -316,8 +222,3 @@ func (p *Plugin) kilocodeBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
|
@ -91,7 +92,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kilocode"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -103,7 +104,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kilocode"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -347,8 +348,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "ses_abc123",
|
||||
kilocodeTitleMetadataKey: "Fix login redirect",
|
||||
kilocodeSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -405,9 +406,9 @@ func TestDeriveActivityState(t *testing.T) {
|
|||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.event, func(t *testing.T) {
|
||||
state, ok := DeriveActivityState(tc.event, nil)
|
||||
state, ok := activitystate.StandardDeriveActivityState(tc.event, nil)
|
||||
if state != tc.wantState || ok != tc.wantOK {
|
||||
t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tc.event, state, ok, tc.wantState, tc.wantOK)
|
||||
t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)", tc.event, state, ok, tc.wantState, tc.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,15 +17,12 @@ package kimi
|
|||
|
||||
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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -34,6 +31,7 @@ const adapterID = "kimi"
|
|||
// Plugin is the Kimi CLI agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -59,14 +57,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 Kimi session:
|
||||
//
|
||||
// kimi -p <prompt> (non-interactive, default)
|
||||
|
|
@ -74,7 +64,7 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
|
|||
//
|
||||
// When a prompt is supplied, it is delivered via `-p` (in command), which runs
|
||||
// a single prompt without opening the TUI. Per Kimi docs, `--prompt` cannot be
|
||||
// combined with `--yolo`, `--auto`, or `--plan` — non-interactive mode already
|
||||
// combined with `--yolo`, `--auto`, or `--plan` -- non-interactive mode already
|
||||
// uses the `auto` permission policy by default, so approval flags would be
|
||||
// rejected at startup. They are only emitted on the (interactive) path with no
|
||||
// prompt. Kimi has no documented system-prompt flag, so cfg.SystemPrompt /
|
||||
|
|
@ -96,21 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Kimi 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: Kimi CLI exposes no native hook system
|
||||
// and is not documented as Claude Code hook-compatible.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Kimi session
|
||||
// when a native Kimi session id is known:
|
||||
//
|
||||
|
|
@ -118,8 +93,8 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
|
|||
//
|
||||
// ok is false when no native session id has been captured, so callers fall back
|
||||
// to fresh launch behavior. Per Kimi docs, `--yolo` and `--auto` cannot be
|
||||
// combined with `--session` (or `--continue`) — resumed sessions inherit the
|
||||
// approval settings of the original session — so cfg.Permissions is
|
||||
// combined with `--session` (or `--continue`) -- resumed sessions inherit the
|
||||
// approval settings of the original session -- so cfg.Permissions is
|
||||
// intentionally ignored here. Kimi has no lifecycle hook for AO to capture the
|
||||
// native session id from yet, so in practice this returns ok=false today.
|
||||
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
|
||||
|
|
@ -139,15 +114,6 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo is intentionally a no-op until Kimi exposes a way to capture its
|
||||
// native session id and display 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
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's permission modes onto Kimi's approval flags
|
||||
// for interactive launches. Per Kimi docs these flags cannot be combined with
|
||||
// `--prompt`, `--session`, or `--continue`, so callers on those paths must
|
||||
|
|
@ -181,72 +147,25 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode {
|
|||
}
|
||||
}
|
||||
|
||||
var kimiBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "kimi",
|
||||
Names: []string{"kimi"},
|
||||
WinNames: []string{"kimi.cmd", "kimi.exe", "kimi"},
|
||||
UnixPaths: []string{"/usr/local/bin/kimi", "/opt/homebrew/bin/kimi"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "kimi"}, {".cargo", "bin", "kimi"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".local", "bin", "kimi.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveKimiBinary finds the `kimi` binary, searching PATH then common install
|
||||
// locations (the uv tool/curl installer drops it in ~/.local/bin, plus Homebrew
|
||||
// and ~/.cargo/bin). It returns "kimi" as a last resort so callers get the
|
||||
// shell's normal command-not-found behavior if Kimi is absent.
|
||||
func ResolveKimiBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"kimi.cmd", "kimi.exe", "kimi"} {
|
||||
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", "kimi.cmd"),
|
||||
filepath.Join(appData, "npm", "kimi.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "kimi.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("kimi: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("kimi"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/kimi",
|
||||
"/opt/homebrew/bin/kimi",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "kimi"),
|
||||
filepath.Join(home, ".cargo", "bin", "kimi"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("kimi: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, kimiBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) kimiBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -264,8 +183,3 @@ func (p *Plugin) kimiBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
package kiro
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Kiro 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 kiroManagedHooks
|
||||
// ("session-start", "user-prompt-submit", "permission-request", "stop"), not
|
||||
// the native Kiro event name (agentSpawn/userPromptSubmit/preToolUse/stop).
|
||||
// Kiro currently has no session/process-end hook in the adapter, so runtime
|
||||
// exit still falls back to the lifecycle reaper.
|
||||
//
|
||||
// TODO(kiro): ActivityExited is still runtime-observation-owned. If Kiro 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 Kiro 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
|
||||
}
|
||||
}
|
||||
|
|
@ -27,13 +27,18 @@ const (
|
|||
// skips duplicates and uninstall recognizes AO entries by prefix without an
|
||||
// embedded template to diff against.
|
||||
kiroHookCommandPrefix = "ao hooks kiro "
|
||||
|
||||
kiroAgentName = "ao"
|
||||
kiroAgentDescription = "Agent Orchestrator session instructions"
|
||||
)
|
||||
|
||||
// kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by
|
||||
// tests to decode the written file. Kiro hooks are a map of camelCase event
|
||||
// name to a flat array of {matcher?, command} entries.
|
||||
type kiroHookFile struct {
|
||||
Hooks map[string][]kiroHookEntry `json:"hooks"`
|
||||
Name string `json:"name"`
|
||||
Prompt *string `json:"prompt"`
|
||||
Hooks map[string][]kiroHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type kiroHookEntry struct {
|
||||
|
|
@ -97,7 +102,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
|
|||
}
|
||||
}
|
||||
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, cfg.SystemPrompt, cfg.Config); err != nil {
|
||||
return fmt.Errorf("kiro.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), kiroAgentFileName); err != nil {
|
||||
|
|
@ -137,7 +142,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error
|
|||
}
|
||||
}
|
||||
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil {
|
||||
if err := writeKiroHooks(hooksPath, topLevel, rawHooks, "", ports.AgentConfig{}); err != nil {
|
||||
return fmt.Errorf("kiro.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -210,7 +215,11 @@ func readKiroHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMess
|
|||
|
||||
// writeKiroHooks folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error {
|
||||
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
|
||||
if err := setKiroAgentDefaults(topLevel, systemPrompt, agentConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(rawHooks) == 0 {
|
||||
delete(topLevel, "hooks")
|
||||
} else {
|
||||
|
|
@ -229,33 +238,48 @@ func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMess
|
|||
return fmt.Errorf("encode %s: %w", hooksPath, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := atomicWriteFile(hooksPath, data, 0o600); err != nil {
|
||||
if err := hookutil.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 + rename, so a crash mid-
|
||||
// write can't leave a truncated/empty file that Kiro 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
|
||||
func setKiroAgentDefaults(topLevel map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
|
||||
defaults := map[string]any{
|
||||
"name": kiroAgentName,
|
||||
"description": kiroAgentDescription,
|
||||
"prompt": nil,
|
||||
"mcpServers": map[string]any{},
|
||||
"tools": []string{"*"},
|
||||
"toolAliases": map[string]any{},
|
||||
"allowedTools": []any{},
|
||||
"resources": []any{},
|
||||
"toolsSettings": map[string]any{},
|
||||
"includeMcpJson": true,
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
if systemPrompt != "" {
|
||||
defaults["prompt"] = systemPrompt
|
||||
}
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
if model := strings.TrimSpace(agentConfig.Model); model != "" {
|
||||
defaults["model"] = model
|
||||
} else {
|
||||
delete(topLevel, "model")
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
|
||||
for key, value := range defaults {
|
||||
managedKey := key == "name" || key == "prompt" || key == "model"
|
||||
if !managedKey {
|
||||
if _, ok := topLevel[key]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode agent %s: %w", key, err)
|
||||
}
|
||||
topLevel[key] = data
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupKiroHooksByEvent groups the managed hook specs by their Kiro event so
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
// and reading hook-derived session info.
|
||||
//
|
||||
// Kiro is AWS's agentic coding assistant. Its terminal CLI ships as the
|
||||
// `kiro-cli` binary and exposes a non-interactive ("headless") mode via
|
||||
// `kiro-cli chat --no-interactive "<prompt>"`, suitable for AO-driven worker
|
||||
// sessions. See https://kiro.dev/docs/cli/headless/ and
|
||||
// `kiro-cli` binary. AO launches Kiro with a workspace-local custom agent so
|
||||
// both worker and orchestrator sessions can use Kiro's normal interactive
|
||||
// approval flow. See https://kiro.dev/docs/cli/headless/ and
|
||||
// https://kiro.dev/docs/cli/reference/cli-commands/.
|
||||
//
|
||||
// Launch delivers the initial prompt as a positional argument after `--` so a
|
||||
|
|
@ -15,31 +15,25 @@
|
|||
// captured from a Kiro hook payload.
|
||||
//
|
||||
// AO-managed sessions derive native session identity and display metadata from
|
||||
// Kiro's native hooks (see hooks.go / activity.go) rather than transcript scans.
|
||||
// Kiro's native hooks (see hooks.go) rather than transcript scans.
|
||||
package kiro
|
||||
|
||||
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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
kiroTitleMetadataKey = "title"
|
||||
kiroSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Kiro agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -65,46 +59,47 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Kiro 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 Kiro session:
|
||||
// `kiro-cli chat --no-interactive [trust flags] -- <prompt>`.
|
||||
// GetLaunchCommand builds the argv to start a new Kiro session:
|
||||
// `kiro-cli chat --agent ao [trust flags] [-- <prompt>]`.
|
||||
//
|
||||
// The prompt is passed as a positional argument after `--` so a leading "-" is
|
||||
// not read as a flag. Kiro's --no-interactive mode requires a prompt argument.
|
||||
// not read as a flag. Kiro runs interactively for both workers and orchestrators;
|
||||
// standing instructions come from the generated custom agent.
|
||||
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
|
||||
binary, err := p.kiroBinary(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd = []string{binary, "chat", "--no-interactive"}
|
||||
cmd = []string{binary, "chat", "--agent", kiroAgentName}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
|
||||
if cfg.Prompt != "" {
|
||||
cmd = append(cmd, "--", cfg.Prompt)
|
||||
prompt := cfg.Prompt
|
||||
if prompt != "" {
|
||||
cmd = append(cmd, "--", prompt)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Kiro receives its prompt in the launch
|
||||
// command itself.
|
||||
// GetPromptDeliveryStrategy reports how Kiro receives the initial task prompt.
|
||||
// Orchestrator standing instructions are delivered through the generated
|
||||
// custom-agent prompt, so no command or post-start prompt injection is needed
|
||||
// there.
|
||||
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.Prompt != "" {
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
if cfg.Kind == domain.KindOrchestrator {
|
||||
return ports.PromptDeliveryCustomAgent, nil
|
||||
}
|
||||
return ports.PromptDeliveryInCommand, nil
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session:
|
||||
// `kiro-cli chat --no-interactive --resume-id <agentSessionId> [trust flags]`.
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session.
|
||||
// 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) {
|
||||
|
|
@ -121,8 +116,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
return nil, false, err
|
||||
}
|
||||
|
||||
cmd = make([]string, 0, 8)
|
||||
cmd = append(cmd, binary, "chat", "--no-interactive", "--resume-id", agentSessionID)
|
||||
cmd = []string{binary, "chat", "--agent", kiroAgentName, "--resume-id", agentSessionID}
|
||||
appendApprovalFlags(&cmd, cfg.Permissions)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
|
@ -133,91 +127,31 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[kiroTitleMetadataKey],
|
||||
Summary: session.Metadata[kiroSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
var kiroBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "kiro",
|
||||
Names: []string{"kiro-cli"},
|
||||
WinNames: []string{"kiro-cli.cmd", "kiro-cli.exe", "kiro-cli"},
|
||||
UnixPaths: []string{"/usr/local/bin/kiro-cli", "/opt/homebrew/bin/kiro-cli"},
|
||||
UnixHomePaths: [][]string{{".kiro", "bin", "kiro-cli"}, {".local", "bin", "kiro-cli"}},
|
||||
// The native Kiro installer location is probed before the npm shim, matching
|
||||
// the pre-refactor order so a native install still wins when both are present.
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "kiro", "kiro-cli.exe"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kiro-cli.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "kiro-cli.exe"}},
|
||||
{Base: binaryutil.WinHome, Parts: []string{".kiro", "bin", "kiro-cli.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveKiroBinary returns the path to the kiro-cli binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations. Returns
|
||||
// "kiro-cli" as a last-ditch fallback so callers see a clear "command not
|
||||
// found" rather than an empty argv.
|
||||
// searching PATH then a handful of well-known install locations. It returns a
|
||||
// wrapped ports.ErrAgentBinaryNotFound when kiro-cli is absent.
|
||||
func ResolveKiroBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"kiro-cli.cmd", "kiro-cli.exe", "kiro-cli"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
candidates := []string{}
|
||||
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(localAppData, "Programs", "kiro", "kiro-cli.exe"),
|
||||
)
|
||||
}
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "npm", "kiro-cli.cmd"),
|
||||
filepath.Join(appData, "npm", "kiro-cli.exe"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".kiro", "bin", "kiro-cli.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("kiro: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("kiro-cli"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/kiro-cli",
|
||||
"/opt/homebrew/bin/kiro-cli",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".kiro", "bin", "kiro-cli"),
|
||||
filepath.Join(home, ".local", "bin", "kiro-cli"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("kiro: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, kiroBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) kiroBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -236,14 +170,14 @@ func (p *Plugin) kiroBinary(ctx context.Context) (string, error) {
|
|||
return binary, nil
|
||||
}
|
||||
|
||||
// appendApprovalFlags maps AO's 4 permission modes onto Kiro's tool-trust
|
||||
// flags. Default emits no flag so Kiro defers to the user's own configuration
|
||||
// (the interactive per-tool prompt). accept-edits grants the write-capable
|
||||
// built-in tools; auto/bypass grant all tools.
|
||||
// appendApprovalFlags maps AO's permission modes onto Kiro's tool-trust flags.
|
||||
// Default emits no flag so Kiro uses its normal interactive approval flow.
|
||||
// accept-edits grants the write-capable built-in tools; auto/bypass grant all
|
||||
// tools.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Kiro config / per-tool prompting.
|
||||
// No flag: defer to Kiro's normal interactive approval flow.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
*cmd = append(*cmd, "--trust-tools=fs_read,fs_write")
|
||||
case ports.PermissionModeAuto:
|
||||
|
|
@ -252,20 +186,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*cmd = append(*cmd, "--trust-all-tools")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func TestManifestIDIsKiro(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
|
||||
func TestGetLaunchCommandBuildsInteractiveArgv(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
|
|
@ -40,7 +40,8 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
|
|||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat", "--no-interactive",
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--trust-all-tools",
|
||||
"--", "-fix this",
|
||||
}
|
||||
|
|
@ -49,6 +50,64 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Kind: domain.KindOrchestrator,
|
||||
SystemPrompt: "You are the human-facing coordinator.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandPromptlessWorkerStaysInteractive(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLaunchCommandPromptTakesPrecedenceOverSystemPrompt(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
|
||||
Prompt: "fix the failing test",
|
||||
SystemPrompt: "standing role instructions",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--", "fix the failing test",
|
||||
}
|
||||
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
|
||||
|
|
@ -114,7 +173,7 @@ func TestGetLaunchCommandCtxCancelled(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -125,9 +184,36 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
func TestGetPromptDeliveryStrategyOrchestratorUsesCustomAgent(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{Kind: domain.KindOrchestrator})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryCustomAgent {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyPromptedOrchestratorUsesCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{
|
||||
Kind: domain.KindOrchestrator,
|
||||
Prompt: "do this explicit task",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ports.PromptDeliveryInCommand {
|
||||
t.Fatalf("unexpected strategy: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -183,7 +269,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
hooksPath := filepath.Join(hooksDir, kiroAgentFileName)
|
||||
existing := `{"name":"ao","hooks":{"stop":[{"command":"custom stop hook"}]}}`
|
||||
existing := `{"name":"stale","hooks":{"stop":[{"command":"custom stop hook"}]}}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -191,6 +277,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
WorkspacePath: workspace,
|
||||
}
|
||||
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
|
||||
|
|
@ -205,13 +292,23 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The unmanaged top-level "name" key must be preserved.
|
||||
var topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := topLevel["name"]; !ok {
|
||||
t.Fatalf("unmanaged top-level key 'name' was dropped: %s", data)
|
||||
var name string
|
||||
if err := json.Unmarshal(topLevel["name"], &name); err != nil {
|
||||
t.Fatalf("decode name from %s: %v", data, err)
|
||||
}
|
||||
if name != kiroAgentName {
|
||||
t.Fatalf("name = %q, want %q", name, kiroAgentName)
|
||||
}
|
||||
var prompt string
|
||||
if err := json.Unmarshal(topLevel["prompt"], &prompt); err != nil {
|
||||
t.Fatalf("decode prompt from %s: %v", data, err)
|
||||
}
|
||||
if prompt != "standing AO instructions" {
|
||||
t.Fatalf("prompt = %q, want system prompt", prompt)
|
||||
}
|
||||
|
||||
var config kiroHookFile
|
||||
|
|
@ -233,6 +330,167 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksCreatesNamedKiroAgentFile(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "exact orchestrator system prompt",
|
||||
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 topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var name string
|
||||
if err := json.Unmarshal(topLevel["name"], &name); err != nil {
|
||||
t.Fatalf("decode name from %s: %v", data, err)
|
||||
}
|
||||
if name != kiroAgentName {
|
||||
t.Fatalf("name = %q, want %q", name, kiroAgentName)
|
||||
}
|
||||
var config kiroHookFile
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Prompt == nil || *config.Prompt != "exact orchestrator system prompt" {
|
||||
t.Fatalf("prompt = %#v, want exact system prompt", config.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksWritesConfiguredModel(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
Config: ports.AgentConfig{Model: "claude-sonnet-4-5"},
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
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 topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var model string
|
||||
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
|
||||
t.Fatalf("decode model from %s: %v", data, err)
|
||||
}
|
||||
if model != "claude-sonnet-4-5" {
|
||||
t.Fatalf("model = %q, want configured model", model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksOverwritesStaleConfiguredModel(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
Config: ports.AgentConfig{Model: "project-model"},
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
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 topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var model string
|
||||
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
|
||||
t.Fatalf("decode model from %s: %v", data, err)
|
||||
}
|
||||
if model != "project-model" {
|
||||
t.Fatalf("model = %q, want project override", model)
|
||||
}
|
||||
var tools []string
|
||||
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
|
||||
t.Fatalf("decode tools from %s: %v", data, err)
|
||||
}
|
||||
if !reflect.DeepEqual(tools, []string{"custom"}) {
|
||||
t.Fatalf("tools = %#v, want preserved custom tools", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentHooksClearsStaleModelWhenConfigRemoved(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
hooksPath := kiroAgentPath(workspace)
|
||||
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
|
||||
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := ports.WorkspaceHookConfig{
|
||||
DataDir: t.TempDir(),
|
||||
SessionID: "sess-1",
|
||||
SystemPrompt: "standing AO instructions",
|
||||
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 topLevel map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &topLevel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := topLevel["model"]; ok {
|
||||
t.Fatalf("model still present in %s, want cleared when config has no model", data)
|
||||
}
|
||||
var tools []string
|
||||
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
|
||||
t.Fatalf("decode tools from %s: %v", data, err)
|
||||
}
|
||||
if !reflect.DeepEqual(tools, []string{"custom"}) {
|
||||
t.Fatalf("tools = %#v, want preserved custom tools", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallHooksRemovesKiroHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
workspace := t.TempDir()
|
||||
|
|
@ -310,7 +568,8 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
|||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
want := []string{
|
||||
"kiro-cli", "chat", "--no-interactive",
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--resume-id", "uuid-123",
|
||||
"--trust-all-tools",
|
||||
}
|
||||
|
|
@ -319,6 +578,32 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "kiro-cli"}
|
||||
|
||||
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Kind: domain.KindOrchestrator,
|
||||
Permissions: ports.PermissionModeDefault,
|
||||
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{
|
||||
"kiro-cli", "chat",
|
||||
"--agent", "ao",
|
||||
"--resume-id", "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: "kiro-cli"}
|
||||
|
||||
|
|
@ -357,8 +642,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "uuid-123",
|
||||
kiroTitleMetadataKey: "Fix login redirect",
|
||||
kiroSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -400,29 +685,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 TestResolveKiroBinaryFallback(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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
|
||||
_ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes
|
||||
|
|
@ -39,17 +41,18 @@ const (
|
|||
// `ao spawn --agent`. It matches domain.HarnessOpenCode.
|
||||
adapterID = "opencode"
|
||||
|
||||
// Normalized session-metadata keys the opencode plugin persists into the AO
|
||||
// session store and SessionInfo reads back. Shared vocabulary with the Codex
|
||||
// and Claude Code adapters so the dashboard treats every agent uniformly.
|
||||
// opencodeAgentSessionIDMetadataKey is the session-metadata key the opencode
|
||||
// plugin persists the native session id under. GetRestoreCommand reads it back
|
||||
// to resume an existing session. SessionInfo delegates to
|
||||
// agentbase.StandardSessionInfo which reads ports.MetadataKeyAgentSessionID
|
||||
// (same value), but GetRestoreCommand reads it directly, so the const stays.
|
||||
opencodeAgentSessionIDMetadataKey = "agentSessionId"
|
||||
opencodeTitleMetadataKey = "title"
|
||||
opencodeSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the opencode agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -76,16 +79,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. opencode exposes none
|
||||
// yet: model and agent selection are read from opencode's own config
|
||||
// (opencode.json / ~/.config/opencode), exactly as a normal launch.
|
||||
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 opencode session.
|
||||
// Shape:
|
||||
//
|
||||
|
|
@ -111,15 +104,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that opencode receives its prompt in the
|
||||
// launch command itself (via --prompt).
|
||||
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 opencode
|
||||
// session: `opencode [--dangerously-skip-permissions] --session <agentSessionId>`.
|
||||
// It re-applies the permission flag (resume otherwise reverts to the configured
|
||||
|
|
@ -154,15 +138,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[opencodeAgentSessionIDMetadataKey],
|
||||
Title: session.Metadata[opencodeTitleMetadataKey],
|
||||
Summary: session.Metadata[opencodeSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// AuthStatus checks whether opencode has at least one configured provider
|
||||
|
|
@ -353,24 +330,11 @@ func opencodeDBCount(ctx context.Context, db *sql.DB, query string) (int, error)
|
|||
// - default / accept-edits / auto → no flag. opencode resolves approvals from
|
||||
// its own `permission` config exactly as a normal launch.
|
||||
func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
if normalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions {
|
||||
if ports.NormalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions {
|
||||
*cmd = append(*cmd, "--dangerously-skip-permissions")
|
||||
}
|
||||
}
|
||||
|
||||
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 opencode's own config (no flag).
|
||||
return ports.PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveOpenCodeBinary returns the path to the opencode binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (the install
|
||||
// script's ~/.opencode/bin, Homebrew, npm global).
|
||||
|
|
@ -393,7 +357,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
|
|||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -416,7 +380,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
if hookutil.FileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
|
@ -442,8 +406,3 @@ func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "opencode"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -356,7 +356,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "opencode"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -599,8 +599,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
opencodeAgentSessionIDMetadataKey: "ses_abc123",
|
||||
opencodeTitleMetadataKey: "Fix login redirect",
|
||||
opencodeSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
// variant), so a system-prompt file is read from disk and its contents are
|
||||
// inlined into the flag; a read failure aborts the launch.
|
||||
//
|
||||
// Permissions: Pi has no permission/approval CLI flags ("No permission popups" —
|
||||
// Permissions: Pi has no permission/approval CLI flags ("No permission popups" --
|
||||
// confirmation flows are built via TypeScript extensions), so AO emits no
|
||||
// permission flag and defers to Pi's own behavior.
|
||||
//
|
||||
|
|
@ -33,15 +33,13 @@ package pi
|
|||
|
||||
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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -50,6 +48,7 @@ const adapterID = "pi"
|
|||
// Plugin is the Pi agent adapter. It is safe for concurrent use; the binary
|
||||
// path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -75,14 +74,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 Pi session:
|
||||
//
|
||||
// pi --print [--append-system-prompt <system prompt>] [<prompt>]
|
||||
|
|
@ -112,22 +103,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Pi 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: Pi's lifecycle hooks are only
|
||||
// reachable through in-process TypeScript extensions, not a config file AO can
|
||||
// install, and Pi has no Claude Code hook compatibility.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Pi session when
|
||||
// a native session id is available in metadata. Pi resumes by id with
|
||||
// `--session <id>` (partial UUIDs accepted). Until that id exists, ok is false
|
||||
|
|
@ -149,77 +124,23 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo is intentionally a no-op until a Pi-specific extension persists
|
||||
// session metadata (title/summary). The native session id, when known, is read
|
||||
// directly from metadata by GetRestoreCommand.
|
||||
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
|
||||
var piBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "pi",
|
||||
Names: []string{"pi"},
|
||||
WinNames: []string{"pi.cmd", "pi.exe", "pi"},
|
||||
UnixPaths: []string{"/usr/local/bin/pi", "/opt/homebrew/bin/pi"},
|
||||
UnixHomePaths: [][]string{{".npm-global", "bin", "pi"}, {".local", "bin", "pi"}, {".pi", "bin", "pi"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolvePiBinary finds the `pi` binary, searching PATH then common install
|
||||
// locations. It returns "pi" as a last resort so callers get the shell's normal
|
||||
// command-not-found behavior if Pi is absent.
|
||||
func ResolvePiBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"pi.cmd", "pi.exe", "pi"} {
|
||||
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", "pi.cmd"),
|
||||
filepath.Join(appData, "npm", "pi.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("pi: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("pi"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/pi",
|
||||
"/opt/homebrew/bin/pi",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".npm-global", "bin", "pi"),
|
||||
filepath.Join(home, ".local", "bin", "pi"),
|
||||
filepath.Join(home, ".pi", "bin", "pi"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("pi: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, piBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) piBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -237,8 +158,3 @@ func (p *Plugin) piBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
package qwen
|
||||
|
||||
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
|
||||
// DeriveActivityState maps a Qwen Code 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 qwenManagedHooks
|
||||
// ("session-start", "user-prompt-submit", "permission-request", "stop"), not
|
||||
// the native Qwen event name. Qwen Code has no SessionEnd equivalent in the
|
||||
// adapter yet, so runtime exit still falls back to the reaper.
|
||||
//
|
||||
// TODO(qwen): ActivityExited is still runtime-observation-owned. Qwen Code has a
|
||||
// native SessionEnd hook; if AO installs it, map "session-end" to
|
||||
// ActivityExited here. Until then, make sure the lifecycle reaper can still mark
|
||||
// a dead Qwen 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
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,9 @@ package qwen
|
|||
|
||||
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/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -17,10 +12,8 @@ const (
|
|||
qwenSettingsDirName = ".qwen"
|
||||
qwenSettingsFileName = "settings.json"
|
||||
|
||||
// qwenHookCommandPrefix 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.
|
||||
// qwenHookCommandPrefix identifies the hook commands AO owns, so install
|
||||
// skips duplicates and uninstall recognizes AO entries by prefix.
|
||||
qwenHookCommandPrefix = "ao hooks qwen "
|
||||
|
||||
// qwenHookTimeout is in milliseconds: Qwen Code (a gemini-cli fork) measures
|
||||
|
|
@ -28,355 +21,44 @@ const (
|
|||
qwenHookTimeout = 30000
|
||||
)
|
||||
|
||||
// qwenHookFile is the on-disk shape of the "hooks" sub-object of
|
||||
// .qwen/settings.json. It is used by tests to decode the written file.
|
||||
type qwenHookFile struct {
|
||||
Hooks map[string][]qwenMatcherGroup `json:"hooks"`
|
||||
}
|
||||
|
||||
type qwenMatcherGroup struct {
|
||||
// Matcher is a pointer so it round-trips exactly: SessionStart targets the
|
||||
// payload "source" field with a "startup" matcher; UserPromptSubmit/Stop/
|
||||
// PermissionRequest omit it (Qwen ignores the matcher for those events).
|
||||
// omitempty drops a nil matcher on write.
|
||||
Matcher *string `json:"matcher,omitempty"`
|
||||
Hooks []qwenHookEntry `json:"hooks"`
|
||||
}
|
||||
|
||||
type qwenHookEntry struct {
|
||||
Type string `json:"type"`
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// qwenHookSpec describes one hook AO installs, defined in code rather than read
|
||||
// from an embedded settings file.
|
||||
type qwenHookSpec struct {
|
||||
Event string
|
||||
Matcher *string
|
||||
Command string
|
||||
}
|
||||
|
||||
// qwenStartupMatcher is referenced by pointer so SessionStart serializes with
|
||||
// its "startup" source matcher.
|
||||
var qwenStartupMatcher = "startup"
|
||||
|
||||
// qwenManagedHooks is the source of truth for the hooks AO installs:
|
||||
// SessionStart (under the "startup" source matcher), UserPromptSubmit,
|
||||
// PermissionRequest, and Stop. They report normalized session metadata and
|
||||
// activity-state signals back into AO's store (see DeriveActivityState). The
|
||||
// AO sub-command names are FIXED and must match the cases in
|
||||
// DeriveActivityState.
|
||||
var qwenManagedHooks = []qwenHookSpec{
|
||||
// PermissionRequest, and Stop.
|
||||
var qwenManagedHooks = []hooksjson.HookSpec{
|
||||
{Event: "SessionStart", Matcher: &qwenStartupMatcher, Command: qwenHookCommandPrefix + "session-start"},
|
||||
{Event: "UserPromptSubmit", Command: qwenHookCommandPrefix + "user-prompt-submit"},
|
||||
{Event: "PermissionRequest", Command: qwenHookCommandPrefix + "permission-request"},
|
||||
{Event: "Stop", Command: qwenHookCommandPrefix + "stop"},
|
||||
}
|
||||
|
||||
// GetAgentHooks installs AO's Qwen Code hooks into the worktree-local
|
||||
// .qwen/settings.json file (the project-level settings). The hooks
|
||||
// (SessionStart, UserPromptSubmit, PermissionRequest, Stop) report normalized
|
||||
// session metadata and activity 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("qwen.GetAgentHooks: WorkspacePath is required")
|
||||
}
|
||||
|
||||
settingsPath := qwenSettingsPath(cfg.WorkspacePath)
|
||||
topLevel, rawHooks, err := readQwenSettings(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
|
||||
}
|
||||
|
||||
for event, specs := range groupQwenHooksByEvent() {
|
||||
var existingGroups []qwenMatcherGroup
|
||||
if err := parseQwenHookType(rawHooks, event, &existingGroups); err != nil {
|
||||
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !qwenHookCommandExists(existingGroups, spec.Command) {
|
||||
entry := qwenHookEntry{Type: "command", Command: spec.Command, Timeout: qwenHookTimeout}
|
||||
existingGroups = addQwenHook(existingGroups, entry, spec.Matcher)
|
||||
}
|
||||
}
|
||||
if err := marshalQwenHookType(rawHooks, event, existingGroups); err != nil {
|
||||
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("qwen.GetAgentHooks: %w", err)
|
||||
}
|
||||
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), qwenSettingsFileName); err != nil {
|
||||
return fmt.Errorf("qwen.GetAgentHooks: gitignore: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallHooks removes AO's Qwen Code hooks from the workspace-local
|
||||
// .qwen/settings.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("qwen.UninstallHooks: workspacePath is required")
|
||||
}
|
||||
|
||||
settingsPath := qwenSettingsPath(workspacePath)
|
||||
if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
topLevel, rawHooks, err := readQwenSettings(settingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qwen.UninstallHooks: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range qwenManagedEvents() {
|
||||
var groups []qwenMatcherGroup
|
||||
if err := parseQwenHookType(rawHooks, event, &groups); err != nil {
|
||||
return fmt.Errorf("qwen.UninstallHooks: %w", err)
|
||||
}
|
||||
groups = removeQwenManagedHooks(groups)
|
||||
if err := marshalQwenHookType(rawHooks, event, groups); err != nil {
|
||||
return fmt.Errorf("qwen.UninstallHooks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil {
|
||||
return fmt.Errorf("qwen.UninstallHooks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreHooksInstalled reports whether any AO Qwen 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("qwen.AreHooksInstalled: workspacePath is required")
|
||||
}
|
||||
|
||||
settingsPath := qwenSettingsPath(workspacePath)
|
||||
if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
_, rawHooks, err := readQwenSettings(settingsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err)
|
||||
}
|
||||
|
||||
for _, event := range qwenManagedEvents() {
|
||||
var groups []qwenMatcherGroup
|
||||
if err := parseQwenHookType(rawHooks, event, &groups); err != nil {
|
||||
return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if isQwenManagedHook(hook.Command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
// qwenHooks manages AO's hooks in the workspace-local .qwen/settings.json file.
|
||||
var qwenHooks = hooksjson.Manager{
|
||||
Label: "qwen",
|
||||
CommandPrefix: qwenHookCommandPrefix,
|
||||
Timeout: qwenHookTimeout,
|
||||
Path: qwenSettingsPath,
|
||||
Managed: qwenManagedHooks,
|
||||
}
|
||||
|
||||
func qwenSettingsPath(workspacePath string) string {
|
||||
return filepath.Join(workspacePath, qwenSettingsDirName, qwenSettingsFileName)
|
||||
}
|
||||
|
||||
// readQwenSettings 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 readQwenSettings(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
|
||||
// GetAgentHooks installs AO's Qwen Code hooks, preserving user-defined hooks and unrelated settings.
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return qwenHooks.Install(ctx, cfg.WorkspacePath)
|
||||
}
|
||||
|
||||
// writeQwenSettings folds rawHooks back into topLevel and writes the file. An
|
||||
// empty hooks map drops the "hooks" key entirely.
|
||||
func writeQwenSettings(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 := atomicWriteFile(settingsPath, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", settingsPath, err)
|
||||
}
|
||||
return nil
|
||||
// UninstallHooks removes AO's Qwen Code hooks, leaving user-defined hooks untouched.
|
||||
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
|
||||
return qwenHooks.Uninstall(ctx, workspacePath)
|
||||
}
|
||||
|
||||
// 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 Qwen Code 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)
|
||||
}
|
||||
|
||||
// groupQwenHooksByEvent groups the managed hook specs by their Qwen event so
|
||||
// each event's settings array is rewritten once.
|
||||
func groupQwenHooksByEvent() map[string][]qwenHookSpec {
|
||||
byEvent := map[string][]qwenHookSpec{}
|
||||
for _, spec := range qwenManagedHooks {
|
||||
byEvent[spec.Event] = append(byEvent[spec.Event], spec)
|
||||
}
|
||||
return byEvent
|
||||
}
|
||||
|
||||
// qwenManagedEvents returns the distinct Qwen events AO manages, in the order
|
||||
// they first appear in qwenManagedHooks.
|
||||
func qwenManagedEvents() []string {
|
||||
seen := map[string]bool{}
|
||||
events := make([]string, 0, len(qwenManagedHooks))
|
||||
for _, spec := range qwenManagedHooks {
|
||||
if !seen[spec.Event] {
|
||||
seen[spec.Event] = true
|
||||
events = append(events, spec.Event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func isQwenManagedHook(command string) bool {
|
||||
return strings.HasPrefix(command, qwenHookCommandPrefix)
|
||||
}
|
||||
|
||||
// removeQwenManagedHooks strips AO hook entries from every group, dropping any
|
||||
// group left without hooks so the event array doesn't accumulate empty matcher
|
||||
// objects.
|
||||
func removeQwenManagedHooks(groups []qwenMatcherGroup) []qwenMatcherGroup {
|
||||
result := make([]qwenMatcherGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
kept := make([]qwenHookEntry, 0, len(group.Hooks))
|
||||
for _, hook := range group.Hooks {
|
||||
if !isQwenManagedHook(hook.Command) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
group.Hooks = kept
|
||||
result = append(result, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseQwenHookType(rawHooks map[string]json.RawMessage, event string, target *[]qwenMatcherGroup) 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 marshalQwenHookType(rawHooks map[string]json.RawMessage, event string, groups []qwenMatcherGroup) 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 qwenHookCommandExists(groups []qwenMatcherGroup, command string) bool {
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
if hook.Command == command {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addQwenHook 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 addQwenHook(groups []qwenMatcherGroup, hook qwenHookEntry, matcher *string) []qwenMatcherGroup {
|
||||
for i, group := range groups {
|
||||
if matchersEqual(group.Matcher, matcher) {
|
||||
groups[i].Hooks = append(groups[i].Hooks, hook)
|
||||
return groups
|
||||
}
|
||||
}
|
||||
return append(groups, qwenMatcherGroup{Matcher: matcher, Hooks: []qwenHookEntry{hook}})
|
||||
}
|
||||
|
||||
func matchersEqual(a, b *string) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
// AreHooksInstalled reports whether any AO Qwen Code hook is present.
|
||||
func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) {
|
||||
return qwenHooks.AreInstalled(ctx, workspacePath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,26 +15,19 @@ package qwen
|
|||
|
||||
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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
qwenTitleMetadataKey = "title"
|
||||
qwenSummaryMetadataKey = "summary"
|
||||
)
|
||||
|
||||
// Plugin is the Qwen Code agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -60,14 +53,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// GetConfigSpec reports the agent-specific config keys. Qwen Code 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 Qwen Code session: the
|
||||
// approval-mode flag, optional system-prompt instructions, and the initial
|
||||
// prompt (passed via `-p` so a leading "-" is not read as a flag). Prompt is
|
||||
|
|
@ -92,15 +77,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Qwen Code 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 Qwen Code
|
||||
// session: `qwen [--approval-mode <mode>] -r <agentSessionId>`. ok is false when
|
||||
// the hook-derived native session id has not landed yet, so callers can fall
|
||||
|
|
@ -132,83 +108,26 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por
|
|||
if err := ctx.Err(); err != nil {
|
||||
return ports.SessionInfo{}, false, err
|
||||
}
|
||||
info := ports.SessionInfo{
|
||||
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
|
||||
Title: session.Metadata[qwenTitleMetadataKey],
|
||||
Summary: session.Metadata[qwenSummaryMetadataKey],
|
||||
}
|
||||
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
|
||||
return ports.SessionInfo{}, false, nil
|
||||
}
|
||||
return info, true, nil
|
||||
info, ok := agentbase.StandardSessionInfo(session)
|
||||
return info, ok, nil
|
||||
}
|
||||
|
||||
// ResolveQwenBinary returns the path to the qwen binary on this machine,
|
||||
// searching PATH then a handful of well-known install locations (Homebrew, npm
|
||||
// global). Returns ports.ErrAgentBinaryNotFound when none of those find the
|
||||
// binary — better than the previous silent `"qwen"` fallback, which let an
|
||||
// empty tmux pane masquerade as a live session.
|
||||
var qwenBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "qwen",
|
||||
Names: []string{"qwen"},
|
||||
WinNames: []string{"qwen.cmd", "qwen.exe", "qwen"},
|
||||
UnixPaths: []string{"/usr/local/bin/qwen", "/opt/homebrew/bin/qwen"},
|
||||
UnixHomePaths: [][]string{{".npm-global", "bin", "qwen"}, {".npm", "bin", "qwen"}, {".local", "bin", "qwen"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "qwen.cmd"}},
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"npm", "qwen.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveQwenBinary returns the path to the qwen binary, or a wrapped
|
||||
// ports.ErrAgentBinaryNotFound when it is absent.
|
||||
func ResolveQwenBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"qwen.cmd", "qwen.exe", "qwen"} {
|
||||
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", "qwen.cmd"),
|
||||
filepath.Join(appData, "npm", "qwen.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("qwen: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("qwen"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/qwen",
|
||||
"/opt/homebrew/bin/qwen",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".npm-global", "bin", "qwen"),
|
||||
filepath.Join(home, ".npm", "bin", "qwen"),
|
||||
filepath.Join(home, ".local", "bin", "qwen"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("qwen: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, qwenBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) qwenBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -231,7 +150,7 @@ func (p *Plugin) qwenBinary(ctx context.Context) (string, error) {
|
|||
// `--approval-mode` choices (plan|default|auto-edit|auto|yolo). Default emits no
|
||||
// flag so Qwen resolves its starting mode from the user's own config.
|
||||
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
||||
switch normalizePermissionMode(permissions) {
|
||||
switch ports.NormalizePermissionMode(permissions) {
|
||||
case ports.PermissionModeDefault:
|
||||
// No flag: defer to the user's Qwen Code config/default behavior.
|
||||
case ports.PermissionModeAcceptEdits:
|
||||
|
|
@ -242,20 +161,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
|
|||
*cmd = append(*cmd, "--approval-mode", "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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "qwen"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{})
|
||||
if err != nil {
|
||||
|
|
@ -101,7 +101,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "qwen"}
|
||||
plugin := &Plugin{}
|
||||
|
||||
spec, err := plugin.GetConfigSpec(context.Background())
|
||||
if err != nil {
|
||||
|
|
@ -140,6 +140,10 @@ func TestContextCancellationIsHonored(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
type qwenHookFile struct {
|
||||
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
|
||||
}
|
||||
|
||||
func TestGetAgentHooksInstallsQwenHooks(t *testing.T) {
|
||||
plugin := &Plugin{resolvedBinary: "qwen"}
|
||||
workspace := t.TempDir()
|
||||
|
|
@ -203,7 +207,7 @@ func TestGetAgentHooksInstallsQwenHooks(t *testing.T) {
|
|||
assertStartupMatcher(t, config.Hooks["SessionStart"])
|
||||
}
|
||||
|
||||
func assertStartupMatcher(t *testing.T, groups []qwenMatcherGroup) {
|
||||
func assertStartupMatcher(t *testing.T, groups []hooksjson.MatcherGroup) {
|
||||
t.Helper()
|
||||
for _, group := range groups {
|
||||
for _, hook := range group.Hooks {
|
||||
|
|
@ -330,8 +334,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) {
|
|||
WorkspacePath: "/some/path",
|
||||
Metadata: map[string]string{
|
||||
ports.MetadataKeyAgentSessionID: "sess-123",
|
||||
qwenTitleMetadataKey: "Fix login redirect",
|
||||
qwenSummaryMetadataKey: "Updated the auth callback and tests.",
|
||||
ports.MetadataKeyTitle: "Fix login redirect",
|
||||
ports.MetadataKeySummary: "Updated the auth callback and tests.",
|
||||
"ignored": "not returned",
|
||||
},
|
||||
})
|
||||
|
|
@ -373,29 +377,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -429,7 +410,7 @@ func containsSubsequence(values []string, needle []string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func countQwenHookCommand(entries []qwenMatcherGroup, command string) int {
|
||||
func countQwenHookCommand(entries []hooksjson.MatcherGroup, command string) int {
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
for _, hook := range entry.Hooks {
|
||||
|
|
|
|||
|
|
@ -26,15 +26,12 @@ package vibe
|
|||
|
||||
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/agentbase"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
)
|
||||
|
||||
|
|
@ -43,6 +40,7 @@ const adapterID = "vibe"
|
|||
// Plugin is the Mistral Vibe agent adapter. It is safe for concurrent use; the
|
||||
// binary path is resolved once and cached under binaryMu.
|
||||
type Plugin struct {
|
||||
agentbase.Base
|
||||
binaryMu sync.Mutex
|
||||
resolvedBinary string
|
||||
}
|
||||
|
|
@ -68,14 +66,6 @@ func (p *Plugin) Manifest() adapters.Manifest {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 non-interactive Vibe session:
|
||||
//
|
||||
// vibe --trust --output text [--workdir <path>] [--agent <profile>] -p <prompt>
|
||||
|
|
@ -104,21 +94,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
|
|||
return cmd, nil
|
||||
}
|
||||
|
||||
// GetPromptDeliveryStrategy reports that Vibe 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: Vibe has no usable lifecycle-hook
|
||||
// surface for AO activity reporting (Tier C).
|
||||
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// GetRestoreCommand rebuilds the argv that continues an existing Vibe session
|
||||
// when a native session id is available in metadata. Without it, ok is false
|
||||
// and callers fall back to fresh launch behavior.
|
||||
|
|
@ -143,15 +118,8 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
return cmd, true, nil
|
||||
}
|
||||
|
||||
// SessionInfo is intentionally a no-op until Vibe can surface native session
|
||||
// metadata to AO.
|
||||
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
|
||||
}
|
||||
|
||||
// appendWorkdirFlag adds Vibe's explicit `--workdir` flag. Vibe validates its
|
||||
// own working directory in addition to the process cwd AO sets.
|
||||
func appendWorkdirFlag(cmd *[]string, workspacePath string) {
|
||||
if workspacePath != "" {
|
||||
*cmd = append(*cmd, "--workdir", workspacePath)
|
||||
|
|
@ -172,70 +140,22 @@ func appendAgentFlags(cmd *[]string, mode ports.PermissionMode) {
|
|||
}
|
||||
}
|
||||
|
||||
var vibeBinarySpec = binaryutil.BinarySpec{
|
||||
Label: "vibe",
|
||||
Names: []string{"vibe"},
|
||||
WinNames: []string{"vibe.exe", "vibe.cmd", "vibe"},
|
||||
UnixPaths: []string{"/usr/local/bin/vibe", "/opt/homebrew/bin/vibe"},
|
||||
UnixHomePaths: [][]string{{".local", "bin", "vibe"}, {".local", "share", "uv", "tools", "mistral-vibe", "bin", "vibe"}},
|
||||
WinPaths: []binaryutil.WinPath{
|
||||
{Base: binaryutil.WinAppData, Parts: []string{"Python", "Scripts", "vibe.exe"}},
|
||||
{Base: binaryutil.WinLocalAppData, Parts: []string{"uv", "tools", "mistral-vibe", "Scripts", "vibe.exe"}},
|
||||
},
|
||||
}
|
||||
|
||||
// ResolveVibeBinary finds the `vibe` binary, searching PATH then common install
|
||||
// locations. It returns "vibe" as a last resort so callers get the shell's
|
||||
// normal command-not-found behavior if Vibe is absent.
|
||||
// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when Vibe is absent.
|
||||
func ResolveVibeBinary(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, name := range []string{"vibe.exe", "vibe.cmd", "vibe"} {
|
||||
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, "Python", "Scripts", "vibe.exe"),
|
||||
)
|
||||
}
|
||||
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(localAppData, "uv", "tools", "mistral-vibe", "Scripts", "vibe.exe"),
|
||||
)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("vibe: %w", ports.ErrAgentBinaryNotFound)
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("vibe"); err == nil && path != "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"/usr/local/bin/vibe",
|
||||
"/opt/homebrew/bin/vibe",
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "vibe"),
|
||||
filepath.Join(home, ".local", "share", "uv", "tools", "mistral-vibe", "bin", "vibe"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if fileExists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("vibe: %w", ports.ErrAgentBinaryNotFound)
|
||||
return binaryutil.ResolveBinary(ctx, vibeBinarySpec)
|
||||
}
|
||||
|
||||
func (p *Plugin) vibeBinary(ctx context.Context) (string, error) {
|
||||
|
|
@ -253,8 +173,3 @@ func (p *Plugin) vibeBinary(ctx context.Context) (string, error) {
|
|||
p.resolvedBinary = binary
|
||||
return binary, nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type agentListOptions struct {
|
||||
refresh bool
|
||||
json bool
|
||||
}
|
||||
|
||||
// agentInfo mirrors the daemon's agent Info body for the CLI client.
|
||||
type agentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
AuthStatus string `json:"authStatus,omitempty"`
|
||||
}
|
||||
|
||||
// agentInventory mirrors GET /api/v1/agents and POST /api/v1/agents/refresh.
|
||||
type agentInventory struct {
|
||||
Supported []agentInfo `json:"supported"`
|
||||
Installed []agentInfo `json:"installed"`
|
||||
Authorized []agentInfo `json:"authorized"`
|
||||
}
|
||||
|
||||
func newAgentCommand(ctx *commandContext) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Inspect agent catalog readiness",
|
||||
}
|
||||
cmd.AddCommand(newAgentListCommand(ctx))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAgentListCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts agentListOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List supported agents and local auth readiness",
|
||||
Args: noArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
inv, err := ctx.fetchAgentInventory(cmd.Context(), opts.refresh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.json {
|
||||
return writeJSON(cmd.OutOrStdout(), inv)
|
||||
}
|
||||
return writeAgentList(cmd, inv)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.refresh, "refresh", false, "Refresh local install/auth probes before listing")
|
||||
cmd.Flags().BoolVar(&opts.json, "json", false, "Output raw agent catalog JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func writeAgentList(cmd *cobra.Command, inv agentInventory) error {
|
||||
out := cmd.OutOrStdout()
|
||||
if len(inv.Supported) == 0 {
|
||||
_, err := fmt.Fprintln(out, "No agents supported by this daemon.")
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Slice(inv.Supported, func(i, j int) bool {
|
||||
return inv.Supported[i].ID < inv.Supported[j].ID
|
||||
})
|
||||
installed := agentInfoByID(inv.Installed)
|
||||
authorized := agentInfoByID(inv.Authorized)
|
||||
|
||||
tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(tw, "ID\tLABEL\tINSTALL\tAUTH"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, info := range inv.Supported {
|
||||
installLabel := "needs install"
|
||||
authLabel := "auth unknown"
|
||||
if installedInfo, ok := installed[info.ID]; ok {
|
||||
installLabel = "installed"
|
||||
switch installedInfo.AuthStatus {
|
||||
case "authorized":
|
||||
authLabel = "authorized"
|
||||
case "unauthorized":
|
||||
authLabel = "needs auth"
|
||||
default:
|
||||
authLabel = "auth unknown"
|
||||
}
|
||||
}
|
||||
if _, ok := authorized[info.ID]; ok {
|
||||
installLabel = "installed"
|
||||
authLabel = "authorized"
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, info.Label, installLabel, authLabel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func agentInfoByID(infos []agentInfo) map[string]agentInfo {
|
||||
out := make(map[string]agentInfo, len(infos))
|
||||
for _, info := range infos {
|
||||
out[info.ID] = info
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentListUsesCachedCatalogByDefault(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" {
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls")
|
||||
if err != nil {
|
||||
t.Fatalf("agent ls failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "codex") || !strings.Contains(out, "needs install") {
|
||||
t.Fatalf("output missing table labels:\n%s", out)
|
||||
}
|
||||
want := []string{"GET /api/v1/agents"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentListRefreshAndStatuses(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh" {
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"aider","label":"Aider"},{"id":"codex","label":"Codex"},{"id":"goose","label":"Goose"},{"id":"opencode","label":"OpenCode"}],"installed":[{"id":"aider","label":"Aider","authStatus":"unauthorized"},{"id":"codex","label":"Codex","authStatus":"authorized"},{"id":"goose","label":"Goose","authStatus":"unknown"}],"authorized":[{"id":"codex","label":"Codex","authStatus":"authorized"}]}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--refresh")
|
||||
if err != nil {
|
||||
t.Fatalf("agent ls --refresh failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
for _, want := range []string{"codex", "authorized", "aider", "needs auth", "goose", "auth unknown", "opencode", "needs install"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
want := []string{"POST /api/v1/agents/refresh"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentListJSONEmitsRawCatalog(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" {
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("agent ls --json failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
var inv agentInventory
|
||||
if err := json.Unmarshal([]byte(out), &inv); err != nil {
|
||||
t.Fatalf("json output did not decode: %v\n%s", err, out)
|
||||
}
|
||||
if len(inv.Supported) != 1 || len(inv.Installed) != 1 || len(inv.Authorized) != 1 {
|
||||
t.Fatalf("inventory = %#v", inv)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,18 @@ type apiError struct {
|
|||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type apiResponseError struct {
|
||||
StatusCode int
|
||||
ErrorBody apiError
|
||||
}
|
||||
|
||||
func (e apiResponseError) Error() string {
|
||||
if e.ErrorBody.Message == "" {
|
||||
return fmt.Sprintf("daemon returned HTTP %d", e.StatusCode)
|
||||
}
|
||||
return e.ErrorBody.String()
|
||||
}
|
||||
|
||||
// String renders the envelope for the user: "<message> (<code>) [request <id>]",
|
||||
// omitting whichever parts the daemon left empty.
|
||||
func (e apiError) String() string {
|
||||
|
|
@ -128,10 +140,7 @@ func (c *commandContext) doJSONPath(ctx context.Context, method, path string, bo
|
|||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var e apiError
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if e.Message == "" {
|
||||
return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s", e.String())
|
||||
return apiResponseError{StatusCode: resp.StatusCode, ErrorBody: e}
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import (
|
|||
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||
agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent"
|
||||
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
|
||||
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
|
||||
)
|
||||
|
|
@ -106,6 +107,32 @@ func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string,
|
|||
return sessionsvc.ClaimPRResult{}, nil
|
||||
}
|
||||
|
||||
type fakeAgentCatalog struct{}
|
||||
|
||||
var _ controllers.AgentCatalog = (*fakeAgentCatalog)(nil)
|
||||
|
||||
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) {
|
||||
return authorizedCodexInventory(), nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) {
|
||||
return authorizedCodexInventory(), nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) {
|
||||
info := agentsvc.Info{ID: agentID, Label: agentID, AuthStatus: "authorized"}
|
||||
return agentsvc.ProbeResult{Agent: info, Supported: true, Installed: true}, nil
|
||||
}
|
||||
|
||||
func authorizedCodexInventory() agentsvc.Inventory {
|
||||
info := agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"}
|
||||
return agentsvc.Inventory{
|
||||
Supported: []agentsvc.Info{info},
|
||||
Installed: []agentsvc.Info{info},
|
||||
Authorized: []agentsvc.Info{info},
|
||||
}
|
||||
}
|
||||
|
||||
// fakeProjectManager captures the project.AddInput the controller decodes from
|
||||
// the CLI's request body. Every other method is a no-op so it satisfies the
|
||||
// projectsvc.Manager interface.
|
||||
|
|
@ -119,8 +146,9 @@ func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error)
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) {
|
||||
return projectsvc.GetResult{}, nil
|
||||
func (f *fakeProjectManager) Get(_ context.Context, id domain.ProjectID) (projectsvc.GetResult, error) {
|
||||
project := projectsvc.Project{ID: id, Path: "/repo/" + string(id)}
|
||||
return projectsvc.GetResult{Status: "ok", Project: &project}, nil
|
||||
}
|
||||
|
||||
func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) {
|
||||
|
|
@ -150,6 +178,7 @@ func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, pro
|
|||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
||||
Agents: &fakeAgentCatalog{},
|
||||
Sessions: sessions,
|
||||
Projects: projects,
|
||||
}, httpd.ControlDeps{})
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
|||
root.AddCommand(newStopCommand(ctx))
|
||||
root.AddCommand(newStatusCommand(ctx))
|
||||
root.AddCommand(newDoctorCommand(ctx))
|
||||
root.AddCommand(newAgentCommand(ctx))
|
||||
root.AddCommand(newSpawnCommand(ctx))
|
||||
root.AddCommand(newSendCommand(ctx))
|
||||
root.AddCommand(newPreviewCommand(ctx))
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
|
|
@ -19,14 +24,15 @@ import (
|
|||
const maxDisplayNameLen = 20
|
||||
|
||||
type spawnOptions struct {
|
||||
project string
|
||||
harness string
|
||||
branch string
|
||||
prompt string
|
||||
issue string
|
||||
name string
|
||||
claimPR string
|
||||
noTakeover bool
|
||||
project string
|
||||
harness string
|
||||
branch string
|
||||
prompt string
|
||||
issue string
|
||||
name string
|
||||
claimPR string
|
||||
noTakeover bool
|
||||
skipAgentCheck bool
|
||||
}
|
||||
|
||||
// spawnRequest mirrors the daemon's SpawnSessionRequest body for
|
||||
|
|
@ -37,7 +43,7 @@ type spawnRequest struct {
|
|||
Harness string `json:"harness,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
}
|
||||
|
||||
type spawnResult struct {
|
||||
|
|
@ -47,6 +53,12 @@ type spawnResult struct {
|
|||
} `json:"session"`
|
||||
}
|
||||
|
||||
type agentProbeResult struct {
|
||||
Agent agentInfo `json:"agent"`
|
||||
Supported bool `json:"supported"`
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts spawnOptions
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -57,25 +69,33 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
"fresh git worktree. Register the project first with `ao project add`.",
|
||||
Args: noArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.project == "" {
|
||||
return usageError{fmt.Errorf("--project is required")}
|
||||
}
|
||||
name := strings.TrimSpace(opts.name)
|
||||
if name == "" {
|
||||
return usageError{fmt.Errorf("--name is required")}
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayNameLen {
|
||||
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
|
||||
}
|
||||
if opts.noTakeover && opts.claimPR == "" {
|
||||
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
|
||||
}
|
||||
claimRef := ""
|
||||
if opts.claimPR != "" {
|
||||
project, err := ctx.fetchProjectDetails(cmd.Context(), opts.project)
|
||||
if err != nil {
|
||||
if explicitName := strings.TrimSpace(opts.name); utf8.RuneCountInString(explicitName) > maxDisplayNameLen {
|
||||
return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)}
|
||||
}
|
||||
|
||||
project, err := ctx.resolveSpawnProject(cmd.Context(), opts.project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.project = project.ID
|
||||
|
||||
harness, err := resolveSpawnHarness(opts.harness, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.harness = harness
|
||||
|
||||
name := resolveSpawnDisplayName(opts.name, opts.prompt)
|
||||
if !opts.skipAgentCheck {
|
||||
if err := ctx.preflightSpawnAgentAuth(cmd.Context(), cmd, opts.harness); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
claimRef := ""
|
||||
if opts.claimPR != "" {
|
||||
claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -123,7 +143,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
} else {
|
||||
attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)"
|
||||
}
|
||||
_, err := fmt.Fprintf(out, "attach with: %s\n", attach)
|
||||
_, err = fmt.Fprintf(out, "attach with: %s\n", attach)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
|
@ -136,17 +156,274 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
|
||||
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (default: AO_PROJECT_ID or current registered repo)")
|
||||
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)")
|
||||
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
|
||||
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
||||
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
||||
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)")
|
||||
f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (default: derived from --prompt, max 20 characters)")
|
||||
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
|
||||
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
|
||||
f.BoolVar(&opts.skipAgentCheck, "skip-agent-check", false, "Skip advisory agent catalog install/auth preflight before spawning")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (c *commandContext) fetchAgentInventory(ctx context.Context, refresh bool) (agentInventory, error) {
|
||||
var inv agentInventory
|
||||
if refresh {
|
||||
if err := c.postJSON(ctx, "agents/refresh", struct{}{}, &inv); err != nil {
|
||||
return agentInventory{}, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
if err := c.getJSON(ctx, "agents", &inv); err != nil {
|
||||
return agentInventory{}, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (c *commandContext) resolveSpawnProject(ctx context.Context, explicit string) (projectDetails, error) {
|
||||
if id := strings.TrimSpace(explicit); id != "" {
|
||||
return c.fetchProjectDetails(ctx, id)
|
||||
}
|
||||
if id := strings.TrimSpace(os.Getenv("AO_PROJECT_ID")); id != "" {
|
||||
return c.fetchProjectDetails(ctx, id)
|
||||
}
|
||||
if sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID")); sessionID != "" {
|
||||
project, err := c.resolveProjectFromSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return projectDetails{}, err
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
project, ok, err := c.resolveProjectFromCWD(ctx)
|
||||
if err != nil {
|
||||
return projectDetails{}, err
|
||||
}
|
||||
if ok {
|
||||
return project, nil
|
||||
}
|
||||
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved; pass --project or run `ao project add --path <repo-path> --worker-agent <agent>`")}
|
||||
}
|
||||
|
||||
func (c *commandContext) resolveProjectFromSession(ctx context.Context, sessionID string) (projectDetails, error) {
|
||||
sess, err := c.fetchScopedSession(ctx, sessionID, "")
|
||||
if err != nil {
|
||||
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)}
|
||||
}
|
||||
if strings.TrimSpace(sess.ProjectID) == "" {
|
||||
return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)}
|
||||
}
|
||||
return c.fetchProjectDetails(ctx, sess.ProjectID)
|
||||
}
|
||||
|
||||
func (c *commandContext) resolveProjectFromCWD(ctx context.Context) (projectDetails, bool, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
cwd, err = normalizeProjectMatchPath(cwd)
|
||||
if err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
|
||||
var list projectListResult
|
||||
if err := c.getJSON(ctx, "projects", &list); err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
sort.Slice(list.Projects, func(i, j int) bool {
|
||||
return list.Projects[i].ID < list.Projects[j].ID
|
||||
})
|
||||
|
||||
var best projectDetails
|
||||
bestLen := -1
|
||||
ambiguous := false
|
||||
for _, summary := range list.Projects {
|
||||
project, err := c.fetchProjectDetails(ctx, summary.ID)
|
||||
if err != nil {
|
||||
return projectDetails{}, false, err
|
||||
}
|
||||
if project.Path == "" {
|
||||
continue
|
||||
}
|
||||
projectPath, err := normalizeProjectMatchPath(project.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !pathContains(projectPath, cwd) {
|
||||
continue
|
||||
}
|
||||
pathLen := len(projectPath)
|
||||
switch {
|
||||
case pathLen > bestLen:
|
||||
best = project
|
||||
bestLen = pathLen
|
||||
ambiguous = false
|
||||
case pathLen == bestLen:
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
if bestLen == -1 {
|
||||
return projectDetails{}, false, nil
|
||||
}
|
||||
if ambiguous {
|
||||
return projectDetails{}, false, usageError{fmt.Errorf("current directory matches multiple registered projects; pass --project")}
|
||||
}
|
||||
return best, true, nil
|
||||
}
|
||||
|
||||
func normalizeProjectMatchPath(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if realPath, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = realPath
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
func pathContains(root, child string) bool {
|
||||
if root == child {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(root, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func resolveSpawnHarness(explicit string, project projectDetails) (string, error) {
|
||||
if harness := strings.TrimSpace(explicit); harness != "" {
|
||||
return harness, nil
|
||||
}
|
||||
if project.Config != nil {
|
||||
if harness := strings.TrimSpace(project.Config.Worker.Agent); harness != "" {
|
||||
return harness, nil
|
||||
}
|
||||
}
|
||||
return "", usageError{fmt.Errorf("agent could not be resolved; pass --agent or configure `ao project set-config %s --worker-agent <agent>`", project.ID)}
|
||||
}
|
||||
|
||||
func resolveSpawnDisplayName(explicit, prompt string) string {
|
||||
if name := strings.TrimSpace(explicit); name != "" {
|
||||
return name
|
||||
}
|
||||
return deriveDisplayNameFromPrompt(prompt)
|
||||
}
|
||||
|
||||
func deriveDisplayNameFromPrompt(prompt string) string {
|
||||
fields := strings.Fields(strings.TrimSpace(prompt))
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, field := range fields {
|
||||
next := strings.Trim(field, " \t\r\n.,;:!?()[]{}\"'")
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
next = " " + next
|
||||
}
|
||||
if utf8.RuneCountInString(b.String()+next) > maxDisplayNameLen {
|
||||
break
|
||||
}
|
||||
b.WriteString(next)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *commandContext) preflightSpawnAgentAuth(ctx context.Context, cmd *cobra.Command, agentID string) error {
|
||||
inv, err := c.fetchAgentInventory(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := agentCatalogStateFor(inv, agentID)
|
||||
if !state.supported {
|
||||
return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID)
|
||||
}
|
||||
if !state.installed || state.authStatus == "unauthorized" {
|
||||
fresh, err := c.probeSpawnAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
if agentProbeUnavailable(err) {
|
||||
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q fresh readiness probe is unavailable; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !fresh.Supported {
|
||||
return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID)
|
||||
}
|
||||
if !fresh.Installed {
|
||||
return fmt.Errorf("agent %q needs install; install the agent CLI or pass --skip-agent-check to let spawn validate it", agentID)
|
||||
}
|
||||
state.installed = true
|
||||
state.authorized = fresh.Agent.AuthStatus == "authorized"
|
||||
state.authStatus = fresh.Agent.AuthStatus
|
||||
}
|
||||
if state.authorized {
|
||||
return nil
|
||||
}
|
||||
if state.authStatus == "unauthorized" {
|
||||
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q may need auth according to a fresh local probe; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q auth status is unknown; continuing and letting spawn validate runtime readiness\n", agentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *commandContext) probeSpawnAgent(ctx context.Context, agentID string) (agentProbeResult, error) {
|
||||
var result agentProbeResult
|
||||
if err := c.postJSON(ctx, "agents/"+url.PathEscape(agentID)+"/probe", struct{}{}, &result); err != nil {
|
||||
return agentProbeResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func agentProbeUnavailable(err error) bool {
|
||||
var apiErr apiResponseError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
return apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusNotImplemented
|
||||
}
|
||||
|
||||
type agentCatalogState struct {
|
||||
supported bool
|
||||
installed bool
|
||||
authorized bool
|
||||
authStatus string
|
||||
}
|
||||
|
||||
func agentCatalogStateFor(inv agentInventory, agentID string) agentCatalogState {
|
||||
state := agentCatalogState{}
|
||||
for _, info := range inv.Supported {
|
||||
if info.ID == agentID {
|
||||
state.supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, info := range inv.Authorized {
|
||||
if info.ID == agentID {
|
||||
state.installed = true
|
||||
state.authorized = true
|
||||
state.authStatus = "authorized"
|
||||
return state
|
||||
}
|
||||
}
|
||||
for _, info := range inv.Installed {
|
||||
if info.ID == agentID {
|
||||
state.installed = true
|
||||
state.authorized = info.AuthStatus == "authorized"
|
||||
state.authStatus = info.AuthStatus
|
||||
return state
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// rollbackSpawnedSession reverses a partial `spawn` whose out-of-band follow-up
|
||||
// (PR claim) failed. It calls the daemon's `/rollback` endpoint, which deletes
|
||||
// the seed-state row outright instead of marking it terminated — so the user
|
||||
|
|
|
|||
|
|
@ -6,23 +6,44 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing
|
||||
// --project before touching the network, so it fails fast without a daemon.
|
||||
func TestSpawnCommand_RequiresProject(t *testing.T) {
|
||||
var out, errb bytes.Buffer
|
||||
root := NewRootCommand(Deps{Out: &out, Err: &errb})
|
||||
root.SetArgs([]string{"spawn"})
|
||||
err := root.Execute()
|
||||
func authorizedAgentsJSON(agent string) string {
|
||||
info := `{"id":` + jsonQuote(agent) + `,"label":` + jsonQuote(agent) + `,"authStatus":"authorized"}`
|
||||
return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}`
|
||||
}
|
||||
|
||||
// TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project
|
||||
// setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one.
|
||||
func TestSpawnCommand_MissingProjectContext(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects" {
|
||||
_, _ = io.WriteString(w, `{"projects":[]}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when --project is missing")
|
||||
t.Fatal("expected an error when project context is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--project is required") {
|
||||
t.Fatalf("error = %v, want it to mention --project is required", err)
|
||||
if !strings.Contains(err.Error(), "ao project add --path <repo-path> --worker-agent <agent>") {
|
||||
t.Fatalf("error = %v, want project add hint", err)
|
||||
}
|
||||
if want := []string{"GET /api/v1/projects"}; !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +71,8 @@ func TestSpawnClaimPRWiring(t *testing.T) {
|
|||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-9/pr/claim":
|
||||
|
|
@ -66,14 +89,14 @@ func TestSpawnClaimPRWiring(t *testing.T) {
|
|||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover")
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142", "--no-takeover")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") {
|
||||
t.Fatalf("output missing claimed label: %s", out)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
|
|
@ -89,6 +112,8 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
sessions["demo-10"] = true
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`)
|
||||
|
|
@ -108,7 +133,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142")
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142")
|
||||
if err == nil {
|
||||
t.Fatal("expected spawn claim failure")
|
||||
}
|
||||
|
|
@ -119,7 +144,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) {
|
|||
if sessions["demo-10"] {
|
||||
t.Fatalf("spawned session still present after claim rollback: %#v", sessions)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
|
|
@ -132,15 +157,6 @@ func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name
|
||||
// before touching the network, mirroring the --project guard.
|
||||
func TestSpawnCommand_RequiresName(t *testing.T) {
|
||||
_, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo")
|
||||
if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") {
|
||||
t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name
|
||||
// longer than 20 characters without contacting the daemon.
|
||||
func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
||||
|
|
@ -149,3 +165,529 @@ func TestSpawnCommand_RejectsOverlongName(t *testing.T) {
|
|||
t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
t.Setenv("AO_PROJECT_ID", "demo")
|
||||
|
||||
out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix failing tests in auth")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(out, "spawned session demo-11") {
|
||||
t.Fatalf("output missing spawn: %s", out)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "Fix failing tests in" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1":
|
||||
_, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-15","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
t.Setenv("AO_SESSION_ID", "demo-1")
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/sessions/demo-1", "GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/missing":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"error":"not_found","code":"SESSION_NOT_FOUND","message":"Session not found"}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
t.Setenv("AO_SESSION_ID", "missing")
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex")
|
||||
if err == nil || !strings.Contains(err.Error(), `project could not be resolved from AO_SESSION_ID "missing"; pass --project`) {
|
||||
t.Fatalf("err=%v, want AO_SESSION_ID project error", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/sessions/missing"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnResolvesProjectFromCWD(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
repo := filepath.Join(t.TempDir(), "repo")
|
||||
subdir := filepath.Join(repo, "pkg")
|
||||
if err := os.MkdirAll(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(subdir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldwd) })
|
||||
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects":
|
||||
_, _ = io.WriteString(w, `{"projects":[{"id":"demo","name":"Demo"}]}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":`+jsonQuote(repo)+`,"config":{"worker":{"agent":"codex"}}}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, authorizedAgentsJSON("codex"))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"authorized"},"supported":true,"installed":true}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if errOut != "" {
|
||||
t.Fatalf("stderr = %q, want no warning after fresh authorized probe", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unauthorized"},"supported":true,"installed":true}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "may need auth according to a fresh local probe") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
_, _ = io.WriteString(w, `{"message":"not implemented","code":"NOT_IMPLEMENTED"}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "fresh readiness probe is unavailable") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unknown")
|
||||
if err == nil || !strings.Contains(err.Error(), "agent \"unknown\" is not supported") {
|
||||
t.Fatalf("err=%v, want unsupported", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex"},"supported":true,"installed":false}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err == nil || !strings.Contains(err.Error(), "agent \"codex\" needs install") {
|
||||
t.Fatalf("err=%v, want needs install", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
_, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unknown"},"supported":true,"installed":true}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "auth status is unknown") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"message":"not found","code":"NOT_FOUND"}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "fresh readiness probe is unavailable") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"message":"probe failed","code":"PROBE_FAILED","requestId":"req-1"}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err == nil || !strings.Contains(err.Error(), "probe failed (PROBE_FAILED) [request req-1]") {
|
||||
t.Fatalf("err=%v, want probe server error", err)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var requests []string
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
appendPrimaryRequest(&requests, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-14","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unsupported", "--skip-agent-check")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "unsupported" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions"}
|
||||
if !reflect.DeepEqual(requests, want) {
|
||||
t.Fatalf("requests=%#v want %#v", requests, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
var req spawnRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo":
|
||||
_, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh":
|
||||
_, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unknown"}],"authorized":[]}`)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions":
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"session":{"id":"demo-13","status":"idle"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
writeRunFileFor(t, cfg, srv)
|
||||
|
||||
_, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("spawn failed: %v stderr=%s", err, errOut)
|
||||
}
|
||||
if !strings.Contains(errOut, "auth status is unknown") {
|
||||
t.Fatalf("stderr missing warning: %s", errOut)
|
||||
}
|
||||
if req.ProjectID != "demo" || req.Harness != "codex" {
|
||||
t.Fatalf("spawn request = %#v", req)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,10 +132,16 @@ func Run() error {
|
|||
}
|
||||
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log)
|
||||
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
|
||||
agentSvc := agentsvc.New()
|
||||
go func() {
|
||||
if _, err := agentSvc.Refresh(ctx); err != nil {
|
||||
log.Warn("initial agent catalog refresh failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
|
||||
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}),
|
||||
Agents: agentsvc.New(),
|
||||
Agents: agentSvc,
|
||||
Sessions: sessionSvc,
|
||||
Reviews: reviewSvc,
|
||||
Notifications: notifier,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,45 @@ paths:
|
|||
summary: Return cached supported and locally installed agent adapters
|
||||
tags:
|
||||
- agents
|
||||
/api/v1/agents/{agent}/probe:
|
||||
post:
|
||||
operationId: probeAgent
|
||||
parameters:
|
||||
- description: Agent adapter identifier.
|
||||
in: path
|
||||
name: agent
|
||||
required: true
|
||||
schema:
|
||||
description: Agent adapter identifier.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProbeAgentResponse'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/APIError'
|
||||
description: Bad Request
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/APIError'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/APIError'
|
||||
description: Not Implemented
|
||||
summary: Run a fresh local readiness probe for one agent adapter
|
||||
tags:
|
||||
- agents
|
||||
/api/v1/agents/refresh:
|
||||
post:
|
||||
operationId: refreshAgents
|
||||
|
|
@ -1975,6 +2014,19 @@ components:
|
|||
- targetSha
|
||||
- status
|
||||
type: object
|
||||
ProbeAgentResponse:
|
||||
properties:
|
||||
agent:
|
||||
$ref: '#/components/schemas/AgentInfo'
|
||||
installed:
|
||||
type: boolean
|
||||
supported:
|
||||
type: boolean
|
||||
required:
|
||||
- agent
|
||||
- supported
|
||||
- installed
|
||||
type: object
|
||||
Project:
|
||||
properties:
|
||||
agent:
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ var schemaNames = map[string]string{
|
|||
// httpd/controllers (wire envelopes)
|
||||
"ControllersListProjectsResponse": "ListProjectsResponse",
|
||||
"ControllersProjectResponse": "ProjectResponse",
|
||||
"ControllersAgentIDParam": "AgentIDParam",
|
||||
"ControllersGetProjectResponse": "ProjectGetResponse",
|
||||
"ControllersProjectOrDegraded": "ProjectOrDegraded",
|
||||
"ControllersListSessionsQuery": "ListSessionsQuery",
|
||||
|
|
@ -174,6 +175,7 @@ var schemaNames = map[string]string{
|
|||
"ControllersOrchestratorResponse": "OrchestratorResponse",
|
||||
"AgentInventory": "ListAgentsResponse",
|
||||
"AgentInfo": "AgentInfo",
|
||||
"AgentProbeResult": "ProbeAgentResponse",
|
||||
"ControllersListNotificationsQuery": "ListNotificationsQuery",
|
||||
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
|
||||
"ControllersNotificationIDParam": "NotificationIDParam",
|
||||
|
|
@ -313,6 +315,17 @@ func agentOperations() []operation {
|
|||
{http.StatusNotImplemented, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: http.MethodPost, path: "/api/v1/agents/{agent}/probe", id: "probeAgent", tag: "agents",
|
||||
summary: "Run a fresh local readiness probe for one agent adapter",
|
||||
pathParams: []any{controllers.AgentIDParam{}},
|
||||
resps: []respUnit{
|
||||
{http.StatusOK, controllers.ProbeAgentResponse{}},
|
||||
{http.StatusBadRequest, envelope.APIError{}},
|
||||
{http.StatusInternalServerError, envelope.APIError{}},
|
||||
{http.StatusNotImplemented, envelope.APIError{}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package controllers
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ import (
|
|||
type AgentCatalog interface {
|
||||
List(ctx context.Context) (agentsvc.Inventory, error)
|
||||
Refresh(ctx context.Context) (agentsvc.Inventory, error)
|
||||
Probe(ctx context.Context, agentID string) (agentsvc.ProbeResult, error)
|
||||
}
|
||||
|
||||
// AgentsController owns the /agents routes.
|
||||
|
|
@ -26,6 +28,7 @@ type AgentsController struct {
|
|||
func (c *AgentsController) Register(r chi.Router) {
|
||||
r.Get("/agents", c.list)
|
||||
r.Post("/agents/refresh", c.refresh)
|
||||
r.Post("/agents/{agent}/probe", c.probe)
|
||||
}
|
||||
|
||||
func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -53,3 +56,21 @@ func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, inventory)
|
||||
}
|
||||
|
||||
func (c *AgentsController) probe(w http.ResponseWriter, r *http.Request) {
|
||||
if c.Catalog == nil {
|
||||
apispec.NotImplemented(w, r, "POST", "/api/v1/agents/{agent}/probe")
|
||||
return
|
||||
}
|
||||
agentID := strings.TrimSpace(chi.URLParam(r, "agent"))
|
||||
if agentID == "" {
|
||||
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "AGENT_REQUIRED", "agent is required", nil)
|
||||
return
|
||||
}
|
||||
result, err := c.Catalog.Probe(r.Context(), agentID)
|
||||
if err != nil {
|
||||
envelope.WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
envelope.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ import (
|
|||
type fakeAgentCatalog struct {
|
||||
inventory agentsvc.Inventory
|
||||
refreshed agentsvc.Inventory
|
||||
probed agentsvc.ProbeResult
|
||||
err error
|
||||
listCalls int
|
||||
refreshCalls int
|
||||
probeCalls int
|
||||
probeAgent string
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) {
|
||||
|
|
@ -35,6 +38,12 @@ func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error)
|
|||
return f.inventory, f.err
|
||||
}
|
||||
|
||||
func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) {
|
||||
f.probeCalls++
|
||||
f.probeAgent = agentID
|
||||
return f.probed, f.err
|
||||
}
|
||||
|
||||
func TestListAgents(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{
|
||||
|
|
@ -92,3 +101,31 @@ func TestRefreshAgents(t *testing.T) {
|
|||
t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeAgent(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
catalog := &fakeAgentCatalog{
|
||||
probed: agentsvc.ProbeResult{
|
||||
Agent: agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"},
|
||||
Supported: true,
|
||||
Installed: true,
|
||||
},
|
||||
}
|
||||
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{
|
||||
Agents: catalog,
|
||||
}, httpd.ControlDeps{}))
|
||||
defer srv.Close()
|
||||
|
||||
body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/codex/probe", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("POST /agents/codex/probe = %d, body=%s", status, body)
|
||||
}
|
||||
for _, want := range []string{`"supported":true`, `"installed":true`, `"id":"codex"`, `"authStatus":"authorized"`} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Fatalf("body missing %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
if catalog.probeCalls != 1 || catalog.probeAgent != "codex" {
|
||||
t.Fatalf("probe calls=%d agent=%q, want one codex probe", catalog.probeCalls, catalog.probeAgent)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ type ProjectIDParam struct {
|
|||
ID string `path:"id" description:"Project identifier (registry key)."`
|
||||
}
|
||||
|
||||
// AgentIDParam is the {agent} path parameter for one-agent catalog probes.
|
||||
type AgentIDParam struct {
|
||||
Agent string `path:"agent" description:"Agent adapter identifier."`
|
||||
}
|
||||
|
||||
// ListProjectsResponse is the body of GET /api/v1/projects.
|
||||
type ListProjectsResponse struct {
|
||||
Projects []projectsvc.Summary `json:"projects"`
|
||||
|
|
@ -442,6 +447,9 @@ type ListAgentsResponse = agentsvc.Inventory
|
|||
// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh.
|
||||
type RefreshAgentsResponse = agentsvc.Inventory
|
||||
|
||||
// ProbeAgentResponse is the body of POST /api/v1/agents/{agent}/probe.
|
||||
type ProbeAgentResponse = agentsvc.ProbeResult
|
||||
|
||||
// AgentInfo is one supported or installed agent entry.
|
||||
type AgentInfo = agentsvc.Info
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ const (
|
|||
type LaunchConfig struct {
|
||||
Config AgentConfig
|
||||
IssueID string
|
||||
Kind domain.SessionKind
|
||||
Permissions PermissionMode
|
||||
Prompt string
|
||||
SessionID string
|
||||
|
|
@ -151,12 +152,14 @@ type WorkspaceHookConfig struct {
|
|||
Config AgentConfig
|
||||
DataDir string
|
||||
SessionID string
|
||||
SystemPrompt string
|
||||
WorkspacePath string
|
||||
}
|
||||
|
||||
// RestoreConfig carries inputs needed to continue an existing native agent session.
|
||||
type RestoreConfig struct {
|
||||
Config AgentConfig
|
||||
Kind domain.SessionKind
|
||||
Permissions PermissionMode
|
||||
Session SessionRef
|
||||
// SystemPrompt carries the session's standing instructions (e.g. the
|
||||
|
|
@ -196,11 +199,28 @@ const (
|
|||
PermissionModeBypassPermissions = domain.PermissionModeBypassPermissions
|
||||
)
|
||||
|
||||
// NormalizePermissionMode collapses an empty or unrecognized mode to
|
||||
// PermissionModeDefault, leaving the four known modes unchanged. Adapters call
|
||||
// it so a stored value they don't recognize defers to the agent's own config
|
||||
// (usually by emitting no flag) rather than mapping onto a bogus one.
|
||||
func NormalizePermissionMode(mode PermissionMode) PermissionMode {
|
||||
switch mode {
|
||||
case PermissionModeDefault,
|
||||
PermissionModeAcceptEdits,
|
||||
PermissionModeAuto,
|
||||
PermissionModeBypassPermissions:
|
||||
return mode
|
||||
default:
|
||||
return PermissionModeDefault
|
||||
}
|
||||
}
|
||||
|
||||
// PromptDeliveryStrategy describes how AO should deliver the initial prompt.
|
||||
type PromptDeliveryStrategy string
|
||||
|
||||
// How the orchestrator hands the initial prompt to a freshly launched agent.
|
||||
const (
|
||||
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
|
||||
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
|
||||
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
|
||||
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
|
||||
PromptDeliveryCustomAgent PromptDeliveryStrategy = "custom_agent"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -280,6 +280,61 @@ func TestRefreshIsRateLimited(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProbeBypassesRefreshRateLimitForOneAgent(t *testing.T) {
|
||||
previous := agentRefreshMinInterval
|
||||
agentRefreshMinInterval = time.Hour
|
||||
t.Cleanup(func() { agentRefreshMinInterval = previous })
|
||||
|
||||
probes := 0
|
||||
svc := NewWithAgents([]agentregistry.HarnessAgent{
|
||||
{
|
||||
Harness: domain.AgentHarness("codex"),
|
||||
Manifest: adapters.Manifest{
|
||||
ID: "codex",
|
||||
Name: "Codex",
|
||||
},
|
||||
Agent: probeTrackingAgent{fakeAgent: fakeAgent{}, onProbe: func() { probes++ }},
|
||||
},
|
||||
harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound),
|
||||
})
|
||||
|
||||
if _, err := svc.Refresh(context.Background()); err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
got, err := svc.Probe(context.Background(), "codex")
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
if !got.Supported || !got.Installed || got.Agent.ID != "codex" {
|
||||
t.Fatalf("Probe = %#v, want supported installed codex", got)
|
||||
}
|
||||
if probes != 2 {
|
||||
t.Fatalf("probes = %d, want refresh plus fresh probe", probes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeReportsUnsupportedAndMissingAgent(t *testing.T) {
|
||||
svc := NewWithAgents([]agentregistry.HarnessAgent{
|
||||
harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound),
|
||||
})
|
||||
|
||||
missing, err := svc.Probe(context.Background(), "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("Probe missing: %v", err)
|
||||
}
|
||||
if !missing.Supported || missing.Installed {
|
||||
t.Fatalf("Probe missing = %#v, want supported but not installed", missing)
|
||||
}
|
||||
|
||||
unsupported, err := svc.Probe(context.Background(), "unknown")
|
||||
if err != nil {
|
||||
t.Fatalf("Probe unknown: %v", err)
|
||||
}
|
||||
if unsupported.Supported || unsupported.Installed || unsupported.Agent.ID != "unknown" {
|
||||
t.Fatalf("Probe unknown = %#v, want unsupported unknown", unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func harnessAgent(id, label string, err error) agentregistry.HarnessAgent {
|
||||
return agentregistry.HarnessAgent{
|
||||
Harness: domain.AgentHarness(id),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ type probeResult struct {
|
|||
authorized bool
|
||||
}
|
||||
|
||||
// ProbeResult describes a fresh readiness probe for one supported agent.
|
||||
type ProbeResult struct {
|
||||
Agent Info `json:"agent"`
|
||||
Supported bool `json:"supported"`
|
||||
Installed bool `json:"installed"`
|
||||
}
|
||||
|
||||
// Info is the user-facing identity for an agent adapter.
|
||||
type Info struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -140,6 +147,31 @@ func (s *Service) Refresh(ctx context.Context) (Inventory, error) {
|
|||
return next, nil
|
||||
}
|
||||
|
||||
// Probe runs a fresh bounded binary/auth probe for one agent, bypassing the
|
||||
// catalog refresh rate limit. It is intended for user-initiated preflight paths
|
||||
// where a cached negative catalog result may be stale.
|
||||
func (s *Service) Probe(ctx context.Context, agentID string) (ProbeResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ProbeResult{}, err
|
||||
}
|
||||
for _, item := range s.agents {
|
||||
info := Info{ID: string(item.Harness), Label: item.Manifest.Name}
|
||||
if info.Label == "" {
|
||||
info.Label = info.ID
|
||||
}
|
||||
if info.ID != agentID {
|
||||
continue
|
||||
}
|
||||
res := probeAgent(ctx, item)
|
||||
return ProbeResult{
|
||||
Agent: res.info,
|
||||
Supported: true,
|
||||
Installed: res.installed,
|
||||
}, nil
|
||||
}
|
||||
return ProbeResult{Agent: Info{ID: agentID}, Supported: false, Installed: false}, nil
|
||||
}
|
||||
|
||||
func supportedInfos(agents []agentregistry.HarnessAgent) []Info {
|
||||
supported := make([]Info, 0, len(agents))
|
||||
for _, item := range agents {
|
||||
|
|
|
|||
|
|
@ -256,15 +256,16 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
|
||||
}
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
|
||||
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
|
||||
_ = m.workspace.Destroy(ctx, ws)
|
||||
m.rollbackSpawnSeedRow(ctx, id)
|
||||
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
|
||||
}
|
||||
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
|
||||
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
|
||||
SessionID: string(id),
|
||||
WorkspacePath: ws.Path,
|
||||
Kind: cfg.Kind,
|
||||
Prompt: prompt,
|
||||
SystemPrompt: systemPrompt,
|
||||
IssueID: string(cfg.IssueID),
|
||||
|
|
@ -575,9 +576,6 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
|
|||
if !ok {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness)
|
||||
}
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
// The system prompt is derived, not persisted: recompute it so a restored
|
||||
// session keeps its standing instructions across the relaunch.
|
||||
systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID)
|
||||
|
|
@ -586,7 +584,11 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
|
|||
}
|
||||
// Restore re-applies the project's resolved agent config so a configured
|
||||
// model/permissions carry across a restore, matching fresh spawn.
|
||||
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, effectiveAgentConfig(rec.Kind, project.Config), rec.Kind)
|
||||
agentConfig := effectiveAgentConfig(rec.Kind, project.Config)
|
||||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, agentConfig, rec.Kind)
|
||||
if err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
|
|
@ -1297,11 +1299,13 @@ type preLauncher interface {
|
|||
// starts the agent: installing the workspace-local activity hooks (so early
|
||||
// startup hooks can update the already-created session row), then any optional
|
||||
// PreLaunch step. Shared by Spawn and Restore.
|
||||
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string) error {
|
||||
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath, systemPrompt string, agentConfig ports.AgentConfig) error {
|
||||
if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{
|
||||
SessionID: string(id),
|
||||
WorkspacePath: workspacePath,
|
||||
DataDir: m.dataDir,
|
||||
SystemPrompt: systemPrompt,
|
||||
Config: agentConfig,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("install hooks: %w", err)
|
||||
}
|
||||
|
|
@ -1326,7 +1330,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
|
|||
WorkspacePath: workspacePath,
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID},
|
||||
}
|
||||
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions})
|
||||
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, Kind: kind, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore command: %w", err)
|
||||
}
|
||||
|
|
@ -1343,6 +1347,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
|
|||
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
|
||||
SessionID: string(id),
|
||||
WorkspacePath: workspacePath,
|
||||
Kind: kind,
|
||||
Prompt: meta.Prompt,
|
||||
SystemPrompt: systemPrompt,
|
||||
Config: agentConfig,
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
|
@ -5,6 +5,18 @@ It starts, discovers, inspects, and stops the daemon through the loopback HTTP
|
|||
surface and the `running.json` handshake. It must not open SQLite directly or
|
||||
call runtime, workspace, tracker, or agent adapters in-process.
|
||||
|
||||
When using the CLI directly from a shell, make sure the daemon is running first
|
||||
with `ao start` or by opening the desktop app. Product commands such as
|
||||
`ao agent ls` and `ao spawn` call the loopback daemon and will fail with a
|
||||
"daemon is not running" error if no `running.json` points at a live process. From
|
||||
a source checkout, build and run the local binary explicitly, for example:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go build -o ./bin/ao ./cmd/ao
|
||||
./bin/ao agent ls
|
||||
```
|
||||
|
||||
## Current commands
|
||||
|
||||
Every product command resolves to a daemon HTTP route. Run `ao <command>
|
||||
|
|
@ -31,6 +43,8 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
|
|||
| `ao project get <id>` | `GET /api/v1/projects/{id}` |
|
||||
| `ao project set-config <id>` | `PUT /api/v1/projects/{id}/config` |
|
||||
| `ao project rm <id>` | `DELETE /api/v1/projects/{id}` |
|
||||
| `ao agent ls` | `GET /api/v1/agents` |
|
||||
| `ao agent ls --refresh` | `POST /api/v1/agents/refresh` |
|
||||
| `ao spawn` | `POST /api/v1/sessions` |
|
||||
| `ao session ls` | `GET /api/v1/sessions` |
|
||||
| `ao session get <id>` | `GET /api/v1/sessions/{id}` |
|
||||
|
|
@ -44,6 +58,23 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
|
|||
| `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` |
|
||||
| `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) |
|
||||
|
||||
`ao agent ls` prints the daemon-supported agent catalog with local install/auth
|
||||
readiness. Use `--refresh` to rerun the bounded local probes and `--json` to
|
||||
print the raw inventory response.
|
||||
|
||||
`ao spawn` resolves project context in this order: explicit `--project`,
|
||||
`AO_PROJECT_ID`, `AO_SESSION_ID` (by fetching the current session from the
|
||||
daemon), then the current working directory matched against registered project
|
||||
paths. If `AO_SESSION_ID` is set but the session cannot be fetched, pass
|
||||
`--project` explicitly.
|
||||
|
||||
If `--agent` / `--harness` is omitted, `ao spawn` uses the resolved project's
|
||||
`worker.agent` config. Before spawning, the CLI refreshes the advisory agent
|
||||
catalog and fails early when the selected agent is unsupported, not installed,
|
||||
or unauthorized. It warns-but-continues when auth remains unknown because daemon
|
||||
spawn remains the authoritative runtime validation point. Use
|
||||
`--skip-agent-check` to bypass only this CLI-side preflight.
|
||||
|
||||
`ao preview` resolves its session from the `AO_SESSION_ID` environment variable
|
||||
(it is meant to run inside a session), not a flag. With no argument it
|
||||
autodetects an `index.html` in the session workspace; with a URL argument it
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agents/{agent}/probe": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Run a fresh local readiness probe for one agent adapter */
|
||||
post: operations["probeAgent"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agents/refresh": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -708,6 +725,11 @@ export interface components {
|
|||
targetSha: string;
|
||||
title: string;
|
||||
};
|
||||
ProbeAgentResponse: {
|
||||
agent: components["schemas"]["AgentInfo"];
|
||||
installed: boolean;
|
||||
supported: boolean;
|
||||
};
|
||||
Project: {
|
||||
agent?: string;
|
||||
config?: components["schemas"]["ProjectConfig"];
|
||||
|
|
@ -1027,6 +1049,56 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
probeAgent: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Agent adapter identifier. */
|
||||
agent: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProbeAgentResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["APIError"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["APIError"];
|
||||
};
|
||||
};
|
||||
/** @description Not Implemented */
|
||||
501: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["APIError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
refreshAgents: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 813 B |
|
After Width: | Height: | Size: 519 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 582 B |
|
After Width: | Height: | Size: 926 B |
|
After Width: | Height: | Size: 620 B |
|
After Width: | Height: | Size: 362 B |
|
After Width: | Height: | Size: 571 B |