Compare commits

...

36 Commits

Author SHA1 Message Date
Khushi Diwan aae38fd5cd
New README (#2419)
* docs: refresh README

* docs: refine README layout and add actual agent logos

* docs: fix image quality and url in twitter journey section

* chore: format spawn orchestrator helper

* docs: align README tweet screenshots

* fix
2026-07-06 13:26:36 +05:30
NIKHIL ACHALE d51ddd6130
feat: enhance Kiro agent integration with custom AO agent prompts (#2444)
* feat: enhance Kiro agent integration with system prompts and agent configuration

* fix(kiro): clear stale model from hooks when configuration is removed
2026-07-06 02:58:08 +05:30
Apoorv Singh 71fc914afa
fix(desktop): wheel-scroll opencode transcripts and surface native Windows notifications (#2422) 2026-07-06 01:56:14 +05:30
Pritom Mazumdar 0f2295c0c0
feat(telemetry): instrument renderer failure and CTA events (#2360) 2026-07-05 21:42:54 +05:30
Mukesh R 860fa5a242
fix(sidebar): open New Project tooltip rightward when collapsed (#2431)
Fixes #2430
2026-07-05 19:05:47 +05:30
neversettle 21294e6481
fix: consolidate PR summary status details (#2406)
* fix: consolidate pr summary status details

* chore: format pr summary changes

* chore: format rebased frontend files

* chore: format generated route tree

* fix: count pr summary overflow by rendered links

* fix: remove duplicate pr summary action copy

* fix: preserve pr summary hidden link totals

* fix: keep board pr footer lifecycle-only
2026-07-05 18:50:42 +05:30
PRADEEP SAHU 608333bd54
fix(ui): deduplicate agent catalog error messages in CreateProjectAgentSheet (#2428) 2026-07-05 18:45:43 +05:30
neversettle cbaffd2cb4
fix: simplify kanban pr footer (#2374) 2026-07-05 18:40:45 +05:30
Apoorv Singh 6cc7cc3e4e
fix(browser): remove excess padding around the inspector Browser tab (#2423) 2026-07-05 17:48:15 +05:30
NIKHIL ACHALE 821cda64a8
feat(cli): context-aware agent spawn with agent catalog and auth preflight (#2411)
* feat(cli): add agent command for listing and refreshing agent catalog

- Implemented `ao agent ls` command to list supported agents and their installation/auth readiness.
- Added `--refresh` flag to refresh local install/auth probes before listing agents.
- Introduced JSON output option with `--json` for raw agent catalog response.
- Updated tests to cover new agent command functionality, including cached catalog usage and refresh behavior.
- Enhanced error handling and output formatting for better user experience.

* docs: add CLI Guide link to README for direct usage instructions

* docs: clarify direct CLI usage

* fix: tighten spawn agent preflight

* feat(cli): implement agent readiness probe and update API specifications
2026-07-04 21:23:03 +05:30
Harshit Singh Bhandari 2c08597ee6
refactor(adapters): cut ~3,100 LOC of redundancy in the agent adapter layer (#2349) (#2355)
* refactor(adapters): add shared helpers for adapter dedup (#2349)

Introduce the shared building blocks the per-adapter cleanup will use:
- ports.NormalizePermissionMode (finding 3)
- hookutil.FileExists (finding 10)
- binaryutil.ResolveBinary + BinarySpec (finding 2)
- activitystate.StandardDeriveActivityState (finding 4)
- agentbase.Base embed + StandardSessionInfo (findings 6-9)

No adapters wired up yet; behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(goose): convert to shared adapter helpers (reference) (#2349)

Reference conversion proving the shared packages against real tests:
- hooks.go collapses onto hooksjson.Manager (finding 1)
- ResolveGooseBinary via binaryutil.BinarySpec (finding 2)
- gooseMode uses ports.NormalizePermissionMode (finding 3)
- activity.go deleted; dispatch points at activitystate (findings 4, 5)
- SessionInfo via agentbase.StandardSessionInfo; Base embed drops the
  GetConfigSpec/GetPromptDeliveryStrategy no-ops (findings 6-8)
- fileExists/atomicWriteFile copies removed (findings 5, 10)

Also adds hooksjson package + activitystate test. goose: 327->~90 LOC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(adapters): dedup remaining 22 agent adapters onto shared helpers (#2349)

Applies the shared helpers across every remaining adapter:
- hooksjson.Manager for the matcher-group cohort (claudecode, qwen, droid)
- binaryutil.BinarySpec for 19 binary resolvers (aider/cursor/opencode/codex
  keep their special resolvers; all now use hookutil.FileExists)
- ports.NormalizePermissionMode replaces 15 private copies
- agentbase.Base embed drops the GetConfigSpec/GetPromptDeliveryStrategy no-ops
  (and GetAgentHooks/GetRestoreCommand/SessionInfo no-ops on hookless adapters)
- agentbase.StandardSessionInfo replaces the per-adapter metadata readers
- activitystate.StandardDeriveActivityState via dispatch for the 8 name-only
  derivers; their activity.go files removed (claudecode/codex/droid/agy/opencode
  keep payload-parsing derivers)
- 4 private atomicWriteFile copies missing fsync now use hookutil.AtomicWriteFile
- 23 private fileExists copies removed

Full backend build + vet + test suite green (1647 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* fix(binaryutil): preserve per-adapter Windows candidate order (#2349)

Review feedback: the shared resolver hardcoded Windows candidate order as
APPDATA then LOCALAPPDATA, which flipped Kiro's lookup so the npm shim
(%APPDATA%\npm\kiro-cli.*) was probed before the native install
(%LOCALAPPDATA%\Programs\kiro\kiro-cli.exe). A fixed order can't preserve every
adapter's original order (goose/vibe want APPDATA first, kiro wants LOCALAPPDATA
first), so BinarySpec now takes an ordered WinPaths []WinPath list where each
entry names its base (WinAppData/WinLocalAppData/WinHome). Every adapter's list
reproduces its pre-refactor order exactly; kiro's native path is restored to
first.

go build / vet / test all green (1647 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(adapters): drop unused resolvedBinary writes flagged by govet (#2349)

Embedding agentbase.Base (value receiver) lets govet's unusedwrite analyzer
prove that setting resolvedBinary in tests that only call Base-promoted methods
(GetConfigSpec/GetPromptDeliveryStrategy/SessionInfo/cancellation) is a dead
write. Drop the field from those 23 constructions; GetLaunchCommand/GetRestore
tests that actually read it keep it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: gofmt manager_test.go struct alignment (#2349)

Inherited via the merge of main: a SessionRecord literal aligned its Metadata
field across a multi-key line, which gofmt/goimports rejects. One-line reformat
to unblock CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(adapters): fix stale resolver fallback comments (#2349)

Review nit: 6 Resolve*Binary functions now delegate to binaryutil.ResolveBinary,
which returns a wrapped ports.ErrAgentBinaryNotFound rather than the bare binary
name. Update their doc comments (kiro, vibe, amp, agy, crush, cline) to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-04 20:49:15 +05:30
Khushi Diwan 5d7ad6137d
Fix restored terminal reconnect (#2313)
* fix: reconnect restored terminals

- Reopen terminal mux after restored sessions become live
- Cover restore with unchanged terminal handles

* fix: limit restored terminal reconnects

* chore: format with prettier [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-04 18:29:04 +05:30
NIKHIL ACHALE cc75a519ab
fix: replace orchestrators through safe canonical branch handoff (#2338)
* fix: replace orchestrators through canonical handoff

* fix(tests): correct logic in TestRetireForReplacementCapturesAndReleasesWorkspace

* fix: enhance tests and add project config handling in service and workspace components

* fix: update project summary to include orchestrator agent and adjust related tests

* fix: block orchestrator replacement on runtime teardown failure

* fix: enhance orchestrator handling in ProjectSettingsForm and SessionsBoard components

* fix: rename parameter for clarity in projectConfigPtr function
2026-07-04 11:35:03 +05:30
NIKHIL ACHALE a639e2025c
feat: add agent catalog/auth API and safer orchestrator switching (#2309)
* feat: add agent catalog API and integrate with project settings

- Implemented AgentsController to handle /agents endpoint, returning a list of supported and installed agents.
- Created agent inventory service to manage agent data and detect installed agents.
- Updated ProjectSettingsForm to fetch and display agent information, including installed and supported agents.
- Enhanced error handling for agent detection and orchestrator restarts.
- Added tests for agent catalog and service to ensure correct functionality and error handling.

* Implement agent authorization status checks and update frontend to reflect changes

- Added `AuthStatus` method to various agent plugins to check authorization status using CLI probes.
- Introduced `authprobe` package to handle common CLI command checks for agent authorization.
- Updated backend tests to include scenarios for authorized and unauthorized agents.
- Modified frontend API schema to include `authorized` counts and `authStatus` for agents.
- Enhanced `ProjectSettingsForm` to display authorized agents and their statuses, including prompts for login when necessary.
- Adjusted agent selection logic to prioritize authorized agents and provide feedback for unauthorized or uninstalled agents.

* fix: simplify orchestrator replacement retry flow

* refactor:  cache agent catalog ,remove AgentCounts schema and related references from API and frontend

* fix: clarify orchestrator replacement recovery state

* feat: enhance project settings and agent management

- Updated NewTaskDialog tests to increase timeout for async operations.
- Modified ProjectSettingsForm tests to improve agent handling and validation messages.
- Refactored ProjectSettingsForm component to streamline agent selection and validation logic.
- Introduced new agent service to manage agent inventory and authentication status.
- Improved Sidebar tests to ensure proper agent options are loaded and handled.
- Enhanced SessionsBoard component by removing unused imports and optimizing state management.
- Fixed Select component styling for better consistency in UI.
- Added error handling for AO daemon readiness in ShellLayout.

* chore: format with prettier [skip ci]

* feat: add AuthStatus method documentation and improve error handling in session manager

* feat: enhance agent authentication and configuration management

- Implement local authentication status checks for PI, Qwen, and Vibe agents.
- Introduce JSON-based authentication status retrieval for PI agents.
- Add environment variable checks for Qwen agents and improve settings file parsing.
- Enhance Vibe agent authentication with support for environment variables and session logs.
- Update agent service to handle asynchronous probing for installed and authorized agents.
- Modify session manager to support prompt delivery strategies based on agent capabilities.
- Improve frontend agent selection UI with loading states and error handling.
- Add tests for new authentication logic and session management features.

* chore: format with prettier [skip ci]

* test: fix session manager fake after rebase

* feat: enhance agent authentication status checks

- Implement local authentication status checks for the Devin, Droid, Kiro, and other agents.
- Add support for reading credentials from specific configuration files and environment variables.
- Introduce new tests for various agents to ensure proper authentication status reporting.
- Refactor existing authentication logic to improve clarity and maintainability.
- Remove deprecated agent setup warnings from the SessionsBoard component in the frontend.

* feat: clear environment variables in auth status tests for Aider and OpenCode

* feat: enhance error handling in authentication status functions and update component props

* feat: integrate fallback agents in RequiredAgentField and streamline props handling

* fix: refactor Sidebar test imports and parameters for clarity

* refactor: remove orchestrator retirement logic and related tests

- Deleted the RetireOrchestrator function and its associated error handling.
- Removed tests related to orchestrator retirement and state management.
- Simplified ProjectSettingsForm by eliminating orchestrator restart logic and related UI elements.
- Updated API client mocks to reflect the removal of orchestrator-related functionality.

* feat: enhance agent management and error handling

- Added agent refresh functionality in ProjectSettingsForm with UI updates for agent availability.
- Implemented `refreshAgents` API call to fetch the latest agent catalog.
- Updated agent selection logic to disable unavailable agents and show appropriate error messages.
- Enhanced error handling in `apiErrorMessage` to include daemon error codes alongside messages.
- Created new test cases for agent availability and error handling in Sidebar component.
- Introduced `ResolveBinary` method for multiple agent adapters to standardize binary resolution.
- Added new agent adapter files for various agents (e.g., Aider, Claude Code, etc.) to support binary resolution.

* chore: format with prettier [skip ci]

* fix: satisfy backend lint checks

* refactor: clean up authentication logic and improve error handling across agents

* chore: format with prettier [skip ci]

* feat(authprobe): enhance status classification for authentication outputs and add tests for explicit false/true keys
chore(docs): update README to remove outdated agent adapter contract references
fix(components): improve agent selection logic to handle unknown auth status and update related tests

* chore: format with prettier [skip ci]

* refactor: remove shell environment authentication logic and update related tests

* feat(tests): integrate QueryClient for agent data in CreateProjectAgentSheet tests

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-04 10:59:34 +05:30
Anirudh Sharma d18ea87f57
feat(tracker): GitHub issue intake (backend + dashboard) (#2325)
* feat(tracker): GitHub issue intake observer

Adds an opt-in daemon poll loop that spawns one worker session per
eligible open GitHub issue, keyed by the canonical "github:<native>"
id so restarts cannot double-spawn. Eligibility requires an explicit
label or assignee rule to avoid draining an entire backlog.

Provider dispatch goes through a TrackerResolver interface
(SingleTrackerResolver for now) so Linear/Jira can be added later as
new adapters plus a resolver-map entry, without touching the poll,
eligibility, or backoff logic. Reuses the existing rate-limit-aware
GitHub tracker adapter and backs off per-project on any poll failure
(including rate limits) instead of retrying in a tight loop.

Closes #2324.

* feat(frontend): surface GitHub tracker intake in project settings

Adds a Tracker Intake card to project settings (enable, repository
override, labels, assignee) with inline validation requiring at
least one label or assignee before intake can be saved. Session
cards and the inspector show the canonical "github:<native>" issue
id when a session was spawned by intake.

Regenerated frontend/src/api/schema.ts against the backend's
TrackerIntakeConfig. The provider is currently fixed to "github";
adding a picker for future providers is additive once the backend
enum grows.

Closes #2324.

* fix(tracker): start the intake observer unconditionally at boot

startTrackerIntake scanned projects once at daemon startup and skipped
starting the observer loop entirely when none had intake enabled yet.
Poll() already re-reads every project's config on each tick and skips
disabled projects there, so the boot-time gate only broke the common
case: enabling intake on a project after the daemon is already running
silently never got picked up until the next restart, with no error or
log line to explain why.

Always start the loop; the adapter (and its token resolution) stays
lazy regardless, so there's no added cost when intake is unused.

Found while manually verifying issue intake end-to-end against a live
dev daemon: enabled intake via the settings UI, saved successfully,
but no session ever spawned for a matching labeled issue.

* fix(frontend): show auto-detected repo link, hide labels input for now

The Electron app only registers git projects today, so the daemon
always has a usable git origin to derive owner/repo from when
trackerIntake.repo is unset (trackerRepo() in observer.go, already
covered by every Poll-level test in observer_test.go). Replace the
manual Repository input with a read-only link derived client-side
from the project's own git origin, purely for display — the daemon's
own derivation at poll time is unaffected either way.

Comment out the Labels input; Assignee is the only intake eligibility
rule editable from this form for now. form.intakeLabels/intakeRepo
stay wired into buildIntake so a value set via the CLI round-trips
on a UI save instead of being silently cleared.

* chore: format with prettier [skip ci]

* feat(frontend): offer issue intake at project creation

Add the GitHub tracker intake controls to the create-project sheet so a
project can opt into issue-driven worker spawning at creation time, not
only later via Settings.

- Extract the shared IntakeFields component (enable toggle, assignee
  input, validation, credential hint) plus buildIntake/intakeNeedsRule/
  deriveGitHubRepo helpers into IntakeFields.tsx, and consume it from
  ProjectSettingsForm so the two surfaces can't drift.
- CreateProjectAgentSheet renders IntakeFields (no repo-preview row,
  since the git origin isn't known there; the daemon derives the repo).
  The selection now carries an optional trackerIntake payload, gated by
  the same "requires a label or assignee" rule the backend enforces.
- Thread trackerIntake through Sidebar's onCreateProject into the
  POST /api/v1/projects config. No backend change: the endpoint already
  accepts a full ProjectConfig and the intake observer picks up newly
  enabled projects on its next tick.

Verified in the web preview: enabling reveals the assignee field and
gates submit until a rule is set; submit builds the intake payload.

* chore: format with prettier [skip ci]

* feat(frontend): simplify intake controls in the create-project sheet

Reduce the create sheet's tracker-intake block to the essentials: the
enable toggle plus an info icon whose hover tooltip explains what
enabling does, then the assignee input. Drop the descriptive intro
paragraph, the inline "requires a label or assignee" guard text, and
the credential hint — the sheet stays minimal and submit gating already
communicates the missing rule via the disabled button.

- IntakeFields gains a `compact` prop: hides the prose, folds the
  explanation into an Info tooltip, and drops the trailing help text.
  The tooltip is wrapped in its own TooltipProvider so the component is
  self-contained regardless of ancestor. The verbose settings card is
  unchanged (renders without `compact`).
- Assignee placeholder reworded to "type username or * for any".

Verified in the web preview: the info tooltip surfaces the one-line
description on hover, the assignee placeholder is updated, and no other
copy remains in the create sheet.

* perf(tracker): cache GitHub issue lists with ETag conditional requests

The intake observer polls Tracker.List for the same repo+filter every
tick, and each poll did an uncached GET + full JSON decode even when
nothing changed. Add HTTP conditional requests so unchanged polls are
cheap and don't consume the primary rate limit.

- Tracker holds a per-request-path cache of {etag, mapped issues},
  guarded by a mutex. The key space is bounded by intake-enabled
  repo/filter pairs, so no eviction is needed.
- List sends If-None-Match with the cached ETag (verbatim, preserving
  weak validators). On 304 it returns the cached issues without
  re-decoding (GitHub 304s don't count against the primary rate limit);
  a rotated ETag on the 304 is recorded. On 200 it stores the new
  {etag, issues}, or drops a stale entry if the response omits an ETag.
  A 304 without a prior validator falls back to an unconditional refetch.
- Extract the HTTP round-trip into roundTrip(); do() delegates to it so
  Get and Preflight keep byte-for-byte identical behavior. roundTrip
  owns If-None-Match, ETag capture, and 304 handling before
  classifyError.

Tests cover revalidation returning cached issues on 304, ETag rotation
on change, separate cache keys per filter, and no caching when the
response carries no ETag.

* feat(frontend): trim tracker intake copy in project settings

Reduce the settings Tracker Intake card to a one-line description
("Auto-spawn worker sessions from matching tracker issues.") and drop
the trailing credential/daemon-restart hint. The compact create sheet is
unaffected (it already renders neither).

* feat(tracker): scope v1 intake to assignee-only

Per PR review, labels were a persisted/API/CLI eligibility rule while the
UI only ever exposed assignee — surface beyond the intended v1 scope.
Remove labels from intake end-to-end; assignee becomes the required and
only eligibility rule.

- domain: drop TrackerIntakeConfig.Labels; Validate() now requires a
  non-empty assignee when enabled (was "labels or assignee").
- observer: pass only State+Assignee into ListFilter; drop the label
  match loop in issueMatchesConfig.
- CLI: remove --tracker-label and its plumbing.
- Regenerate openapi.yaml + schema.ts (labels gone from the schema).
- frontend: drop labels from IntakeForm/buildIntake/intakeNeedsRule and
  the settings form state; validation copy now "requires an assignee".
  Remove the label round-trip test; keep assignee coverage.

The generic domain.ListFilter.Labels adapter capability is retained;
only intake stops using it.

* fix(tracker): paginate GitHub issue listing with page-aware ETag cache

Per PR review, single-page listing is a correctness bug for intake, not
just a bounded v1 tradeoff: with more than a page of eligible open
issues, AO re-sees the same first page every poll, the persisted
issue_id dedupe skips the already-spawned first-page issues, and later
pages are never fetched or spawned.

- List now requests per_page=100 and follows the GitHub Link header
  rel="next" until no next link remains, accumulating issues across all
  pages. A maxListPages guard fails loud on a pathological Link cycle.
- The ETag cache is page-aware: each entry stores {etag, issues,
  nextPath} keyed by the per-page request path, so a 304 Not Modified
  still continues to the cached next page. roundTrip now surfaces the
  parsed next path alongside the ETag; do() delegates unchanged so
  Get/Preflight keep identical behavior.
- ListFilter.Limit becomes an optional total-result cap (page size is
  fixed at the provider max).

Tests cover multi-page accumulation, all-304 revalidation continuing the
cached chain, page-count shrink orphaning a stale entry, and Link
header parsing.

* style(session_manager): gofmt manager_test.go to unblock CI

The session-restart test carried a struct-literal alignment that gofmt
(and golangci-lint's goimports) reject, failing the build-test and lint
jobs. Whitespace-only reformat; no test logic changes.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
2026-07-04 02:52:14 +05:30
Adil Shaikh 4557146681
fix: render raw activity state in inspector (#2278)
* fix: render raw session activity in inspector

* fix: hide unknown activity state in inspector
2026-07-04 00:29:32 +05:30
Harshit Singh Bhandari 8cadea5b16
fix(ci): gofmt manager_test.go to unbreak main (#2386)
manager_test.go landed from #2350 with a gofmt violation (extra spaces
aligning a struct field), turning main red: build-test's `gofmt -l .`
and lint's goimports both flag it. #2350's merge push never ran the Go
workflow, so it went unnoticed until the next push surfaced it.

Pure `gofmt -w` output, one whitespace line, no behavior change.
2026-07-03 22:42:27 +05:30
Harshit Singh Bhandari c9cff5f22e
feat(sidebar): nightly channel badge on the wordmark (#2379)
* feat(sidebar): purple nightly badge on the wordmark

Add a small purple pill labeled "nightly" next to the "Agent Orchestrator"
wordmark in the sidebar header. The badge:
- is derived from the running app version string (contains "-nightly."),
  not the update-channel setting, so it reflects the actual binary identity
- is hidden in the collapsed icon rail (group-data-[collapsible=icon]:hidden)
- uses a new --purple CSS token added to both dark (#a78bfa) and light
  (#7c3aed) palette blocks in styles.css, styled via color-mix following
  the existing reviewer-status idiom
- is shrink-0 so it does not crowd the flex-1 truncate wordmark span

Stable builds show no badge. Dev builds ("0.0.0-preview") show no badge.

Part of AgentWrapper/agent-orchestrator#2377.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-03 22:29:07 +05:30
Harshit Singh Bhandari 6f09a1fc78
fix(frontend): show session display name in terminal toolbar header (#2382)
* fix(frontend): show session display name in terminal toolbar header

The terminal toolbar showed the raw session id. Show the display name
(session.title, same field the sidebar renders) instead, and
'Orchestrator' for orchestrator sessions.

Fixes #2380

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(frontend): cover terminal toolbar session label

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:23:57 +05:30
Harshit Singh Bhandari f92ff1111a
feat(release): add important flag to nightly feed manifests (#2378)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:23:03 +05:30
Vaibhaav Tiwari 52d8c6051a
docs: add contributing guide (#2385)
Co-authored-by: Vaibhaav <user@example.com>
2026-07-03 22:17:02 +05:30
Vaibhaav Tiwari 365c99f705
docs: add first-timer README overview (#2384)
Co-authored-by: Vaibhaav <user@example.com>
2026-07-03 21:59:51 +05:30
Harshit Singh Bhandari a32d507eb8
feat(scm): detect cross-fork PRs by scanning all project remotes (#2368)
The SCM observer only polled a project's push origin for open PRs, so a
PR opened from the fork against an upstream base repo (the standard
fork -> upstream flow) was never discovered: GitHub lists a PR under its
base repo, not its head, so an origin-only scan can't see it. Sessions
that opened such PRs showed no PR on the dashboard.

Scan every GitHub remote in the project checkout (origin + upstreams /
mirrors) for open PRs, and attribute a PR to a session only when its
head branch lives in that session's push origin. This surfaces
cross-fork PRs while preserving the no-misattribution guarantee: a
stranger's fork PR has a head repo no session owns and is dropped.

Tracked cross-fork PRs are keyed for refresh by their own recorded repo
(the upstream base) so the GraphQL refetch targets the right repo.
2026-07-03 20:50:41 +05:30
Harshit Singh Bhandari 3aa8e044b2
test(session_manager): prove agent sessions survive daemon restart/upgrade (#2335) (#2350)
* test(session_manager): prove sessions survive daemon restart and re-adopt (#2335)

Add an end-to-end Reconcile() durability test covering the full mix of
session states a daemon restart/upgrade leaves behind: an alive
orchestrator and worker are adopted in place under their original ids
(no id increment, runtime never torn down), a worker whose runtime died
with the daemon is captured and relaunched under its original id, and a
truly-dead unmarked session is not resurrected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-03 19:59:27 +05:30
Harshit Singh Bhandari 57ba468fea
feat(skills): add using-ao skill, install it under the data dir, and wire it into session prompts (#2365)
* feat(prompt): point sessions at using-ao skill for CLI usage

Append a short standing-prompt pointer to skills/using-ao/SKILL.md so
orchestrator and worker sessions read the skill catalog before using the
ao CLI, instead of inlining the whole command surface.

* docs(skills): add using-ao skill cataloging the ao CLI

Full command catalog sourced verbatim from 'ao --help' recursively:
SKILL.md (top-level catalog), commands/*.md (one per command with every
subcommand, flag, and examples), and references.md (natural-language to
command table).

* feat(skills): install using-ao skill under the data dir on daemon boot

A repo-relative skills/ path only resolves when a worker's project is the
AO repo itself; the ao CLI is used in every session regardless of project.
Move the skill into the Go module, embed it, and clobber-install it into
<dataDir>/skills/using-ao at daemon boot (before any session spawns, so no
locking is needed). The embedded copy is the single source of truth: a new
daemon build always refreshes it, so there is no version marker to drift.

Flip the session-prompt pointer to the absolute installed path so it
resolves from any project's worktree.

* fix(skillassets): tighten install perms to satisfy gosec

golangci-lint gosec flagged G301 (dir perms) and G306 (file perms).
Use 0o750 for dirs and 0o600 for files, matching the repo convention
for daemon-owned single-user state.

* Update preview.md with dev server instructions

Added note about using the correct URL for the dev server.

---------

Co-authored-by: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com>
2026-07-03 18:11:15 +05:30
Dushyant Singh Hada 7e409d438b
fix: clear stale preview URL from SQLite and prevent stale white screen on preview clear (#2358)
* fix: remove auto-switch-to-summary effect, keep browser tab on clear

* fix: clear browser preview when session is terminated
2026-07-03 15:38:43 +05:30
VenkataSakethDakuri fadbdd26cd
Fix and enrich the orchestrator coordinator system prompt so it matches the current CLI and teaches harness selection + command discovery (#2361)
* Prompt changed for correct agent spawning

* Fix formatting issue
2026-07-03 01:36:14 +05:30
Harshit Singh Bhandari e56248759e
ci(prettier): make workflow check-only instead of auto-committing (#2356)
Convert the Prettier workflow from auto-formatting and pushing commits
back to the PR branch into a check-only job. It now runs
`prettier@3 --check .`, which annotates and fails on unformatted files
but never writes, commits, or pushes anything. Drops the auto-commit
step and narrows permissions to `contents: read`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:49:29 +05:30
Harshit Singh Bhandari 6186458df7
docs(bug-triage): retarget skill at the Go rewrite and upstream repo (#2339)
* docs(bug-triage): retarget skill at the Go rewrite and upstream repo

Rewrite the bug-triage skill for this repository: file issues/PRs on the
upstream AgentWrapper/agent-orchestrator, swap the Zellij runtime notes for
tmux (ConPTY on Windows), refresh the label list to match upstream, and drop
ReverbCode-specific naming. Port 3001, ~/.ao paths, and the CLI/daemon layout
are unchanged and were re-verified against backend/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-02 22:03:13 +05:30
Anirudh Sharma e74fb65a85
feat(review): support additional reviewer harnesses (#2306)
* feat(review): support more reviewer harnesses

* fix(review): preserve reviewer daemon context

* fix(review): allow printf-piped review commands in reviewer allowlists

The review prompt now pipes JSON into `gh api` and `ao review submit`
via `printf '%s' ... | cmd` (heredocs break in interactive PTY panes).
Widen the claude-code allowlist with `Bash(printf:*)` and the opencode
permission policy with `printf * | gh api *` / `printf * | ao review
submit *` so those piped commands pass each tool's allowlist. Cover
both with tests that model each tool's matching semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-02 19:25:18 +05:30
VenkataSakethDakuri c267420632
feat(projects): default worker and orchestrator agents to claude-code (#2321)
Pre-fill both role agents with claude-code in the create-project dialog and project settings instead of requiring a selection; the required-agent validation gate is removed. Users can still change either agent at creation or later in settings.
2026-07-01 21:56:00 +05:30
Laxman f74ebf379f
docs: add telemetry.md with accurate description of PostHog integration (#2308)
Replaces the inaccurate opt-in framing. Documents that telemetry is on
by default in the renderer, covers session recording, privacy redaction,
the persistent install ID, and how to override or disable via build-time
env vars.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 14:15:45 +05:30
Khushi Diwan 6a7ba46078
fix: set terminal type for tmux attach (#2312)
- Force TERM for tmux attach processes
- Cover missing and unsupported TERM values
2026-07-01 03:27:29 +05:30
Harshit Singh Bhandari d302414f52
feat(sidebar): inline-edit session display name via hover pencil (#2307)
* feat(sidebar): inline-edit session display name via hover pencil

Reuse the merged PATCH /sessions/{id} rename endpoint (#2302). A pencil
reveals on session-row hover/focus; clicking it swaps the label for an
inline input capped at 20 chars (same as spawn --name). Enter/blur saves
and invalidates the workspace query so the rename survives reload; Escape
cancels.

Refs #2301

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sidebar): cover inline session rename save/cancel/cap

Refs #2301

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-01 03:19:27 +05:30
Harshit Singh Bhandari 60b651f20e
fix(session): clear shutdown-saved marker so killed sessions stay dead (#2319) (#2320)
After Cmd+Q quit + reopen, sessions the user killed reappeared as alive.
The session_worktrees "shutdown-saved" restore marker was write-only:
RestoreAll auto-relaunches any terminated session that still has a marker,
but Kill never deleted it and RestoreAll never consumed it. A session that
survived one reopen cycle (gaining a marker via reconcileLive) and was then
killed still carried the marker, so the next boot resurrected it.

Two-layer fix:
- Kill now deletes the marker (best-effort) after MarkTerminated: a user
  kill is explicit terminal intent and must not leave a resurrection marker.
- RestoreAll deletes the marker after consuming preserveRef and attempting
  relaunch, making it one-shot so it never outlives a single restart. A
  still-live session re-acquires a fresh marker at the next quit.

Manual Restore stays marker-independent, so killed sessions remain
restorable on demand, just not auto-resurrected on boot.

Adds DeleteSessionWorktrees to the Manager Store interface (the concrete
store already implements it) and tests covering all three behaviors.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 03:00:09 +05:30
neversettle 010c7e4c1a
feat: default reviewer is always claude-code (#2241) - temporary change 2026-06-30 21:25:26 +05:30
301 changed files with 17839 additions and 6583 deletions

View File

@ -9,6 +9,11 @@ on:
schedule:
- cron: "30 13 * * *" # 13:30 UTC = 19:00 IST daily
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."
type: boolean
default: false
jobs:
guard:
@ -201,6 +206,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NIGHTLY_VERSION: ${{ needs.guard.outputs.version }}
NIGHTLY_IMPORTANT: ${{ inputs.important || 'false' }}
run: |
# Forge stamps package.json to VERSION without +build metadata and
# publishes to v<that>. Match it.
@ -208,7 +214,9 @@ jobs:
tag="v$version"
mkdir -p dist
gh release download "$tag" --dir dist --clobber
node scripts/feed.mjs dist "$version" nightly
important_flag=""
[ "$NIGHTLY_IMPORTANT" = "true" ] && important_flag="--important"
node scripts/feed.mjs dist "$version" nightly $important_flag
shopt -s nullglob
assets=(dist/nightly*.yml dist/*.blockmap)
if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi

View File

@ -1,11 +1,10 @@
name: Prettier
# Auto-formats the codebase on every push and commits the result back.
# Formatting is a CI concern — developers never need to run Prettier locally
# and formatted output never shows up as local uncommitted changes.
#
# GitHub Actions does not re-trigger workflows on commits made with GITHUB_TOKEN,
# so there is no feedback loop risk.
# Check-only: reports formatting issues without modifying the branch.
# The job fails (and annotates unformatted files) when `prettier --check`
# finds files that are not formatted, but it never writes, commits, or
# pushes anything back to the PR. Developers run `npx prettier@3 --write .`
# locally to fix the reported files.
on:
push:
@ -18,21 +17,9 @@ jobs:
format:
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Format with Prettier
run: npx --yes prettier@3 --write .
- name: Commit formatted files
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git diff --quiet && exit 0
git add -A
git commit -m "chore: format with prettier [skip ci]"
git push
- name: Check formatting with Prettier
run: npx --yes prettier@3 --check .

View File

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

27
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,27 @@
# Contributing
We love contributions! Join our community on Discord to get started.
## Join us on Discord
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white&logoSize=auto)](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

303
README.md
View File

@ -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,210 +9,156 @@
[![Contributors](https://img.shields.io/github/contributors/AgentWrapper/agent-orchestrator)](https://github.com/AgentWrapper/agent-orchestrator/graphs/contributors)
[![Twitter](https://img.shields.io/badge/Twitter-1DA1F2?logo=twitter&logoColor=white)](https://x.com/aoagents)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/UZv7JjxbwG)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](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.
![Agent Orchestrator Dashboard](ao-dashboard-preview.png)
### 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>
[Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing)
<img src="docs/assets/readme/dashboard.png" alt="Agent Orchestrator dashboard showing parallel coding agent sessions" width="100%" />
</div>
---
## What is Agent Orchestrator?
Agent Orchestrator is a meta-harness agent IDE for running AI coding agents in parallel. It gives terminal-based agents like Claude Code, Codex, Cursor, Aider, Goose, and others a shared workspace where their sessions, terminals, branches, pull requests, and feedback loops can be supervised from one place.
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.
Agent Orchestrator is built to keep that loop visible and manageable. It helps you:
- Start multiple agents from the same project without mixing their work
- Keep every session in a separate git worktree
- See which agents are working, waiting, finished, or blocked
- 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:
1. Add a project you want agents to work on.
2. Start one or more sessions from the desktop app or CLI.
3. AO creates an isolated git worktree for each session.
4. AO launches the selected coding agent in that session's terminal runtime.
5. The local daemon watches session state, terminal activity, pull requests, CI, and review feedback.
6. The desktop app and CLI show the current state and let you send follow-up instructions to the right session.
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.
## 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:**
- `tmux` (Darwin/Linux) - For Unix runtime
- `gh` (GitHub CLI) - For authenticated GitHub API calls
### Installation
Download the latest release for your platform:
You can also download the latest desktop build 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) |
| -------- | ------------------------------------------------------------------------------------------------- |
| 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)
## Witness AO's Journey on X
---
## 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 |
| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior |
| 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
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white&logoSize=auto)](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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
@ -200,3 +200,15 @@ func TestHooksLifecycle(t *testing.T) {
t.Fatal("expected hooks to be uninstalled after UninstallHooks")
}
}
func TestAuthStatus(t *testing.T) {
plugin := &Plugin{resolvedBinary: "agy"}
status, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != ports.AgentAuthStatusAuthorized {
t.Errorf("AuthStatus() = %v, want AgentAuthStatusAuthorized", status)
}
}

View File

@ -0,0 +1,17 @@
package agy
import (
"context"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
if _, err := p.ResolveBinary(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
}
return ports.AgentAuthStatusAuthorized, nil
}

View File

@ -0,0 +1,8 @@
package agy
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.agyBinary(ctx)
}

View File

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

View File

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

View File

@ -0,0 +1,131 @@
package aider
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
if _, err := p.ResolveBinary(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := aiderLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return ports.AgentAuthStatusUnknown, nil
}
var aiderAPIKeyEnvVars = []string{
"AIDER_API_KEY",
"AIDER_OPENAI_API_KEY",
"AIDER_ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
"GROQ_API_KEY",
"XAI_API_KEY",
"MISTRAL_API_KEY",
"COHERE_API_KEY",
}
func aiderLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, name := range aiderAPIKeyEnvVars {
if strings.TrimSpace(os.Getenv(name)) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
for _, path := range aiderConfigPaths() {
status, ok, err := aiderAuthStatusFromFile(path)
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if ok {
return status, true, nil
}
}
return ports.AgentAuthStatusUnknown, false, nil
}
func aiderConfigPaths() []string {
paths := []string{}
if cwd, err := os.Getwd(); err == nil && cwd != "" {
paths = append(paths,
filepath.Join(cwd, ".env"),
filepath.Join(cwd, ".aider.conf.yml"),
filepath.Join(cwd, ".aider.conf.yaml"),
)
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
paths = append(paths,
filepath.Join(home, ".env"),
filepath.Join(home, ".aider.conf.yml"),
filepath.Join(home, ".aider.conf.yaml"),
)
}
return paths
}
func aiderAuthStatusFromFile(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
text := strings.TrimSpace(string(data))
if text == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
if fileContainsAPIKey(text) {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func fileContainsAPIKey(text string) bool {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
lower := strings.ToLower(line)
if !strings.Contains(lower, "api") || !strings.Contains(lower, "key") {
continue
}
if valueAfterAssignment(line) != "" {
return true
}
}
return false
}
func valueAfterAssignment(line string) string {
for _, sep := range []string{"=", ":"} {
before, after, ok := strings.Cut(line, sep)
if !ok || !strings.Contains(strings.ToLower(before), "key") {
continue
}
value := strings.Trim(strings.TrimSpace(after), `"'`)
if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") {
return value
}
}
return ""
}

View File

@ -0,0 +1,77 @@
package aider
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestAiderLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) {
clearAiderAuthEnv(t)
t.Setenv("OPENAI_API_KEY", "sk-test")
status, ok, err := aiderLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestAiderLocalAuthStatusAuthorizedWithConfigFile(t *testing.T) {
clearAiderAuthEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.WriteFile(filepath.Join(home, ".aider.conf.yml"), []byte("openai-api-key: sk-test\n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := aiderLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestAiderLocalAuthStatusAuthorizedWithDotEnv(t *testing.T) {
clearAiderAuthEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.WriteFile(filepath.Join(home, ".env"), []byte("ANTHROPIC_API_KEY=sk-ant-test\n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := aiderLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestAiderLocalAuthStatusUnknownWhenMissing(t *testing.T) {
clearAiderAuthEnv(t)
t.Setenv("HOME", t.TempDir())
status, ok, err := aiderLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
func clearAiderAuthEnv(t *testing.T) {
t.Helper()
for _, name := range aiderAPIKeyEnvVars {
t.Setenv(name, "")
}
}

View File

@ -0,0 +1,8 @@
package aider
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.aiderBinary(ctx)
}

View File

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

View File

@ -0,0 +1,121 @@
package amp
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := ampLocalAuthStatus(ctx); err != nil || ok {
return status, err
}
return ampUsageAuthStatus(ctx, binary)
}
func ampLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(os.Getenv("AMP_API_KEY")) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
status, ok, err := ampSettingsAuthStatus(ampSettingsPath())
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
return status, ok, nil
}
func ampSettingsPath() string {
if path := strings.TrimSpace(os.Getenv("AMP_SETTINGS_FILE")); path != "" {
return expandHome(path)
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".config", "amp", "settings.json")
}
return ""
}
func ampSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
if path == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ports.AgentAuthStatusUnknown, false, nil
}
return ports.AgentAuthStatusUnknown, false, err
}
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, key := range []string{"amp.apiKey", "amp.api_key", "apiKey", "api_key"} {
if value, ok := settings[key]; ok {
if strings.TrimSpace(stringValue(value)) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnauthorized, true, nil
}
}
return ports.AgentAuthStatusUnknown, false, nil
}
func ampUsageAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) {
if binary == "" {
return ports.AgentAuthStatusUnknown, nil
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, err := authprobe.CmdRunner(probeCtx, binary, "usage", "--no-color")
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown {
return status, nil
}
if err == nil {
return ports.AgentAuthStatusAuthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}
func stringValue(value any) string {
switch v := value.(type) {
case string:
return v
default:
return ""
}
}
func expandHome(path string) string {
if path == "~" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
}
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return path
}

View File

@ -0,0 +1,99 @@
package amp
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestAuthStatusAuthorizedFromEnv(t *testing.T) {
t.Setenv("AMP_API_KEY", "amp-key")
got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAmpSettingsAuthStatusAuthorizedWithAPIKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(path, []byte(`{"amp.apiKey":"amp-key"}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := ampSettingsAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestAmpSettingsAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(path, []byte(`{"amp.apiKey":""}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := ampSettingsAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestAmpUsageAuthStatusAuthorizedOnSuccessfulUsage(t *testing.T) {
t.Setenv("AMP_API_KEY", "")
t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json"))
restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) {
if name != "amp" || !reflect.DeepEqual(arg, []string{"usage", "--no-color"}) {
t.Fatalf("command = %s %#v, want amp usage --no-color", name, arg)
}
return []byte("Credits remaining: 100"), nil
})
defer restore()
got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAmpUsageAuthStatusUnauthorizedFromUsageOutput(t *testing.T) {
t.Setenv("AMP_API_KEY", "")
t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json"))
restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) {
return []byte("login required"), errors.New("exit status 1")
})
defer restore()
got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusUnauthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized)
}
}
func mockAuthProbeRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() {
t.Helper()
previous := authprobe.CmdRunner
authprobe.CmdRunner = runner
return func() { authprobe.CmdRunner = previous }
}

View File

@ -0,0 +1,8 @@
package amp
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.ampBinary(ctx)
}

View File

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

View File

@ -0,0 +1,40 @@
package auggie
import (
"context"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
return auggieAccountAuthStatus(ctx, binary)
}
func auggieAccountAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) {
if binary == "" {
return ports.AgentAuthStatusUnknown, nil
}
probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
out, err := authprobe.CmdRunner(probeCtx, binary, "account", "status")
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown {
return status, nil
}
if err == nil {
return ports.AgentAuthStatusAuthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}

View File

@ -0,0 +1,54 @@
package auggie
import (
"context"
"errors"
"reflect"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestAuthStatusUsesAuggieAccountStatus(t *testing.T) {
restore := stubAuggieAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) {
if name != "auggie" {
t.Fatalf("binary = %q, want auggie", name)
}
if !reflect.DeepEqual(arg, []string{"account", "status"}) {
t.Fatalf("args = %#v, want [account status]", arg)
}
return []byte("Credits remaining: 42\n"), nil
})
defer restore()
status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized)
}
}
func TestAuthStatusUnauthorizedFromAuggieAccountStatus(t *testing.T) {
restore := stubAuggieAuthRunner(t, func(context.Context, string, ...string) ([]byte, error) {
return []byte("You are not currently logged in to Augment.\nRun 'auggie login' to authenticate first.\n"), errors.New("exit status 1")
})
defer restore()
status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized)
}
}
func stubAuggieAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() {
t.Helper()
previous := authprobe.CmdRunner
authprobe.CmdRunner = runner
return func() { authprobe.CmdRunner = previous }
}

View File

@ -0,0 +1,8 @@
package auggie
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.auggieBinary(ctx)
}

View File

@ -0,0 +1,134 @@
package authprobe
import (
"context"
"os/exec"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// DefaultCommands are cheap local auth/status probes common across agent CLIs.
// Unsupported commands usually exit quickly with help text and are treated as
// unknown rather than unauthorized.
var DefaultCommands = [][]string{
{"auth", "status"},
{"login", "status"},
{"providers", "list"},
}
// CmdRunner runs the command and returns the combined stdout/stderr.
// It is exposed as a package variable to allow mocking in tests.
var CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, arg...).CombinedOutput()
}
// CLIStatus runs bounded local CLI probes and classifies their output.
func CLIStatus(ctx context.Context, binary string, commands [][]string) (ports.AgentAuthStatus, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, err
}
if binary == "" {
return ports.AgentAuthStatusUnknown, nil
}
if len(commands) == 0 {
commands = DefaultCommands
}
for _, args := range commands {
status, err := commandStatus(ctx, binary, args)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status != ports.AgentAuthStatusUnknown {
return status, nil
}
}
return ports.AgentAuthStatusUnknown, nil
}
func commandStatus(ctx context.Context, binary string, args []string) (ports.AgentAuthStatus, error) {
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := CmdRunner(probeCtx, binary, args...)
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
status := StatusFromText(string(out))
if status != ports.AgentAuthStatusUnknown {
return status, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, nil
}
return ports.AgentAuthStatusUnknown, nil
}
// StatusFromText classifies common CLI auth/status output.
func StatusFromText(out string) ports.AgentAuthStatus {
text := strings.ToLower(out)
compactText := compact(text)
if hasAny(text,
"not logged in",
"not currently logged in",
"logged out",
"not authenticated",
"unauthenticated",
"authentication required",
"not authorized",
"unauthorized",
"login required",
"no credentials",
"0 credentials",
"no api key",
"no token",
`"loggedin": false`,
`"loggedin":false`,
) || hasAny(compactText,
`"authenticated":false`,
`'authenticated':false`,
"authenticated:false",
"authenticated=false",
`"authorized":false`,
`'authorized':false`,
"authorized:false",
"authorized=false",
`"logged_in":false`,
`'logged_in':false`,
"logged_in:false",
"logged_in=false",
`"loggedin":false`,
`'loggedin':false`,
"loggedin:false",
"loggedin=false",
) {
return ports.AgentAuthStatusUnauthorized
}
if hasAny(text,
"logged in",
"authenticated",
"authorized",
"token valid",
"api key found",
"credentials found",
`"loggedin": true`,
`"loggedin":true`,
) {
return ports.AgentAuthStatusAuthorized
}
return ports.AgentAuthStatusUnknown
}
func compact(text string) string {
return strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "").Replace(text)
}
func hasAny(text string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(text, needle) {
return true
}
}
return false
}

View File

@ -0,0 +1,96 @@
package authprobe
import (
"context"
"errors"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestCLIStatus_Mocked(t *testing.T) {
tests := []struct {
name string
mockOutput string
mockError error
wantStatus ports.AgentAuthStatus
wantError bool
}{
{
name: "authorized status check",
mockOutput: "User is logged in and authenticated",
wantStatus: ports.AgentAuthStatusAuthorized,
},
{
name: "unauthorized status check",
mockOutput: "You are not logged in",
wantStatus: ports.AgentAuthStatusUnauthorized,
},
{
name: "unknown status check with exit error",
mockOutput: "command not found or invalid syntax",
mockError: errors.New("exit status 1"),
wantStatus: ports.AgentAuthStatusUnknown,
},
{
name: "unknown status check with success but unrecognized output",
mockOutput: "some random output here",
wantStatus: ports.AgentAuthStatusUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save and restore CmdRunner
oldCmdRunner := CmdRunner
defer func() { CmdRunner = oldCmdRunner }()
CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
return []byte(tt.mockOutput), tt.mockError
}
status, err := CLIStatus(context.Background(), "mockbinary", nil)
if (err != nil) != tt.wantError {
t.Fatalf("unexpected error: %v", err)
}
if status != tt.wantStatus {
t.Errorf("CLIStatus() = %v, want %v", status, tt.wantStatus)
}
})
}
}
func TestStatusFromTextExplicitFalseKeys(t *testing.T) {
tests := []string{
`{ "authenticated": false }`,
`{ "authorized": false }`,
`authenticated=false`,
`authorized: false`,
`{ "logged_in": false }`,
`{ "loggedIn": false }`,
}
for _, out := range tests {
t.Run(out, func(t *testing.T) {
if got := StatusFromText(out); got != ports.AgentAuthStatusUnauthorized {
t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusUnauthorized)
}
})
}
}
func TestStatusFromTextExplicitTrueKeys(t *testing.T) {
tests := []string{
`{ "authenticated": true }`,
`{ "authorized": true }`,
`{ "loggedIn": true }`,
}
for _, out := range tests {
t.Run(out, func(t *testing.T) {
if got := StatusFromText(out); got != ports.AgentAuthStatusAuthorized {
t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusAuthorized)
}
})
}
}

View File

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

View File

@ -0,0 +1,75 @@
package autohand
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, err
}
return autohandConfigAuthStatus(autohandConfigPath())
}
func autohandConfigAuthStatus(configPath string) (ports.AgentAuthStatus, error) {
data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ports.AgentAuthStatusUnknown, nil
}
return ports.AgentAuthStatusUnknown, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnknown, nil
}
var config map[string]json.RawMessage
if err := json.Unmarshal(data, &config); err != nil {
return ports.AgentAuthStatusUnknown, err
}
authReady, authKnown := autohandCloudAuthReady(config)
if authReady {
return ports.AgentAuthStatusAuthorized, nil
}
if authKnown {
return ports.AgentAuthStatusUnauthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}
func autohandCloudAuthReady(config map[string]json.RawMessage) (ready, known bool) {
authRaw, ok := config["auth"]
if !ok {
return false, false
}
var auth struct {
Token string `json:"token"`
}
if err := json.Unmarshal(authRaw, &auth); err != nil {
return false, false
}
return usableSecret(auth.Token), true
}
func usableSecret(value string) bool {
normalized := strings.TrimSpace(value)
if normalized == "" {
return false
}
switch strings.ToLower(normalized) {
case "api key", "apikey", "your api key", "your-api-key", "your_api_key", "token", "your token", "your-token", "your_token", "changeme", "change-me", "replace-me", "replace_me":
return false
default:
return true
}
}

View File

@ -0,0 +1,76 @@
package autohand
import (
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestAutohandConfigAuthStatusAuthorized(t *testing.T) {
path := writeAutohandAuthConfig(t, `{
"auth": {"token": "session-token", "user": {"email": "agent@example.com"}},
"provider": "zai",
"zai": {"apiKey": "real-provider-key", "model": "glm-5.1"}
}`)
got, err := autohandConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAutohandConfigAuthStatusUnauthorizedWithMissingCloudToken(t *testing.T) {
path := writeAutohandAuthConfig(t, `{
"auth": {"token": ""},
"provider": "zai",
"zai": {"apiKey": "real-provider-key"}
}`)
got, err := autohandConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnauthorized)
}
}
func TestAutohandConfigAuthStatusAuthorizedWithPlaceholderProviderKey(t *testing.T) {
path := writeAutohandAuthConfig(t, `{
"auth": {"token": "session-token"},
"provider": "zai",
"zai": {"apiKey": "api key ", "model": "glm-5.1"}
}`)
got, err := autohandConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAutohandConfigAuthStatusUnknownWhenMissing(t *testing.T) {
got, err := autohandConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json"))
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusUnknown {
t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnknown)
}
}
func writeAutohandAuthConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package autohand
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.autohandBinary(ctx)
}

View File

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

View File

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

View File

@ -17,19 +17,22 @@
package claudecode
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"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"
)
@ -49,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
}
@ -60,6 +64,7 @@ func New() *Plugin {
var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// Manifest returns the adapter's static self-description.
func (p *Plugin) Manifest() adapters.Manifest {
@ -174,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
@ -258,15 +254,112 @@ 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],
info, ok := agentbase.StandardSessionInfo(session)
return info, ok, nil
}
// AuthStatus checks Claude Code's local authentication state without starting a
// session.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.claudeBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
if status, ok, err := claudeLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return info, true, nil
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, binary, "auth", "status").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
if status, ok := claudeAuthStatusFromOutput(out); ok {
return status, nil
}
if err != nil {
return ports.AgentAuthStatusUnauthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}
func claudeAuthStatusFromOutput(out []byte) (ports.AgentAuthStatus, bool) {
start := bytes.IndexByte(out, '{')
end := bytes.LastIndexByte(out, '}')
if start < 0 || end < start {
return ports.AgentAuthStatusUnknown, false
}
var status struct {
LoggedIn bool `json:"loggedIn"`
}
if json.Unmarshal(out[start:end+1], &status) != nil {
return ports.AgentAuthStatusUnknown, false
}
if status.LoggedIn {
return ports.AgentAuthStatusAuthorized, true
}
return ports.AgentAuthStatusUnauthorized, true
}
func claudeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
cfgPath, err := claudeConfigPath()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
return claudeConfigAuthStatus(cfgPath)
}
func claudeConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
var hasSubscription bool
if raw := root["hasAvailableSubscription"]; len(raw) > 0 {
_ = json.Unmarshal(raw, &hasSubscription)
}
var userID string
if raw := root["userID"]; len(raw) > 0 {
_ = json.Unmarshal(raw, &userID)
}
if strings.TrimSpace(userID) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
var oauthAccount map[string]any
if raw := root["oauthAccount"]; len(raw) > 0 {
if err := json.Unmarshal(raw, &oauthAccount); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
}
if len(oauthAccount) == 0 {
return ports.AgentAuthStatusUnknown, false, nil
}
if hasSubscription {
return ports.AgentAuthStatusAuthorized, true, nil
}
if accountUUID, ok := oauthAccount["accountUuid"].(string); ok && strings.TrimSpace(accountUUID) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
// claudeSessionUUID maps an AO session id onto a stable Claude Code
@ -302,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:
@ -328,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) {
@ -502,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()
}

View File

@ -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,7 +184,7 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) {
t.Fatal(err)
}
var config struct {
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
Permissions json.RawMessage `json:"permissions"`
}
if err := json.Unmarshal(data, &config); err != nil {
@ -260,7 +261,7 @@ func TestUninstallHooksRemovesClaudeHooks(t *testing.T) {
t.Fatal(err)
}
var config struct {
Hooks map[string][]claudeMatcherGroup `json:"hooks"`
Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"`
Permissions json.RawMessage `json:"permissions"`
}
if err := json.Unmarshal(data, &config); err != nil {
@ -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 {
@ -490,6 +491,97 @@ func TestManifestID(t *testing.T) {
}
}
func TestClaudeConfigAuthStatusAuthorizedWithOAuthSubscription(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
content := `{
"hasAvailableSubscription": true,
"oauthAccount": {
"accountUuid": "account-1",
"subscriptionCreatedAt": "2026-01-01T00:00:00Z"
}
}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeConfigAuthStatusAuthorizedWithOAuthAccount(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
content := `{"oauthAccount":{"accountUuid":"account-1"}}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeConfigAuthStatusAuthorizedWithUserID(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
if err := os.WriteFile(path, []byte(`{"userID":"user-1"}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeConfigAuthStatusUnknownWithoutOAuthIdentity(t *testing.T) {
path := filepath.Join(t.TempDir(), ".claude.json")
content := `{"oauthAccount":{}}`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := claudeConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
func TestClaudeAuthStatusFromOutputAuthorizedWithCleanJSON(t *testing.T) {
status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":true,"authMethod":"oauth_token"}`))
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeAuthStatusFromOutputAuthorizedWithPrefixedWarning(t *testing.T) {
output := []byte("warning: ignored config line\n{\"loggedIn\":true,\"authMethod\":\"oauth_token\"}\n")
status, ok := claudeAuthStatusFromOutput(output)
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClaudeAuthStatusFromOutputUnauthorized(t *testing.T) {
status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":false}`))
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, ".claude.json")

View File

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

View File

@ -0,0 +1,8 @@
package claudecode
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.claudeBinary(ctx)
}

View File

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

View File

@ -0,0 +1,122 @@
package cline
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := clineProviderAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return authprobe.CLIStatus(ctx, binary, nil)
}
type clineProvidersFile struct {
LastUsedProvider string `json:"lastUsedProvider"`
Providers map[string]clineProvider `json:"providers"`
}
type clineProvider struct {
Settings clineProviderSettings `json:"settings"`
}
type clineProviderSettings struct {
APIKey string `json:"apiKey"`
APIKeyAlt string `json:"apikey"`
Auth *clineProviderAuth `json:"auth"`
Provider string `json:"provider"`
}
type clineProviderAuth struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
}
func clineProviderAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
home, err := os.UserHomeDir()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if home == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
path := filepath.Join(home, ".cline", "data", "settings", "providers.json")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnauthorized, true, nil
}
var file clineProvidersFile
if err := json.Unmarshal(data, &file); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if len(file.Providers) == 0 {
return ports.AgentAuthStatusUnauthorized, true, nil
}
if provider, ok := configuredClineProvider(file); ok {
if providerAuthorized(provider.Settings) {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnauthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func configuredClineProvider(file clineProvidersFile) (clineProvider, bool) {
if file.LastUsedProvider != "" {
if provider, ok := file.Providers[file.LastUsedProvider]; ok {
return provider, true
}
}
for _, provider := range file.Providers {
return provider, true
}
return clineProvider{}, false
}
func providerAuthorized(settings clineProviderSettings) bool {
if strings.TrimSpace(settings.APIKey) != "" || strings.TrimSpace(settings.APIKeyAlt) != "" {
return true
}
if settings.Auth == nil {
return false
}
if strings.TrimSpace(settings.Auth.RefreshToken) != "" {
return true
}
if strings.TrimSpace(settings.Auth.AccessToken) == "" {
return false
}
if settings.Auth.ExpiresAt > 0 && settings.Auth.ExpiresAt <= time.Now().UnixMilli() {
return false
}
return true
}

View File

@ -0,0 +1,118 @@
package cline
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestClineProviderAuthStatusAuthorizedWithOAuth(t *testing.T) {
writeClineProvidersFile(t, `{
"version": 1,
"lastUsedProvider": "cline",
"providers": {
"cline": {
"settings": {
"provider": "cline",
"auth": {
"accessToken": "token",
"refreshToken": "refresh",
"expiresAt": `+futureMillis(t)+`
}
}
}
}
}`)
status, ok, err := clineProviderAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClineProviderAuthStatusAuthorizedWithAPIKey(t *testing.T) {
writeClineProvidersFile(t, `{
"version": 1,
"lastUsedProvider": "openai",
"providers": {
"openai": {
"settings": {
"provider": "openai",
"apiKey": "sk-test"
}
}
}
}`)
status, ok, err := clineProviderAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestClineProviderAuthStatusUnauthorizedWithExpiredOAuth(t *testing.T) {
writeClineProvidersFile(t, `{
"version": 1,
"lastUsedProvider": "cline",
"providers": {
"cline": {
"settings": {
"provider": "cline",
"auth": {
"accessToken": "token",
"expiresAt": 1
}
}
}
}
}`)
status, ok, err := clineProviderAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestClineProviderAuthStatusUnknownWhenMissing(t *testing.T) {
t.Setenv("HOME", t.TempDir())
status, ok, err := clineProviderAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
func writeClineProvidersFile(t *testing.T, content string) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
settingsDir := filepath.Join(home, ".cline", "data", "settings")
if err := os.MkdirAll(settingsDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(settingsDir, "providers.json"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func futureMillis(t *testing.T) string {
t.Helper()
return strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10)
}

View File

@ -3,9 +3,11 @@
// workspace-local Cline hooks, and reading hook-derived session info.
//
// Cline is an autonomous coding agent that runs in the terminal (binary
// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing
// the prompt as a positional argument and requesting NDJSON output with
// `--json`, which Cline emits one event per line for machine parsing.
// "cline", installed via `npm i -g cline`). AO drives task launches headlessly
// by passing the prompt as a positional argument and requesting NDJSON output
// with `--json`, which Cline emits one event per line for machine parsing.
// Promptless launches (notably orchestrators) must stay in Cline's interactive
// mode because Cline rejects `--json` without a prompt or piped stdin.
//
// AO-managed sessions derive native session identity from Cline hooks
// (the workspace-local `.clinerules/hooks/` executable scripts AO installs)
@ -14,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
}
@ -59,26 +54,21 @@ 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 headless Cline session,
// requesting machine-readable NDJSON output (`--json`), applying the approval
// flags, an optional system-prompt override (`-s`), and the initial prompt as
// the trailing positional argument. The prompt is placed after `--` so a
// leading "-" is not read as a flag.
// 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
// argument or piped stdin. The prompt is placed after `--` so a leading "-" is
// not read as a flag.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.clineBinary(ctx)
if err != nil {
return nil, err
}
cmd = []string{binary, "--json"}
cmd = []string{binary}
if cfg.Prompt != "" {
cmd = append(cmd, "--json")
}
appendApprovalFlags(&cmd, cfg.Permissions)
if cfg.SystemPrompt != "" {
@ -92,19 +82,11 @@ 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 --json [approval flags] --id <agentSessionId>`. ok is false when the
// hook-derived native session id has not landed yet, so callers can fall back
// to fresh launch behavior.
// `cline [approval flags] --id <agentSessionId>`. Resumes are interactive
// because no prompt is supplied here. ok is false when the hook-derived native
// session id has not landed yet, so callers can fall back to fresh launch
// behavior.
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
if err := ctx.Err(); err != nil {
return nil, false, err
@ -120,7 +102,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
}
cmd = make([]string, 0, 8)
cmd = append(cmd, binary, "--json")
cmd = append(cmd, binary)
appendApprovalFlags(&cmd, cfg.Permissions)
cmd = append(cmd, "--id", agentSessionID)
return cmd, true, nil
@ -132,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) {
@ -227,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:
@ -243,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()
}

View File

@ -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"
)
@ -36,6 +36,30 @@ func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) {
}
}
func TestGetLaunchCommandOmitsJSONForPromptlessInteractiveLaunch(t *testing.T) {
plugin := &Plugin{resolvedBinary: "cline"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Permissions: ports.PermissionModeBypassPermissions,
SystemPrompt: "coordinate the project",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"cline",
"--yolo",
"-s", "coordinate the project",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
if contains(cmd, "--json") {
t.Fatalf("promptless Cline launch must not use --json: %#v", cmd)
}
}
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
tests := []struct {
name string
@ -90,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 {
@ -102,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 {
@ -221,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")
}
}
@ -254,7 +278,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
}
want := []string{
"cline",
"--json",
"--auto-approve", "true",
"--id", "session-123",
}
@ -301,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",
},
})
@ -344,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())

View File

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

View File

@ -0,0 +1,8 @@
package cline
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.clineBinary(ctx)
}

View File

@ -13,16 +13,20 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"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
}
@ -34,6 +38,7 @@ func New() *Plugin {
var _ adapters.Adapter = (*Plugin)(nil)
var _ ports.Agent = (*Plugin)(nil)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// Manifest returns the adapter's static self-description.
func (p *Plugin) Manifest() adapters.Manifest {
@ -48,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
@ -89,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
@ -135,21 +123,41 @@ 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],
info, ok := agentbase.StandardSessionInfo(session)
return info, ok, nil
}
// AuthStatus checks Codex's local login state without making a model call.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.codexBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" {
return ports.SessionInfo{}, false, nil
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, binary, "login", "status").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
return info, true, nil
text := strings.ToLower(string(out))
if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") {
return ports.AgentAuthStatusUnauthorized, nil
}
if strings.Contains(text, "logged in") {
return ports.AgentAuthStatusAuthorized, nil
}
if err != nil {
return ports.AgentAuthStatusUnauthorized, nil
}
return ports.AgentAuthStatusUnknown, nil
}
// ResolveCodexBinary returns the path to the codex binary on this machine,
// searching PATH then a handful of well-known install locations
// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback
// so callers see a clear "command not found" rather than an empty argv.
// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch
// fallback so callers see a clear "command not found" rather than an empty
// argv.
func ResolveCodexBinary(ctx context.Context) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
@ -203,6 +211,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
filepath.Join(home, ".cargo", "bin", "codex"),
filepath.Join(home, ".npm", "bin", "codex"),
)
candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...)
}
for _, candidate := range candidates {
@ -217,6 +226,14 @@ func ResolveCodexBinary(ctx context.Context) (string, error) {
return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound)
}
func nvmNodeBinCandidates(home, binary string) []string {
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", binary))
if err != nil || len(matches) == 0 {
return nil
}
sort.Sort(sort.Reverse(sort.StringSlice(matches)))
return matches
}
func resolveNativeWindowsCodex(path string) string {
if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") {
return path
@ -301,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
@ -316,19 +333,8 @@ 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
}
}
func fileExists(path string) bool {
// 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()
}

View File

@ -90,6 +90,38 @@ func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) {
}
}
func TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("NVM install discovery is Unix-specific")
}
home := t.TempDir()
binDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
want := filepath.Join(binDir, "codex")
if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("PATH", "")
origFileExists := fileExists
fileExists = func(path string) bool {
return strings.HasPrefix(path, home+string(os.PathSeparator)) && origFileExists(path)
}
t.Cleanup(func() {
fileExists = origFileExists
})
got, err := ResolveCodexBinary(context.Background())
if err != nil {
t.Fatalf("ResolveCodexBinary: %v", err)
}
if got != want {
t.Fatalf("ResolveCodexBinary = %q, want %q", got, want)
}
}
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
tests := []struct {
name string
@ -201,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 {
@ -213,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 {

View File

@ -0,0 +1,8 @@
package codex
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.codexBinary(ctx)
}

View File

@ -0,0 +1,79 @@
package continueagent
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
yaml "gopkg.in/yaml.v3"
)
func continueLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(os.Getenv("CONTINUE_API_KEY")) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
status, ok, err := continueConfigAuthStatus(filepath.Join(home, ".continue", "config.yaml"))
if err != nil || ok {
return status, ok, err
}
}
return ports.AgentAuthStatusUnknown, false, nil
}
func continueConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if continueConfigHasCredential(&root) {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func continueConfigHasCredential(node *yaml.Node) bool {
if node == nil {
return false
}
switch node.Kind {
case yaml.DocumentNode, yaml.SequenceNode:
for _, child := range node.Content {
if continueConfigHasCredential(child) {
return true
}
}
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
key := strings.ToLower(strings.TrimSpace(node.Content[i].Value))
value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`)
if (strings.Contains(key, "apikey") || strings.Contains(key, "api_key") || strings.Contains(key, "token")) &&
value != "" &&
!strings.EqualFold(value, "null") &&
!strings.EqualFold(value, "none") {
return true
}
if continueConfigHasCredential(node.Content[i+1]) {
return true
}
}
}
return false
}

View File

@ -0,0 +1,50 @@
package continueagent
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestContinueLocalAuthStatusAuthorizedFromEnv(t *testing.T) {
t.Setenv("CONTINUE_API_KEY", "continue-key")
status, ok, err := continueLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestContinueLocalAuthStatusUnknownWithoutEnv(t *testing.T) {
t.Setenv("CONTINUE_API_KEY", "")
t.Setenv("HOME", t.TempDir())
status, ok, err := continueLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
func TestContinueConfigAuthStatusAuthorizedWithAPIKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("models:\n - provider: anthropic\n apiKey: continue-key\n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := continueConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}

View File

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

View File

@ -29,6 +29,12 @@ func TestManifest(t *testing.T) {
}
}
func TestDoesNotImplementAuthChecker(t *testing.T) {
if _, ok := any(&Plugin{}).(ports.AgentAuthChecker); ok {
t.Fatal("Continue must not implement AgentAuthChecker; catalog refresh must not run model-call auth probes")
}
}
func TestGetConfigSpecEmpty(t *testing.T) {
spec, err := (&Plugin{}).GetConfigSpec(context.Background())
if err != nil {

View File

@ -0,0 +1,8 @@
package continueagent
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.continueBinary(ctx)
}

View File

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

View File

@ -0,0 +1,160 @@
package copilot
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := copilotLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return authprobe.CLIStatus(ctx, binary, nil)
}
var copilotTokenEnvVars = []string{
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
}
func copilotLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, name := range copilotTokenEnvVars {
if strings.TrimSpace(os.Getenv(name)) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
home, err := os.UserHomeDir()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if home == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
configStatus, configOK, err := copilotConfigAuthStatus(filepath.Join(home, ".copilot", "config.json"))
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if configOK {
return configStatus, true, nil
}
if status, ok, err := copilotSessionStateAuthStatus(ctx, filepath.Join(home, ".copilot", "session-state")); err != nil || ok {
return status, ok, err
}
if status, ok, err := copilotGHAuthStatus(ctx); err != nil || ok {
return status, ok, err
}
return ports.AgentAuthStatusUnknown, false, nil
}
func copilotConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
text := strings.TrimSpace(string(data))
if text == "" {
return ports.AgentAuthStatusUnauthorized, true, nil
}
if textContainsTokenAssignment(text) {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func copilotSessionStateAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
paths, err := filepath.Glob(filepath.Join(dir, "*", "events.jsonl"))
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, path := range paths {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
continue
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if copilotEventsShowModelUse(string(data)) {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
return ports.AgentAuthStatusUnknown, false, nil
}
func copilotEventsShowModelUse(text string) bool {
return strings.Contains(text, `"model":`) ||
strings.Contains(text, `"type":"tool.execution_complete"`) ||
strings.Contains(text, `"type":"message"`)
}
func copilotGHAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, "gh", "auth", "token").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, false, probeCtx.Err()
}
text := strings.TrimSpace(string(out))
if err == nil && text != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
if strings.Contains(strings.ToLower(text), "no oauth token") || strings.Contains(strings.ToLower(text), "not logged") {
return ports.AgentAuthStatusUnknown, false, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func textContainsTokenAssignment(text string) bool {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") {
continue
}
lower := strings.ToLower(line)
if !strings.Contains(lower, "token") && !strings.Contains(lower, "auth") {
continue
}
for _, sep := range []string{":", "="} {
_, after, ok := strings.Cut(line, sep)
if !ok {
continue
}
value := strings.Trim(strings.TrimSpace(strings.TrimRight(after, ",")), `"'`)
if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") {
return true
}
}
}
return false
}

View File

@ -6,9 +6,9 @@
// "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot`
// suggest/explain extension.
//
// Launch runs the CLI in non-interactive ("programmatic") mode with `-p
// <prompt>` so it executes the task and exits. Permission modes map onto the
// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`).
// Launch runs the CLI in interactive mode so AO can keep a durable terminal
// pane attached to the session. Permission modes map onto the CLI's allow flags
// (`--allow-tool`, `--allow-all-tools`, `--allow-all`).
// Restore continues an existing session via `--resume <agentSessionId>`; the
// native session id (a UUID under ~/.copilot/session-state/) is captured by the
// SessionStart hook AO installs (see hooks.go).
@ -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,21 +64,14 @@ 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 headless Copilot session:
// GetLaunchCommand builds the argv to start a new interactive Copilot session:
//
// copilot [permission flags] [-p <prompt>]
// copilot [permission flags]
//
// The prompt is delivered with `-p`, which runs the prompt in non-interactive
// mode and exits when done. Copilot CLI does not have a documented
// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored.
// The prompt is delivered after the process starts; using `-p` runs Copilot in
// programmatic mode and exits when done, which leaves AO's terminal pane blank
// or dead. Copilot CLI does not have a documented system-prompt-injection flag,
// so SystemPrompt/SystemPromptFile are ignored.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.copilotBinary(ctx)
if err != nil {
@ -90,20 +81,18 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
cmd = []string{binary}
appendApprovalFlags(&cmd, cfg.Permissions)
if cfg.Prompt != "" {
cmd = append(cmd, "-p", cfg.Prompt)
}
return cmd, nil
}
// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the
// launch command itself (via `-p`).
// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the
// 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
}
return ports.PromptDeliveryInCommand, nil
return ports.PromptDeliveryAfterStart, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Copilot
@ -141,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
@ -182,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 {
@ -194,6 +178,9 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
}
if path, err := exec.LookPath("copilot"); err == nil && path != "" {
if native := copilotNativeBinaryForLoader(path); native != "" {
return native, nil
}
return path, nil
}
@ -206,11 +193,15 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
filepath.Join(home, ".copilot", "bin", "copilot"),
filepath.Join(home, ".npm", "bin", "copilot"),
filepath.Join(home, ".local", "bin", "copilot"),
filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "github.copilot-chat", "copilotCli", "copilot"),
)
}
for _, candidate := range candidates {
if fileExists(candidate) {
if hookutil.FileExists(candidate) {
if native := copilotNativeBinaryForLoader(candidate); native != "" {
return native, nil
}
return candidate, nil
}
if err := ctx.Err(); err != nil {
@ -221,6 +212,25 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) {
return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound)
}
func copilotNativeBinaryForLoader(path string) string {
if path == "" || runtime.GOOS == "windows" {
return ""
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || filepath.Base(resolved) != "npm-loader.js" {
return ""
}
platform := runtime.GOOS
if platform == "darwin" {
platform = "darwin"
}
native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH)
if hookutil.FileExists(native) {
return native
}
return ""
}
func (p *Plugin) copilotBinary(ctx context.Context) (string, error) {
p.binaryMu.Lock()
defer p.binaryMu.Unlock()
@ -240,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:
@ -256,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()
}

View File

@ -6,9 +6,9 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
@ -33,7 +33,7 @@ func TestGetLaunchCommandBuildsArgv(t *testing.T) {
t.Fatal(err)
}
want := []string{"copilot", "--allow-all", "-p", "-fix this"}
want := []string{"copilot", "--allow-all"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
@ -117,19 +117,19 @@ 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 {
t.Fatal(err)
}
if got != ports.PromptDeliveryInCommand {
if got != ports.PromptDeliveryAfterStart {
t.Fatalf("unexpected strategy: %q", got)
}
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{resolvedBinary: "copilot"}
plugin := &Plugin{}
spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
@ -140,6 +140,115 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
}
}
func TestCopilotNativeBinaryForNpmLoader(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("npm loader native binary naming is covered on Unix-like platforms")
}
dir := t.TempDir()
packageDir := filepath.Join(dir, "lib", "node_modules", "@github", "copilot")
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(filepath.Join(packageDir, "node_modules", ".bin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
loader := filepath.Join(packageDir, "npm-loader.js")
if err := os.WriteFile(loader, []byte("#!/usr/bin/env node\n"), 0o755); err != nil {
t.Fatal(err)
}
native := filepath.Join(packageDir, "node_modules", ".bin", "copilot-"+runtime.GOOS+"-"+runtime.GOARCH)
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(binDir, "copilot")
if err := os.Symlink(loader, link); err != nil {
t.Fatal(err)
}
want, err := filepath.EvalSymlinks(native)
if err != nil {
t.Fatal(err)
}
got, err := filepath.EvalSymlinks(copilotNativeBinaryForLoader(link))
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("native binary = %q, want %q", got, want)
}
}
func TestAuthStatusAuthorizedFromEnv(t *testing.T) {
clearCopilotAuthEnv(t)
t.Setenv("GH_TOKEN", "github_pat_test")
plugin := &Plugin{resolvedBinary: "copilot"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestCopilotConfigAuthStatusAuthorizedWithPlainTextToken(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"authToken":"token"}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := copilotConfigAuthStatus(configPath)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestCopilotConfigAuthStatusUnauthorizedWithEmptyConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := copilotConfigAuthStatus(configPath)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestCopilotSessionStateAuthStatusAuthorizedWithModelEvent(t *testing.T) {
dir := t.TempDir()
sessionDir := filepath.Join(dir, "session-1")
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sessionDir, "events.jsonl"), []byte(`{"type":"tool.execution_complete","data":{"model":"claude-sonnet-4.5"}}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := copilotSessionStateAuthStatus(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func clearCopilotAuthEnv(t *testing.T) {
t.Helper()
for _, name := range copilotTokenEnvVars {
t.Setenv(name, "")
}
}
func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "copilot"}
@ -199,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",
},
})
@ -397,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 {

View File

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

View File

@ -0,0 +1,8 @@
package copilot
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.copilotBinary(ctx)
}

View File

@ -0,0 +1,84 @@
package crush
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := crushLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return authprobe.CLIStatus(ctx, binary, nil)
}
func crushLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
dataDir, ok := crushDataDir()
if !ok {
return ports.AgentAuthStatusUnknown, false, nil
}
return crushProvidersAuthStatus(filepath.Join(dataDir, "providers.json"))
}
func crushDataDir() (string, bool) {
if dataDir := strings.TrimSpace(os.Getenv("CRUSH_DATA_DIR")); dataDir != "" {
return dataDir, true
}
if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" {
return filepath.Join(dataHome, "crush"), true
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", false
}
return filepath.Join(home, ".local", "share", "crush"), true
}
type crushProviderAuth struct {
APIKey string `json:"api_key"`
}
func crushProvidersAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
var providers []crushProviderAuth
if err := json.Unmarshal(data, &providers); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if len(providers) == 0 {
return ports.AgentAuthStatusUnknown, false, nil
}
for _, provider := range providers {
if strings.TrimSpace(provider.APIKey) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
return ports.AgentAuthStatusUnauthorized, true, nil
}

View File

@ -0,0 +1,42 @@
package crush
import (
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestCrushProvidersAuthStatusAuthorizedWithAPIKey(t *testing.T) {
path := writeCrushProviders(t, `[{"id":"anthropic","api_key":"sk-test"}]`)
status, ok, err := crushProvidersAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestCrushProvidersAuthStatusUnauthorizedWithEmptyAPIKeys(t *testing.T) {
path := writeCrushProviders(t, `[{"id":"anthropic","api_key":""}]`)
status, ok, err := crushProvidersAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func writeCrushProviders(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "providers.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package crush
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.crushBinary(ctx)
}

View File

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

View File

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

View File

@ -0,0 +1,113 @@
package cursor
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, err := cursorCLIAuthStatus(ctx, binary); err == nil && status != ports.AgentAuthStatusUnknown {
return status, nil
} else if err != nil && ctx.Err() != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := cursorLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return authprobe.CLIStatus(ctx, binary, nil)
}
func cursorCLIAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) {
return authprobe.CLIStatus(ctx, binary, [][]string{{"status"}})
}
func cursorLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
home, err := os.UserHomeDir()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if home == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
return cursorConfigAuthStatus(filepath.Join(home, ".cursor", "cli-config.json"))
}
func cursorConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
type cursorConfig struct {
AuthInfo struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
UserID any `json:"userId"`
AuthID string `json:"authId"`
} `json:"authInfo"`
}
var cfgs []cursorConfig
if err := json.Unmarshal(data, &cfgs); err != nil {
var cfg cursorConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
cfgs = []cursorConfig{cfg}
}
for _, cfg := range cfgs {
if cursorConfigHasIdentity(cfg) {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
return ports.AgentAuthStatusUnknown, false, nil
}
func cursorConfigHasIdentity(cfg struct {
AuthInfo struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
UserID any `json:"userId"`
AuthID string `json:"authId"`
} `json:"authInfo"`
}) bool {
if cfg.AuthInfo.UserID != nil {
switch v := cfg.AuthInfo.UserID.(type) {
case string:
if strings.TrimSpace(v) != "" {
return true
}
default:
return true
}
}
if strings.TrimSpace(cfg.AuthInfo.AuthID) != "" ||
strings.TrimSpace(cfg.AuthInfo.Email) != "" ||
strings.TrimSpace(cfg.AuthInfo.DisplayName) != "" {
return true
}
return false
}

View File

@ -0,0 +1,96 @@
package cursor
import (
"context"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestCursorCLIAuthStatusAuthorizedFromStatus(t *testing.T) {
restore := stubCursorAuthCommand(t, []string{"status"}, []byte("✓ Logged in as user@example.com\n"), nil)
defer restore()
status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent")
if err != nil {
t.Fatal(err)
}
if status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized)
}
}
func TestCursorCLIAuthStatusUnknownFromKeychainError(t *testing.T) {
restore := stubCursorAuthCommand(t, []string{"status"}, []byte("ERROR: SecItemCopyMatching failed -50\n"), assertErr("exit status 139"))
defer restore()
status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent")
if err != nil {
t.Fatal(err)
}
if status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown)
}
}
func TestCursorConfigAuthStatusAuthorizedWithAuthInfo(t *testing.T) {
path := filepath.Join(t.TempDir(), "cli-config.json")
if err := os.WriteFile(path, []byte(`{"authInfo":{"email":"user@example.com","userId":"user-1","authId":"auth-1"}}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := cursorConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestCursorConfigAuthStatusUnknownWithoutAuthInfo(t *testing.T) {
path := filepath.Join(t.TempDir(), "cli-config.json")
if err := os.WriteFile(path, []byte(`{"model":{"modelId":"cursor-default"}}`), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := cursorConfigAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
type assertErr string
func (e assertErr) Error() string {
return string(e)
}
func stubCursorAuthCommand(t *testing.T, wantArgs []string, out []byte, err error) func() {
t.Helper()
previous := authprobe.CmdRunner
authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
if name != "cursor-agent" || !reflect.DeepEqual(arg, wantArgs) {
t.Fatalf("command = %s %#v, want cursor-agent %#v", name, arg, wantArgs)
}
return out, err
}
return func() { authprobe.CmdRunner = previous }
}
func TestCursorConfigAuthStatusUnknownWhenMissing(t *testing.T) {
status, ok, err := cursorConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json"))
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package cursor
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.cursorBinary(ctx)
}

View File

@ -0,0 +1,62 @@
package devin
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := devinLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return authprobe.CLIStatus(ctx, binary, [][]string{{"auth", "status"}})
}
func devinLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
home, err := os.UserHomeDir()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if home == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
return devinCredentialsAuthStatus(filepath.Join(home, ".local", "share", "devin", "credentials.toml"))
}
func devinCredentialsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
text := strings.TrimSpace(string(data))
if text == "" {
return ports.AgentAuthStatusUnauthorized, true, nil
}
lower := strings.ToLower(text)
if strings.Contains(lower, "windsurf_api_key") ||
strings.Contains(lower, "devin_api_url") ||
strings.Contains(lower, "devin_webapp_host") {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}

View File

@ -0,0 +1,61 @@
package devin
import (
"context"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestAuthStatusAuthorizedFromAuthStatusOutput(t *testing.T) {
previous := authprobe.CmdRunner
authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) {
if name != "devin" || !reflect.DeepEqual(arg, []string{"auth", "status"}) {
t.Fatalf("command = %s %#v, want devin auth status", name, arg)
}
return []byte("Logged in (via Devin).\n\nUser:\n Email: agentsubs@example.com\n"), nil
}
defer func() { authprobe.CmdRunner = previous }()
got, err := (&Plugin{resolvedBinary: "devin"}).AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestDevinCredentialsAuthStatusAuthorized(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.toml")
if err := os.WriteFile(path, []byte("windsurf_api_key = \"token\"\ndevin_api_url = \"https://api.devin.ai\"\n"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := devinCredentialsAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestDevinCredentialsAuthStatusUnauthorizedWithEmptyFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials.toml")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := devinCredentialsAuthStatus(path)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package devin
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.devinBinary(ctx)
}

View File

@ -0,0 +1,89 @@
package droid
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
if _, err := p.ResolveBinary(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
}
status, ok, err := droidLocalAuthStatus(ctx)
if err != nil || ok {
return status, err
}
return ports.AgentAuthStatusUnauthorized, nil
}
func droidLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(os.Getenv("FACTORY_API_KEY")) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
home, err := os.UserHomeDir()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if home == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
return droidFactoryAuthStatus(filepath.Join(home, ".factory"))
}
func droidFactoryAuthStatus(factoryDir string) (ports.AgentAuthStatus, bool, error) {
if factoryDir == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
if fileHasContent(filepath.Join(factoryDir, "auth.v2.file")) && fileHasContent(filepath.Join(factoryDir, "auth.v2.key")) {
return ports.AgentAuthStatusAuthorized, true, nil
}
return droidSettingsAuthStatus(filepath.Join(factoryDir, "settings.json"))
}
func droidSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
var settings struct {
CustomModels []struct {
Model string `json:"model"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
} `json:"customModels"`
}
if err := json.Unmarshal(data, &settings); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, model := range settings.CustomModels {
if strings.TrimSpace(model.Model) != "" &&
strings.TrimSpace(model.BaseURL) != "" &&
strings.TrimSpace(model.APIKey) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
if len(settings.CustomModels) > 0 {
return ports.AgentAuthStatusUnauthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func fileHasContent(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir() && info.Size() > 0
}

View File

@ -0,0 +1,72 @@
package droid
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestAuthStatusAuthorizedFromFactoryAPIKey(t *testing.T) {
t.Setenv("FACTORY_API_KEY", "fk-test")
got, err := (&Plugin{resolvedBinary: "droid"}).AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestDroidFactoryAuthStatusAuthorizedFromAuthFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "auth.v2.file"), []byte("encrypted auth"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "auth.v2.key"), []byte("key"), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := droidFactoryAuthStatus(dir)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestDroidFactoryAuthStatusAuthorizedFromCustomModelAPIKey(t *testing.T) {
dir := t.TempDir()
settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":"sk-test"}],"model":"custom:Sonnet-0"}`
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := droidFactoryAuthStatus(dir)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestDroidFactoryAuthStatusUnauthorizedFromCustomModelWithoutAPIKey(t *testing.T) {
dir := t.TempDir()
settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":""}]}`
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil {
t.Fatal(err)
}
status, ok, err := droidFactoryAuthStatus(dir)
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package droid
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.droidBinary(ctx)
}

View File

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

View File

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

View File

@ -0,0 +1,167 @@
package goose
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
yaml "gopkg.in/yaml.v3"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := gooseLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return authprobe.CLIStatus(ctx, binary, nil)
}
var gooseAPIKeyEnvVars = []string{
"GOOSE_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
"GROQ_API_KEY",
"XAI_API_KEY",
"MISTRAL_API_KEY",
"COHERE_API_KEY",
}
func gooseLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, name := range gooseAPIKeyEnvVars {
if strings.TrimSpace(os.Getenv(name)) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
for _, path := range gooseConfigPaths() {
status, ok, err := gooseAuthStatusFromConfig(path)
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if ok {
return status, true, nil
}
}
return ports.AgentAuthStatusUnknown, false, nil
}
func gooseConfigPaths() []string {
seen := map[string]struct{}{}
paths := []string{}
add := func(path string) {
if path == "" {
return
}
if _, ok := seen[path]; ok {
return
}
seen[path] = struct{}{}
paths = append(paths, path)
}
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
add(filepath.Join(xdg, "goose", "config.yaml"))
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
// Goose stores config here on macOS as well, rather than under
// os.UserConfigDir's "Application Support" path.
add(filepath.Join(home, ".config", "goose", "config.yaml"))
}
return paths
}
func gooseAuthStatusFromConfig(path string) (ports.AgentAuthStatus, bool, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnauthorized, true, nil
}
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if gooseConfigHasCredential(&root) || gooseConfigHasConfiguredProvider(&root) {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func gooseConfigHasCredential(node *yaml.Node) bool {
if node == nil {
return false
}
switch node.Kind {
case yaml.DocumentNode, yaml.SequenceNode:
for _, child := range node.Content {
if gooseConfigHasCredential(child) {
return true
}
}
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
key := strings.ToLower(strings.TrimSpace(node.Content[i].Value))
value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`)
if (strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "token")) &&
value != "" &&
!strings.EqualFold(value, "null") &&
!strings.EqualFold(value, "none") {
return true
}
if gooseConfigHasCredential(node.Content[i+1]) {
return true
}
}
}
return false
}
func gooseConfigHasConfiguredProvider(node *yaml.Node) bool {
if node == nil {
return false
}
switch node.Kind {
case yaml.DocumentNode, yaml.SequenceNode:
for _, child := range node.Content {
if gooseConfigHasConfiguredProvider(child) {
return true
}
}
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
key := strings.ToLower(strings.TrimSpace(node.Content[i].Value))
value := strings.ToLower(strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`))
if key == "configured" && (value == "true" || value == "yes" || value == "1") {
return true
}
if gooseConfigHasConfiguredProvider(node.Content[i+1]) {
return true
}
}
}
return false
}

View File

@ -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,22 +71,17 @@ 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>]
// [env GOOSE_MODE=<mode>] goose run [--system <text>] -t <prompt>
//
// The prompt is delivered in-command via `-t`. A non-default permission mode is
// rendered as an `env GOOSE_MODE=<mode>` prefix because Goose reads its approval
// mode from the environment, not from a flag. System instructions, when present,
// are passed via `--system`.
// are passed via `--system`. Goose requires one of --instructions, --text, or
// --recipe even when AO intentionally starts a promptless orchestrator, so empty
// prompts are delivered as `-t "" --interactive` to land in an input-ready
// terminal without inventing an initial task.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.gooseBinary(ctx)
if err != nil {
@ -106,22 +98,14 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (
cmd = append(cmd, "--system", systemPrompt)
}
if cfg.Prompt != "" {
cmd = append(cmd, "-t", cfg.Prompt)
if cfg.Prompt == "" {
cmd = append(cmd, "--interactive")
}
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>
@ -152,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`
@ -206,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:
@ -218,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) {
@ -319,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()
}

View File

@ -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"
)
@ -71,6 +72,22 @@ func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) {
}
}
func TestGetLaunchCommandPromptlessLaunchStaysInteractive(t *testing.T) {
plugin := &Plugin{resolvedBinary: "goose"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
SystemPrompt: "coordinate this project",
})
if err != nil {
t.Fatal(err)
}
want := []string{"goose", "run", "--system", "coordinate this project", "-t", "", "--interactive"}
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
@ -125,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 {
@ -137,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 {
@ -148,6 +165,73 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
}
}
func TestAuthStatusAuthorizedFromEnv(t *testing.T) {
clearGooseAuthEnv(t)
t.Setenv("OPENROUTER_API_KEY", "test-key")
plugin := &Plugin{resolvedBinary: "goose"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAuthStatusAuthorizedFromGooseConfig(t *testing.T) {
clearGooseAuthEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
configPath := filepath.Join(home, ".config", "goose", "config.yaml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte("providers:\n openrouter:\n configured: true\n model: anthropic/claude-sonnet-4\n"), 0o600); err != nil {
t.Fatal(err)
}
plugin := &Plugin{resolvedBinary: "goose"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusAuthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized)
}
}
func TestAuthStatusUnauthorizedFromEmptyGooseConfig(t *testing.T) {
clearGooseAuthEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
configPath := filepath.Join(home, ".config", "goose", "config.yaml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
plugin := &Plugin{resolvedBinary: "goose"}
got, err := plugin.AuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got != ports.AgentAuthStatusUnauthorized {
t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized)
}
}
func clearGooseAuthEnv(t *testing.T) {
t.Helper()
for _, name := range gooseAPIKeyEnvVars {
t.Setenv(name, "")
}
}
func TestContextCancellationIsHonored(t *testing.T) {
plugin := &Plugin{resolvedBinary: "goose"}
ctx, cancel := context.WithCancel(context.Background())
@ -336,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",
},
})
@ -428,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 {

View File

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

View File

@ -0,0 +1,8 @@
package goose
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.gooseBinary(ctx)
}

View File

@ -0,0 +1,74 @@
package grok
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := grokLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
return authprobe.CLIStatus(ctx, binary, nil)
}
func grokLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(os.Getenv("GROK_API_KEY")) != "" || strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
home, err := os.UserHomeDir()
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if home == "" {
return ports.AgentAuthStatusUnknown, false, nil
}
path := filepath.Join(home, ".grok", "auth.json")
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ports.AgentAuthStatusUnknown, false, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if strings.TrimSpace(string(data)) == "" {
return ports.AgentAuthStatusUnauthorized, true, nil
}
var entries map[string]json.RawMessage
if err := json.Unmarshal(data, &entries); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if len(entries) == 0 {
return ports.AgentAuthStatusUnauthorized, true, nil
}
for key, value := range entries {
if strings.TrimSpace(key) == "" {
continue
}
trimmed := strings.TrimSpace(string(value))
if trimmed != "" && trimmed != "null" && trimmed != "{}" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
return ports.AgentAuthStatusUnauthorized, true, nil
}

View File

@ -0,0 +1,76 @@
package grok
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestGrokLocalAuthStatusAuthorizedWithAPIKeyEnv(t *testing.T) {
t.Setenv("XAI_API_KEY", "xai-test")
status, ok, err := grokLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestGrokLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) {
writeGrokAuthFile(t, `{
"https://auth.x.ai::account": {
"access_token": "token",
"refresh_token": "refresh"
}
}`)
status, ok, err := grokLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusAuthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized)
}
}
func TestGrokLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) {
writeGrokAuthFile(t, `{}`)
status, ok, err := grokLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if !ok || status != ports.AgentAuthStatusUnauthorized {
t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized)
}
}
func TestGrokLocalAuthStatusUnknownWhenMissing(t *testing.T) {
t.Setenv("HOME", t.TempDir())
status, ok, err := grokLocalAuthStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if ok || status != ports.AgentAuthStatusUnknown {
t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown)
}
}
func writeGrokAuthFile(t *testing.T, content string) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
grokDir := filepath.Join(home, ".grok")
if err := os.MkdirAll(grokDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(grokDir, "auth.json"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

View File

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

View File

@ -0,0 +1,8 @@
package grok
import "context"
// ResolveBinary resolves the executable path for the plugin.
func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) {
return p.grokBinary(ctx)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,194 @@
package kilocode
import (
"context"
"database/sql"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
_ "modernc.org/sqlite" // register sqlite driver for KiloCode auth database probes
)
var _ ports.AgentAuthChecker = (*Plugin)(nil)
// AuthStatus returns the plugin's local authentication status.
func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) {
binary, err := p.ResolveBinary(ctx)
if err != nil {
return ports.AgentAuthStatusUnknown, err
}
if status, ok, err := kilocodeLocalAuthStatus(ctx); err != nil {
return ports.AgentAuthStatusUnknown, err
} else if ok {
return status, nil
}
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput()
if probeCtx.Err() != nil {
return ports.AgentAuthStatusUnknown, probeCtx.Err()
}
status, ok := kilocodeAuthListStatus(string(out))
if ok {
return status, nil
}
if err != nil {
return ports.AgentAuthStatusUnknown, nil
}
return ports.AgentAuthStatusUnknown, nil
}
var kilocodeAPIKeyEnvVars = []string{
"KILO_API_KEY",
"KILOCODE_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
"GROQ_API_KEY",
"XAI_API_KEY",
"MISTRAL_API_KEY",
"COHERE_API_KEY",
}
func kilocodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) {
if err := ctx.Err(); err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
for _, name := range kilocodeAPIKeyEnvVars {
if strings.TrimSpace(os.Getenv(name)) != "" {
return ports.AgentAuthStatusAuthorized, true, nil
}
}
dataDir, ok := kilocodeDataDir()
if !ok {
return ports.AgentAuthStatusUnknown, false, nil
}
authorized, known, err := kilocodeAuthJSONStatus(filepath.Join(dataDir, "auth.json"))
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if known && authorized {
return ports.AgentAuthStatusAuthorized, true, nil
}
authorized, known, err = kilocodeDBAuthStatus(ctx, filepath.Join(dataDir, "kilo.db"))
if err != nil {
return ports.AgentAuthStatusUnknown, false, err
}
if known && authorized {
return ports.AgentAuthStatusAuthorized, true, nil
}
return ports.AgentAuthStatusUnknown, false, nil
}
func kilocodeDataDir() (string, bool) {
if dataDir := strings.TrimSpace(os.Getenv("KILO_DATA_DIR")); dataDir != "" {
return dataDir, true
}
if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" {
return filepath.Join(dataHome, "kilo"), true
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return "", false
}
return filepath.Join(home, ".local", "share", "kilo"), true
}
func kilocodeAuthJSONStatus(path string) (authorized, known bool, err error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return false, false, nil
}
if err != nil {
return false, false, err
}
if strings.TrimSpace(string(data)) == "" {
return false, false, nil
}
var providers map[string]map[string]any
if err := json.Unmarshal(data, &providers); err != nil {
return false, false, err
}
for _, provider := range providers {
if len(provider) == 0 {
continue
}
known = true
for _, key := range []string{"key", "apiKey", "api_key", "access_token", "token"} {
if strings.TrimSpace(asString(provider[key])) != "" {
return true, true, nil
}
}
}
return false, known, nil
}
func asString(value any) string {
s, _ := value.(string)
return s
}
func kilocodeDBAuthStatus(ctx context.Context, path string) (authorized, known bool, err error) {
if err := ctx.Err(); err != nil {
return false, false, err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return false, false, nil
} else if err != nil {
return false, false, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)")
if err != nil {
return false, false, err
}
defer func() {
_ = db.Close()
}()
return kilocodeDBHasAuthorizedAccount(ctx, db)
}
func kilocodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) {
for _, query := range []string{
`SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`,
`SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`,
`SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`,
} {
var count int
if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "no such table") {
continue
}
return false, false, err
}
known = true
if count > 0 {
return true, true, nil
}
}
return false, known, nil
}
var kilocodeAuthListCountRE = regexp.MustCompile(`(?m)\b([1-9][0-9]*)\s+(credentials?|environment variables?)\b`)
func kilocodeAuthListStatus(output string) (ports.AgentAuthStatus, bool) {
text := strings.ToLower(output)
if kilocodeAuthListCountRE.MatchString(text) {
return ports.AgentAuthStatusAuthorized, true
}
if strings.Contains(text, "0 credentials") && strings.Contains(text, "0 environment variable") {
return ports.AgentAuthStatusUnauthorized, true
}
return ports.AgentAuthStatusUnknown, false
}

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