mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Add bundled Orchestrator plugin
Promote the terminal agent work into the built-in plugins tree as _orchestrator with Orchestrator branding and the orchestrator skill. Include adapter status APIs, settings UI, per-agent skill references, thumbnail asset, tests, and DOX coverage for the bundled plugin.
This commit is contained in:
parent
f1787b65f5
commit
f64b6490c5
43 changed files with 2875 additions and 0 deletions
|
|
@ -84,6 +84,7 @@ Direct child DOX files:
|
|||
| [_oauth/AGENTS.md](_oauth/AGENTS.md) | OAuth-backed model-provider connections and local proxy routes. |
|
||||
| [_office/AGENTS.md](_office/AGENTS.md) | LibreOffice office artifacts and office canvas sessions. |
|
||||
| [_onboarding/AGENTS.md](_onboarding/AGENTS.md) | First-time model onboarding wizard. |
|
||||
| [_orchestrator/AGENTS.md](_orchestrator/AGENTS.md) | External terminal coding-agent orchestration skill, adapter status, and settings UI. |
|
||||
| [_plugin_installer/AGENTS.md](_plugin_installer/AGENTS.md) | Plugin install and update flows from ZIP, Git, and Plugin Index. |
|
||||
| [_plugin_scan/AGENTS.md](_plugin_scan/AGENTS.md) | LLM-guided security scanner for third-party plugins. |
|
||||
| [_plugin_validator/AGENTS.md](_plugin_validator/AGENTS.md) | Plugin manifest, structure, convention, and security validator. |
|
||||
|
|
|
|||
5
plugins/_orchestrator/.gitignore
vendored
Normal file
5
plugins/_orchestrator/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
config.json
|
||||
data/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
64
plugins/_orchestrator/AGENTS.md
Normal file
64
plugins/_orchestrator/AGENTS.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Orchestrator Plugin - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Provide a bundled Agent Zero plugin for delegating repository and coding work to external terminal/headless agents.
|
||||
- Keep heavy delegation instructions out of the always-loaded prompt by exposing the `orchestrator` skill instead of a `terminal_agent` tool.
|
||||
- Own adapter status metadata, settings UI, Codex device login APIs, and skill instructions for Agent Zero headless, Codex CLI, Claude Code, Cursor CLI, Grok Build, Hermes Agent, OpenCode, and future terminal agents.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Source-owned files are `.gitignore`, `plugin.yaml`, `default_config.yaml`, `LICENSE`, `README.md`, `thumbnail.png`, `api/`, `helpers/`, `skills/`, `tests/`, and `webui/`.
|
||||
- Runtime/user-state files such as `config.json`, `data/`, `__pycache__/`, credential stores, and generated screenshots are not source contracts.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Plugin imports must use `plugins._orchestrator...`.
|
||||
- The plugin must remain toolless: no `tools/terminal_agent.py`, no subprocess runner, and no `agent.system.tool.terminal_agent.md` prompt.
|
||||
- External agents are orchestrated through the ordinary Agent Zero shell/code execution tool after the `orchestrator` skill is loaded.
|
||||
- Adapters report installation, binary resolution, auth state, safe disconnect capability, and optional device-login capability. They do not build or run task commands.
|
||||
- Settings UI is for status and command defaults. It must not offer generic install buttons; install/login belongs to the skill-guided human-in-the-loop shell workflow.
|
||||
- Credentials must never be hardcoded or printed. Plugin-owned state belongs under `data/<adapter>/` and external CLI state stays in the CLI's own home/config paths.
|
||||
- Agent Zero headless is the first adapter. Its setup flow is the exception: ask whether to use this same Agent Zero instance or another/spun-up instance when the target is not specified.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Keep source docs, UI labels, adapter metadata, tests, `skills/orchestrator/SKILL.md`, and per-agent references aligned whenever adding or changing an adapter.
|
||||
- Prefer direct, discoverable adapter metadata over a second abstraction layer. Keep generic behavior in the skill and per-agent command details in reference files.
|
||||
- For CLI login flows, show provider/menu choices in chat and send the user's selected option back to the active shell session. Do not send users to a Docker shell only to choose a menu.
|
||||
- After changing the skill, tell testers to start a fresh chat or reload `orchestrator`; previously loaded skill text can remain attached to old conversations.
|
||||
- Codex device login is the only settings-screen OAuth flow currently owned here. Add another only when the credential store and revocation/disconnect path are known.
|
||||
- Avoid recursive Agent Zero delegation loops. A0 headless prompts must tell the target instance to answer directly and not delegate back through terminal agents.
|
||||
|
||||
## Verification
|
||||
|
||||
- Framework-runtime adapter self-check:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
- Frontend syntax check when editing WebUI JavaScript:
|
||||
```bash
|
||||
node --check plugins/_orchestrator/webui/orchestrator-store.js
|
||||
```
|
||||
- After Python checks, remove generated bytecode before packaging:
|
||||
```bash
|
||||
find plugins/_orchestrator -type d -name __pycache__ -prune -exec rm -rf {} +
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
| Child | Scope |
|
||||
| --- | --- |
|
||||
| [api/AGENTS.md](api/AGENTS.md) | Plugin HTTP API handlers for status, device login polling, and disconnect. |
|
||||
| [helpers/AGENTS.md](helpers/AGENTS.md) | Adapter registry, base contracts, and shared helper behavior. |
|
||||
| [skills/AGENTS.md](skills/AGENTS.md) | Plugin skill collection and agent-facing delegation instructions. |
|
||||
| [tests/AGENTS.md](tests/AGENTS.md) | Direct regression checks for plugin contracts and skill text. |
|
||||
| [webui/AGENTS.md](webui/AGENTS.md) | Settings UI markup, Alpine store, status display, and device-login UI. |
|
||||
|
||||
Intentionally unindexed local/generated paths:
|
||||
|
||||
| Path | Reason |
|
||||
| --- | --- |
|
||||
| `config.json` | Local plugin settings state. |
|
||||
| `data/` | Plugin-owned credential/state directory. |
|
||||
| `__pycache__/` | Generated Python bytecode. |
|
||||
21
plugins/_orchestrator/LICENSE
Normal file
21
plugins/_orchestrator/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Orchestrator plugin contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
187
plugins/_orchestrator/README.md
Normal file
187
plugins/_orchestrator/README.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# Orchestrator
|
||||
|
||||
Load-on-demand guidance for orchestrating external **terminal coding agents**
|
||||
from Agent Zero without keeping a large delegation tool in every agent prompt.
|
||||
|
||||
The plugin provides:
|
||||
|
||||
- an `orchestrator` skill that tells Agent Zero how to use external CLIs
|
||||
through the user's A0 CLI host bridge or the normal container shell;
|
||||
- a Settings > External Services status screen for configured binaries and
|
||||
detected auth state;
|
||||
- adapter metadata for Agent Zero headless, OpenAI Codex CLI, Claude Code,
|
||||
Cursor CLI, Grok Build, Hermes Agent, OpenCode, and future terminal agents.
|
||||
|
||||
There is intentionally no `terminal_agent` tool and no settings-screen install
|
||||
button. When the user explicitly asks for a terminal agent, the skill first
|
||||
decides whether to use the user's own host CLI through A0 CLI or a pal agent
|
||||
inside the Agent Zero container.
|
||||
|
||||
Host CLI is the primary everyday flow: the user's Claude Code, Codex, Cursor
|
||||
CLI, and other coding agents stay installed and logged in on their own
|
||||
computer. Container agents remain useful when the user explicitly wants the
|
||||
Docker runtime.
|
||||
|
||||
## Supported Agents
|
||||
|
||||
| Agent | CLI | Login |
|
||||
| --- | --- | --- |
|
||||
| Agent Zero (headless) | `a0` | Instance `/login` session; `A0_USERNAME`/`A0_PASSWORD` in the shell for protected hosts |
|
||||
| OpenAI Codex | `codex` | ChatGPT device login from settings or external CLI login |
|
||||
| Claude Code | `claude` | External `claude` login or `ANTHROPIC_API_KEY` |
|
||||
| Cursor CLI | `agent` | `CURSOR_API_KEY`, `NO_OPEN_BROWSER=1 agent login`, or cached Cursor login |
|
||||
| Grok Build | `grok` | `XAI_API_KEY`, `grok login --device-auth`, or cached Grok login |
|
||||
| Hermes Agent | `hermes` | External Hermes/provider setup, `~/.hermes/.env`, `~/.hermes/auth.json`, or provider env vars |
|
||||
| OpenCode | `opencode` | External `opencode auth login`, provider env vars, or `~/.local/share/opencode/auth.json` |
|
||||
|
||||
The `a0` adapter targets the local Agent Zero instance
|
||||
(`http://localhost:80` inside the container) when no `host`/`AGENT_ZERO_HOST`
|
||||
is configured, or another Agent Zero instance when you set a host. This lets A0
|
||||
delegate to another runtime, and also lets the same runtime ask itself a focused
|
||||
question as a secondary use case. A0 is the setup exception: if the user did
|
||||
not specify a target, Agent Zero should ask whether to use the same instance or
|
||||
another/spun-up instance instead of running a generic login flow. The skill
|
||||
tells the target A0 instance to answer directly and not delegate back through
|
||||
terminal agents.
|
||||
|
||||
## Skill Usage
|
||||
|
||||
Ask Agent Zero to load/use the `orchestrator` skill before delegating coding
|
||||
work to one of these CLIs. The skill keeps the global setup loop in
|
||||
`skills/orchestrator/SKILL.md` and reads one per-agent reference from
|
||||
`skills/orchestrator/references/` before acting.
|
||||
|
||||
If the user does not specify host versus container, Agent Zero asks and saves a
|
||||
per-agent preference with `memory_save`. Local/host mode uses the connector's
|
||||
`code_execution_remote` tool and may load `host-code-execution`; container mode
|
||||
uses `code_execution_tool`.
|
||||
|
||||
For local/host coding agents, the user should run A0 CLI on their own machine:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
curl -LsSf https://cli.agent-zero.ai/install.sh | sh
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
irm https://cli.agent-zero.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
If A0 CLI is installed but not running, open a terminal and run `a0`, connect it
|
||||
to the Agent Zero instance, choose the chat/session or enter the remote/VPS URL,
|
||||
then press `F4` for Remote Code Execution. Press `F3` too when host file writes
|
||||
are needed. After that, asking Agent Zero to use local Claude Code/Codex/etc.
|
||||
will run commands on the host machine instead of inside Docker.
|
||||
|
||||
The skill must not use Computer Use to drive coding-agent terminals or TUIs.
|
||||
It uses headless CLI commands. The ACP community plugin can be a separate path
|
||||
when explicitly requested or when direct CLI automation is unsuitable.
|
||||
|
||||
The reference files contain the copy-ready commands, for example Codex:
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$TASK"
|
||||
```
|
||||
|
||||
Claude Code defaults to skip permissions for non-interactive runs:
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
claude -p "$TASK" --output-format json
|
||||
else
|
||||
claude -p "$TASK" --output-format json --permission-mode bypassPermissions --allowedTools Bash,Read,Edit
|
||||
fi
|
||||
```
|
||||
|
||||
When Claude Code is not authenticated, use `claude auth login` for the
|
||||
human-in-the-loop login step. Do not start plain `claude`; that opens the
|
||||
first-run TUI and can trap the agent in theme/provider menus.
|
||||
Ask the user to choose the auth mode first: Claude subscription
|
||||
(`--claudeai`), Anthropic Console/API billing (`--console`), SSO (`--sso`), or
|
||||
an externally set `ANTHROPIC_API_KEY`. In Agent Zero, the user can add that
|
||||
key from **Settings > External Services > Secrets Management**; the runtime
|
||||
file is `/a0/usr/.env`, not the workdir `.env`.
|
||||
If a plain `claude` TUI is already open, reset the terminal session instead of
|
||||
sending Enter or `/login`, then use one of the explicit auth commands.
|
||||
|
||||
If a non-A0 CLI is missing or asks for login/browser confirmation, Agent Zero
|
||||
can install the requested CLI, start its setup/login command, relay the exact
|
||||
human step, wait for the user to confirm, then retry a smoke prompt before
|
||||
running the real task. If setup shows provider/menu choices, Agent Zero should
|
||||
show those options in chat and ask which one to select, not send the user to a
|
||||
Docker shell just to choose from a menu. It should not ask the user to paste
|
||||
secrets into chat unless there is no safer path.
|
||||
|
||||
## Login And Status
|
||||
|
||||
Open **Settings > External Services > Orchestrator** to see registered
|
||||
adapters, binary paths, installed status, and detected auth source. The screen
|
||||
can refresh status and disconnect credentials only when an adapter can safely
|
||||
remove a known credential store.
|
||||
|
||||
Codex still supports plugin-owned device-code login from the settings screen.
|
||||
Those tokens are stored under the plugin data directory (`data/codex/auth.json`,
|
||||
mode 600), and the skill can point Codex at that home when needed. If you have
|
||||
already logged in with `codex login`, the status screen detects the external
|
||||
credentials too.
|
||||
|
||||
> Tokens are password-equivalent credentials. Never share one rotating
|
||||
> refresh-token auth file between two clients; plugin-owned Codex credentials
|
||||
> stay separate from other tools.
|
||||
|
||||
## Configuration (`default_config.yaml`)
|
||||
|
||||
```yaml
|
||||
a0:
|
||||
binary: a0 # falls back to /opt/venv/bin/a0 in Agent Zero Docker
|
||||
host: "" # empty = AGENT_ZERO_HOST env, else local instance
|
||||
codex:
|
||||
binary: codex
|
||||
model: ""
|
||||
bypass_sandbox: true
|
||||
claude:
|
||||
binary: claude
|
||||
model: ""
|
||||
permission_mode: bypassPermissions
|
||||
allowed_tools: "Bash,Read,Edit"
|
||||
bare: false
|
||||
cursor:
|
||||
binary: agent
|
||||
output_format: text
|
||||
force: true
|
||||
grok:
|
||||
binary: grok
|
||||
model: ""
|
||||
output_format: json
|
||||
always_approve: true
|
||||
no_auto_update: true
|
||||
hermes:
|
||||
binary: hermes
|
||||
model: ""
|
||||
provider: ""
|
||||
toolsets: ""
|
||||
yolo: true
|
||||
opencode:
|
||||
binary: opencode
|
||||
model: ""
|
||||
agent: ""
|
||||
auto: true
|
||||
```
|
||||
|
||||
## Adding A New Agent
|
||||
|
||||
1. Create `helpers/adapters/<name>.py` subclassing `TerminalAgentAdapter`
|
||||
(`helpers/adapters/base.py`) and implement `auth_status()`. Add optional
|
||||
safe disconnect or device-login support only when the credential store is
|
||||
known.
|
||||
2. Register an instance in `helpers/registry.py`.
|
||||
3. Add a config block in `default_config.yaml`.
|
||||
4. Add concise command guidance to
|
||||
`skills/orchestrator/references/<name>.md`.
|
||||
5. Add the reference to `skills/orchestrator/SKILL.md` and update generic
|
||||
skill guidance only when the global orchestration loop changes.
|
||||
|
||||
The status API and settings UI pick registered adapters up automatically.
|
||||
42
plugins/_orchestrator/api/AGENTS.md
Normal file
42
plugins/_orchestrator/api/AGENTS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Orchestrator API - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Expose small plugin HTTP handlers used by the Orchestrator settings screen.
|
||||
- Report registered adapter status and manage only supported, safe auth operations.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns `status.py`, `start_device_login.py`, `poll_device_login.py`, `disconnect.py`, and package initialization for plugin APIs.
|
||||
- Does not own execution of terminal agents, installation of CLIs, or generic credential collection.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- API handlers derive from `helpers.api.ApiHandler`.
|
||||
- Read plugin settings through `helpers.plugins.get_plugin_config("_orchestrator")`.
|
||||
- Use `plugins._orchestrator.helpers.registry` for adapter lookup and per-adapter config extraction.
|
||||
- Return JSON dictionaries with `ok` and enough context for the WebUI to show a useful error. Do not let adapter exceptions escape to the user as framework failures.
|
||||
- `status` may include `install_hint` for display/help only; it must not trigger installation.
|
||||
- Device-login endpoints must reject adapters that do not explicitly support device login.
|
||||
- `disconnect` may remove only known credential stores or invoke a known safe CLI logout. It must not delete broad home directories or provider config trees.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Keep response shapes stable for `webui/orchestrator-store.js`.
|
||||
- When adding adapter auth behavior, update `status.py` only if the shared response needs a new field.
|
||||
- Never return token values, API keys, refresh tokens, or raw credential JSON.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the plugin self-check after API changes:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
- For import-only API changes, use the Agent Zero framework runtime:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python -m py_compile plugins/_orchestrator/api/*.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
This folder has no child DOX files.
|
||||
0
plugins/_orchestrator/api/__init__.py
Normal file
0
plugins/_orchestrator/api/__init__.py
Normal file
21
plugins/_orchestrator/api/disconnect.py
Normal file
21
plugins/_orchestrator/api/disconnect.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request
|
||||
from helpers.plugins import get_plugin_config
|
||||
|
||||
from plugins._orchestrator.helpers.registry import adapter_config, get_adapter
|
||||
|
||||
PLUGIN_NAME = "_orchestrator"
|
||||
|
||||
|
||||
class Disconnect(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
agent_id = str(input.get("agent_id") or "codex")
|
||||
try:
|
||||
adapter = get_adapter(agent_id)
|
||||
plugin_config = get_plugin_config(PLUGIN_NAME) or {}
|
||||
cfg = adapter_config(adapter.id, plugin_config)
|
||||
result = adapter.disconnect(cfg)
|
||||
return {"ok": True, "agent_id": adapter.id, **result}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "agent_id": agent_id, "error": str(exc)}
|
||||
16
plugins/_orchestrator/api/poll_device_login.py
Normal file
16
plugins/_orchestrator/api/poll_device_login.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request
|
||||
|
||||
from plugins._orchestrator.helpers.registry import get_adapter
|
||||
|
||||
|
||||
class PollDeviceLogin(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
agent_id = str(input.get("agent_id") or "codex")
|
||||
try:
|
||||
adapter = get_adapter(agent_id)
|
||||
result = adapter.poll_device_login(input)
|
||||
return {"ok": True, "agent_id": adapter.id, **result}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "agent_id": agent_id, "error": str(exc)}
|
||||
18
plugins/_orchestrator/api/start_device_login.py
Normal file
18
plugins/_orchestrator/api/start_device_login.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request
|
||||
|
||||
from plugins._orchestrator.helpers.registry import get_adapter
|
||||
|
||||
|
||||
class StartDeviceLogin(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
agent_id = str(input.get("agent_id") or "codex")
|
||||
try:
|
||||
adapter = get_adapter(agent_id)
|
||||
if not adapter.supports_device_login():
|
||||
raise RuntimeError(f"{adapter.title} does not support device login.")
|
||||
result = adapter.start_device_login()
|
||||
return {"ok": True, "agent_id": adapter.id, **result}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "agent_id": agent_id, "error": str(exc)}
|
||||
39
plugins/_orchestrator/api/status.py
Normal file
39
plugins/_orchestrator/api/status.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request
|
||||
from helpers.plugins import get_plugin_config
|
||||
|
||||
from plugins._orchestrator.helpers.registry import adapter_config, list_adapters
|
||||
|
||||
PLUGIN_NAME = "_orchestrator"
|
||||
|
||||
|
||||
class Status(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
plugin_config = get_plugin_config(PLUGIN_NAME) or {}
|
||||
agents = []
|
||||
for adapter in list_adapters():
|
||||
cfg = adapter_config(adapter.id, plugin_config)
|
||||
try:
|
||||
auth = adapter.auth_status(cfg)
|
||||
except Exception as exc:
|
||||
auth = {
|
||||
"connected": False,
|
||||
"mode": "",
|
||||
"auth_path": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
agents.append(
|
||||
{
|
||||
"id": adapter.id,
|
||||
"title": adapter.title,
|
||||
"description": adapter.description,
|
||||
"binary": adapter.resolve_binary(cfg),
|
||||
"installed": adapter.is_installed(cfg),
|
||||
"install_hint": adapter.install_hint,
|
||||
"supports_device_login": adapter.supports_device_login(),
|
||||
"can_disconnect": adapter.can_disconnect(cfg),
|
||||
"auth": auth,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "agents": agents}
|
||||
34
plugins/_orchestrator/default_config.yaml
Normal file
34
plugins/_orchestrator/default_config.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
a0:
|
||||
binary: a0 # falls back to /opt/venv/bin/a0 in Agent Zero Docker
|
||||
host: "" # empty = AGENT_ZERO_HOST env, else local instance (http://localhost:80)
|
||||
codex:
|
||||
binary: codex
|
||||
model: ""
|
||||
bypass_sandbox: true
|
||||
claude:
|
||||
binary: claude
|
||||
model: ""
|
||||
permission_mode: bypassPermissions
|
||||
allowed_tools: "Bash,Read,Edit"
|
||||
bare: false
|
||||
cursor:
|
||||
binary: agent
|
||||
output_format: text
|
||||
force: true
|
||||
grok:
|
||||
binary: grok
|
||||
model: ""
|
||||
output_format: json
|
||||
always_approve: true
|
||||
no_auto_update: true
|
||||
hermes:
|
||||
binary: hermes
|
||||
model: ""
|
||||
provider: ""
|
||||
toolsets: ""
|
||||
yolo: true
|
||||
opencode:
|
||||
binary: opencode
|
||||
model: ""
|
||||
agent: ""
|
||||
auto: true
|
||||
37
plugins/_orchestrator/helpers/AGENTS.md
Normal file
37
plugins/_orchestrator/helpers/AGENTS.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Orchestrator Helpers - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Provide the status adapter framework and registry for terminal/headless coding agents.
|
||||
- Keep adapter logic focused on discovery, configuration, authentication state, and safe credential cleanup.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns `registry.py`, package initialization, and the `adapters/` subtree.
|
||||
- Does not own shell command execution, long-running process management, or task output parsing.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- `registry.py` is the only place that orders and exposes registered adapters.
|
||||
- A0 Headless must remain first in `list_adapters()` unless product direction changes.
|
||||
- Registry imports must use `plugins._orchestrator...` for bundled plugin imports.
|
||||
- `adapter_config()` must tolerate missing or malformed plugin config and return an empty dict.
|
||||
- Add adapters by creating a `TerminalAgentAdapter` subclass, registering an instance in `registry.py`, adding defaults in `default_config.yaml`, and updating the skill, README, and tests.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Keep helpers boring and inspectable. If command orchestration is needed, put instructions in the skill rather than adding a runner.
|
||||
- Adapter metadata should be readable enough for both the UI and agent skill documentation to stay in sync.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the adapter contract checks:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
| Child | Scope |
|
||||
| --- | --- |
|
||||
| [adapters/AGENTS.md](adapters/AGENTS.md) | Per-agent adapter contracts, auth detection, and safe disconnect rules. |
|
||||
0
plugins/_orchestrator/helpers/__init__.py
Normal file
0
plugins/_orchestrator/helpers/__init__.py
Normal file
62
plugins/_orchestrator/helpers/adapters/AGENTS.md
Normal file
62
plugins/_orchestrator/helpers/adapters/AGENTS.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Terminal Agent Adapters - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Represent each supported external terminal agent as status/auth metadata for the plugin UI and APIs.
|
||||
- Keep every adapter compatible with the shared `TerminalAgentAdapter` contract.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns `base.py` plus adapter modules for A0 Headless, Codex CLI, Claude Code, Cursor CLI, Grok Build, Hermes Agent, OpenCode, and future status adapters.
|
||||
- Owns credential-path detection and safe disconnect behavior only when the adapter can identify the exact credential store.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Subclasses must define `id`, `title`, `binary`, `install_hint`, `description`, and `auth_status()`.
|
||||
- `auth_status()` returns a dict containing at least `connected`, `mode`, and `auth_path`; optional `error` must be display-safe.
|
||||
- `install_hint` is informational. It must not become executable API behavior.
|
||||
- `resolve_binary()` should respect configured absolute paths and fall back to the adapter's default binary.
|
||||
- `is_installed()` checks executability only. It must not install, mutate, or prompt.
|
||||
- Do not add command execution hooks such as `build_command`, `install_command`, `parse_session_id`, or `format_output`.
|
||||
- `data_dir()` is only for plugin-owned private state such as Codex device-login auth. Do not store user-entered provider keys in source or broad config files.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- A0 Headless:
|
||||
- Host resolution is config `a0.host`, then `AGENT_ZERO_HOST`, then `http://localhost:80` inside the Agent Zero container.
|
||||
- The Docker fallback binary is `/opt/venv/bin/a0` when plain `a0` is unavailable.
|
||||
- Status means the host socket is reachable; login/target choice is handled by the skill.
|
||||
- Codex CLI:
|
||||
- Detect plugin-owned `data/codex/auth.json` before external `CODEX_HOME` or `~/.codex/auth.json`.
|
||||
- Keep device-code OAuth compatible with the Agent Zero `_oauth` reference.
|
||||
- External disconnect may call `codex logout`; plugin-owned disconnect deletes only the plugin auth file.
|
||||
- Claude Code:
|
||||
- Treat `ANTHROPIC_API_KEY` as environment auth.
|
||||
- Detect the CLI credentials file but never read or return credential contents.
|
||||
- Do not model the first-run TUI as a status API flow.
|
||||
- Cursor CLI:
|
||||
- Treat `CURSOR_API_KEY` as environment auth.
|
||||
- Detect known files under `~/.cursor/` without returning secret contents.
|
||||
- Do not model the interactive terminal UI as a status API flow.
|
||||
- Grok Build:
|
||||
- Treat `XAI_API_KEY` as environment auth.
|
||||
- Detect `~/.grok/config.toml`, `~/.grok/auth.json`, and `~/.grok/auth/` without returning secret contents.
|
||||
- Do not model the full-screen TUI as a status API flow.
|
||||
- Hermes Agent and OpenCode:
|
||||
- Detect known provider environment variables and known auth files.
|
||||
- Secret detection should answer yes/no without returning secret values.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run adapter tests:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
- For syntax-only adapter edits:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python -m py_compile plugins/_orchestrator/helpers/adapters/*.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
This folder has no child DOX files.
|
||||
0
plugins/_orchestrator/helpers/adapters/__init__.py
Normal file
0
plugins/_orchestrator/helpers/adapters/__init__.py
Normal file
65
plugins/_orchestrator/helpers/adapters/a0.py
Normal file
65
plugins/_orchestrator/helpers/adapters/a0.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
|
||||
# Inside the Agent Zero container, the local WebUI listens on port 80,
|
||||
# so the default target is the instance running this very plugin.
|
||||
DEFAULT_HOST = "http://localhost:80"
|
||||
DEFAULT_DOCKER_A0_BINARY = "/opt/venv/bin/a0"
|
||||
|
||||
|
||||
class AgentZeroAdapter(TerminalAgentAdapter):
|
||||
"""Delegate tasks to an Agent Zero instance via `a0 headless` one-shot mode.
|
||||
|
||||
Host resolution: adapter config `host` > AGENT_ZERO_HOST env > local
|
||||
instance (http://localhost:80 inside the container).
|
||||
"""
|
||||
|
||||
id = "a0"
|
||||
title = "Agent Zero (headless)"
|
||||
binary = "a0"
|
||||
install_hint = "pip install git+https://github.com/agent0ai/a0-connector.git@development"
|
||||
description = "Delegate to this or another Agent Zero instance through a0 headless."
|
||||
|
||||
# --- connection ----------------------------------------------------------
|
||||
|
||||
def resolve_binary(self, config: dict[str, Any] | None = None) -> str:
|
||||
binary = super().resolve_binary(config)
|
||||
if binary == self.binary and shutil.which(binary) is None:
|
||||
bundled = Path(DEFAULT_DOCKER_A0_BINARY)
|
||||
if bundled.is_file():
|
||||
return str(bundled)
|
||||
return binary
|
||||
|
||||
def resolve_host(self, config: dict[str, Any] | None = None) -> str:
|
||||
cfg = config or {}
|
||||
host = str(cfg.get("host") or "").strip()
|
||||
if host:
|
||||
return host
|
||||
env_host = os.environ.get("AGENT_ZERO_HOST", "").strip()
|
||||
return env_host or DEFAULT_HOST
|
||||
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
host = self.resolve_host(config)
|
||||
if _probe(host):
|
||||
return {"connected": True, "mode": "external", "auth_path": host}
|
||||
return {"connected": False, "mode": "", "auth_path": host}
|
||||
|
||||
|
||||
def _probe(host_url: str) -> bool:
|
||||
url = host_url if "://" in host_url else f"http://{host_url}"
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
63
plugins/_orchestrator/helpers/adapters/base.py
Normal file
63
plugins/_orchestrator/helpers/adapters/base.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers import files
|
||||
|
||||
|
||||
class TerminalAgentAdapter(ABC):
|
||||
"""Contract every terminal coding agent status adapter must implement."""
|
||||
|
||||
id: str = ""
|
||||
title: str = ""
|
||||
binary: str = ""
|
||||
install_hint: str = ""
|
||||
description: str = ""
|
||||
|
||||
# --- environment -----------------------------------------------------
|
||||
|
||||
def data_dir(self) -> Path:
|
||||
"""Plugin-owned private directory for this adapter (auth, state)."""
|
||||
path = Path(
|
||||
files.get_abs_path("usr", "plugins", "_orchestrator", "data", self.id)
|
||||
)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def resolve_binary(self, config: dict[str, Any] | None = None) -> str:
|
||||
cfg = config if isinstance(config, dict) else {}
|
||||
configured = str(cfg.get("binary") or "").strip()
|
||||
return configured or self.binary
|
||||
|
||||
def is_installed(self, config: dict[str, Any] | None = None) -> bool:
|
||||
binary = self.resolve_binary(config)
|
||||
if not binary:
|
||||
return False
|
||||
if os.path.isabs(binary):
|
||||
return Path(binary).is_file() and os.access(binary, os.X_OK)
|
||||
return shutil.which(binary) is not None
|
||||
|
||||
# --- authentication ----------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Return {connected: bool, mode: 'plugin'|'external'|'', auth_path: str}."""
|
||||
|
||||
def supports_device_login(self) -> bool:
|
||||
return False
|
||||
|
||||
def start_device_login(self) -> dict[str, Any]:
|
||||
raise NotImplementedError(f"{self.id} does not support device login.")
|
||||
|
||||
def poll_device_login(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError(f"{self.id} does not support device login.")
|
||||
|
||||
def can_disconnect(self, config: dict[str, Any] | None = None) -> bool:
|
||||
return False
|
||||
|
||||
def disconnect(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
raise NotImplementedError(f"{self.id} does not support disconnect.")
|
||||
61
plugins/_orchestrator/helpers/adapters/claude.py
Normal file
61
plugins/_orchestrator/helpers/adapters/claude.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
|
||||
|
||||
class ClaudeCodeAdapter(TerminalAgentAdapter):
|
||||
id = "claude"
|
||||
title = "Claude Code"
|
||||
binary = "claude"
|
||||
install_hint = "npm install -g @anthropic-ai/claude-code"
|
||||
description = "Anthropic Claude Code CLI in non-interactive print mode."
|
||||
|
||||
def _credentials_path(self) -> Path:
|
||||
config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
||||
root = Path(config_dir).expanduser() if config_dir else Path.home() / ".claude"
|
||||
return root / ".credentials.json"
|
||||
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
return {
|
||||
"connected": True,
|
||||
"mode": "env",
|
||||
"auth_path": "ANTHROPIC_API_KEY",
|
||||
}
|
||||
credentials = self._credentials_path()
|
||||
try:
|
||||
if credentials.is_file() and credentials.stat().st_size > 0:
|
||||
return {
|
||||
"connected": True,
|
||||
"mode": "external",
|
||||
"auth_path": str(credentials),
|
||||
}
|
||||
except OSError as exc:
|
||||
return {
|
||||
"connected": False,
|
||||
"mode": "",
|
||||
"auth_path": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
if not self.is_installed(config):
|
||||
return {"connected": False, "mode": "", "auth_path": ""}
|
||||
return {"connected": False, "mode": "", "auth_path": ""}
|
||||
|
||||
def can_disconnect(self, config: dict[str, Any] | None = None) -> bool:
|
||||
return not os.environ.get("ANTHROPIC_API_KEY") and self._credentials_path().is_file()
|
||||
|
||||
def disconnect(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
return {
|
||||
"removed": False,
|
||||
"message": "ANTHROPIC_API_KEY is set in the environment.",
|
||||
}
|
||||
path = self._credentials_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return {"removed": True, "mode": "external"}
|
||||
return {"removed": False, "message": "No Claude Code credentials found."}
|
||||
234
plugins/_orchestrator/helpers/adapters/codex.py
Normal file
234
plugins/_orchestrator/helpers/adapters/codex.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
|
||||
# Codex CLI's public OAuth client (same constants the official CLI uses;
|
||||
# see also plugins/_oauth for the reference implementation).
|
||||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
ISSUER = "https://auth.openai.com"
|
||||
TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
AUTH_FILENAME = "auth.json"
|
||||
DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
|
||||
|
||||
|
||||
class CodexAdapter(TerminalAgentAdapter):
|
||||
id = "codex"
|
||||
title = "OpenAI Codex CLI"
|
||||
binary = "codex"
|
||||
install_hint = "npm install -g @openai/codex"
|
||||
description = "OpenAI Codex CLI for autonomous coding tasks in a workdir."
|
||||
|
||||
# --- authentication ----------------------------------------------------
|
||||
|
||||
def _plugin_auth_path(self) -> Path:
|
||||
return self.data_dir() / AUTH_FILENAME
|
||||
|
||||
def _external_auth_path(self) -> Path:
|
||||
codex_home = os.environ.get("CODEX_HOME", "").strip()
|
||||
if codex_home:
|
||||
return Path(codex_home).expanduser() / AUTH_FILENAME
|
||||
return Path.home() / ".codex" / AUTH_FILENAME
|
||||
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
plugin_path = self._plugin_auth_path()
|
||||
if _has_chatgpt_tokens(plugin_path):
|
||||
return {
|
||||
"connected": True,
|
||||
"mode": "plugin",
|
||||
"auth_path": str(plugin_path),
|
||||
}
|
||||
external_path = self._external_auth_path()
|
||||
if _has_chatgpt_tokens(external_path):
|
||||
return {
|
||||
"connected": True,
|
||||
"mode": "external",
|
||||
"auth_path": str(external_path),
|
||||
}
|
||||
return {"connected": False, "mode": "", "auth_path": ""}
|
||||
|
||||
def supports_device_login(self) -> bool:
|
||||
return True
|
||||
|
||||
def start_device_login(self) -> dict[str, Any]:
|
||||
response = requests.post(
|
||||
f"{ISSUER}/api/accounts/deviceauth/usercode",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"client_id": CLIENT_ID},
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(_oauth_error(response))
|
||||
payload = response.json() if isinstance(response.json(), dict) else {}
|
||||
device_auth_id = str(payload.get("device_auth_id") or "")
|
||||
user_code = str(payload.get("user_code") or payload.get("usercode") or "")
|
||||
if not device_auth_id or not user_code:
|
||||
raise RuntimeError("Device authorization response did not include a code.")
|
||||
return {
|
||||
"device_auth_id": device_auth_id,
|
||||
"user_code": user_code,
|
||||
"interval": _safe_int(payload.get("interval"), 5),
|
||||
"expires_at": time.time() + DEVICE_CODE_TIMEOUT_SECONDS,
|
||||
"verification_url": f"{ISSUER}/codex/device",
|
||||
}
|
||||
|
||||
def poll_device_login(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
device_auth_id = str(payload.get("device_auth_id") or "")
|
||||
user_code = str(payload.get("user_code") or "")
|
||||
if not device_auth_id or not user_code:
|
||||
raise RuntimeError("Missing device_auth_id or user_code.")
|
||||
response = requests.post(
|
||||
f"{ISSUER}/api/accounts/deviceauth/token",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"device_auth_id": device_auth_id, "user_code": user_code},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code in {403, 404}:
|
||||
return {"completed": False}
|
||||
if not response.ok:
|
||||
raise RuntimeError(_oauth_error(response))
|
||||
data = response.json() if isinstance(response.json(), dict) else {}
|
||||
authorization_code = str(data.get("authorization_code") or "")
|
||||
verifier = str(data.get("code_verifier") or "")
|
||||
if not authorization_code or not verifier:
|
||||
raise RuntimeError("Device authorization response missing exchange data.")
|
||||
tokens = _exchange_code(
|
||||
authorization_code, f"{ISSUER}/deviceauth/callback", verifier
|
||||
)
|
||||
account_id = _derive_account_id(tokens["id_token"])
|
||||
self._write_auth(tokens, account_id)
|
||||
return {"completed": True, "account_id": account_id}
|
||||
|
||||
def can_disconnect(self, config: dict[str, Any] | None = None) -> bool:
|
||||
status = self.auth_status(config)
|
||||
return bool(status.get("connected"))
|
||||
|
||||
def disconnect(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
status = self.auth_status(config)
|
||||
path = self._plugin_auth_path()
|
||||
if status.get("mode") == "plugin" and path.exists():
|
||||
path.unlink()
|
||||
return {"removed": True, "mode": "plugin"}
|
||||
if status.get("mode") == "external":
|
||||
result = subprocess.run(
|
||||
[self.resolve_binary(config), "logout"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip())
|
||||
return {"removed": True, "mode": "external"}
|
||||
return {"removed": False, "message": "No Codex credentials found."}
|
||||
|
||||
def _write_auth(self, tokens: dict[str, str], account_id: str) -> None:
|
||||
auth_data = {
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": None,
|
||||
"tokens": {
|
||||
"id_token": tokens["id_token"],
|
||||
"access_token": tokens["access_token"],
|
||||
"refresh_token": tokens["refresh_token"],
|
||||
"account_id": account_id,
|
||||
},
|
||||
"last_refresh": _utc_now_iso(),
|
||||
}
|
||||
path = self._plugin_auth_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(auth_data, indent=2))
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _has_chatgpt_tokens(path: Path) -> bool:
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
tokens = data.get("tokens") if isinstance(data, dict) else None
|
||||
if not isinstance(tokens, dict):
|
||||
return False
|
||||
return bool(tokens.get("access_token") and tokens.get("refresh_token"))
|
||||
|
||||
|
||||
def _exchange_code(code: str, redirect_uri: str, verifier: str) -> dict[str, str]:
|
||||
response = requests.post(
|
||||
TOKEN_URL,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": CLIENT_ID,
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(_oauth_error(response))
|
||||
payload = response.json() if isinstance(response.json(), dict) else {}
|
||||
tokens = {
|
||||
"id_token": str(payload.get("id_token") or ""),
|
||||
"access_token": str(payload.get("access_token") or ""),
|
||||
"refresh_token": str(payload.get("refresh_token") or ""),
|
||||
}
|
||||
missing = [key for key, value in tokens.items() if not value]
|
||||
if missing:
|
||||
raise RuntimeError(f"OAuth token response is missing: {', '.join(missing)}")
|
||||
return tokens
|
||||
|
||||
|
||||
def _derive_account_id(id_token: str) -> str:
|
||||
claims = _parse_jwt_claims(id_token)
|
||||
auth_claims = claims.get("https://api.openai.com/auth")
|
||||
if isinstance(auth_claims, dict):
|
||||
value = auth_claims.get("chatgpt_account_id")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_jwt_claims(token: str) -> dict[str, Any]:
|
||||
import base64
|
||||
|
||||
try:
|
||||
payload_part = token.split(".")[1]
|
||||
padded = payload_part + "=" * (-len(payload_part) % 4)
|
||||
value = json.loads(base64.urlsafe_b64decode(padded))
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _oauth_error(response: requests.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
for key in ("error_description", "error"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return f"OAuth endpoint returned status {response.status_code}."
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
76
plugins/_orchestrator/helpers/adapters/cursor.py
Normal file
76
plugins/_orchestrator/helpers/adapters/cursor.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
|
||||
|
||||
_SECRET_KEYS = {"access_token", "api_key", "id_token", "refresh_token", "token"}
|
||||
_AUTH_FILES = (
|
||||
"auth.json",
|
||||
"credentials.json",
|
||||
"token.json",
|
||||
"agent/auth.json",
|
||||
"agent/credentials.json",
|
||||
"agent/token.json",
|
||||
)
|
||||
|
||||
|
||||
class CursorCliAdapter(TerminalAgentAdapter):
|
||||
id = "cursor"
|
||||
title = "Cursor CLI"
|
||||
binary = "agent"
|
||||
install_hint = "curl https://cursor.com/install -fsS | bash"
|
||||
description = "Cursor Agent CLI in headless print mode."
|
||||
|
||||
def _home(self) -> Path:
|
||||
configured = os.environ.get("CURSOR_HOME", "").strip()
|
||||
return Path(configured).expanduser() if configured else Path.home() / ".cursor"
|
||||
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
env_var = next(
|
||||
(name for name in ("CURSOR_API_KEY", "API_KEY_CURSOR") if os.environ.get(name)),
|
||||
"",
|
||||
)
|
||||
if env_var:
|
||||
return {"connected": True, "mode": "env", "auth_path": env_var}
|
||||
|
||||
home = self._home()
|
||||
for relative in _AUTH_FILES:
|
||||
path = home / relative
|
||||
try:
|
||||
if _file_has_secret(path):
|
||||
return {"connected": True, "mode": "external", "auth_path": str(path)}
|
||||
except OSError as exc:
|
||||
return {"connected": False, "mode": "", "auth_path": "", "error": str(exc)}
|
||||
|
||||
return {"connected": False, "mode": "", "auth_path": str(home)}
|
||||
|
||||
|
||||
def _file_has_secret(path: Path) -> bool:
|
||||
if not path.is_file() or path.stat().st_size <= 0:
|
||||
return False
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
try:
|
||||
return _contains_secret(json.loads(text))
|
||||
except ValueError:
|
||||
return _text_has_secret(text)
|
||||
|
||||
|
||||
def _text_has_secret(text: str) -> bool:
|
||||
return any(f'"{key}"' in text or f"{key} =" in text for key in _SECRET_KEYS)
|
||||
|
||||
|
||||
def _contains_secret(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if str(key) in _SECRET_KEYS and isinstance(item, str) and item.strip():
|
||||
return True
|
||||
if _contains_secret(item):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return any(_contains_secret(item) for item in value)
|
||||
return False
|
||||
97
plugins/_orchestrator/helpers/adapters/grok.py
Normal file
97
plugins/_orchestrator/helpers/adapters/grok.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
|
||||
|
||||
_SECRET_KEYS = {"access_token", "api_key", "id_token", "refresh_token", "token"}
|
||||
|
||||
|
||||
class GrokBuildAdapter(TerminalAgentAdapter):
|
||||
id = "grok"
|
||||
title = "Grok Build"
|
||||
binary = "grok"
|
||||
install_hint = "curl -fsSL https://x.ai/cli/install.sh | bash"
|
||||
description = "xAI Grok Build CLI in headless single-prompt mode."
|
||||
|
||||
def _home(self) -> Path:
|
||||
configured = os.environ.get("GROK_HOME", "").strip()
|
||||
return Path(configured).expanduser() if configured else Path.home() / ".grok"
|
||||
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
env_var = next(
|
||||
(name for name in ("XAI_API_KEY", "API_KEY_XAI") if os.environ.get(name)),
|
||||
"",
|
||||
)
|
||||
if env_var:
|
||||
return {"connected": True, "mode": "env", "auth_path": env_var}
|
||||
|
||||
home = self._home()
|
||||
for path in (home / "config.toml", home / "auth.json"):
|
||||
try:
|
||||
if _file_has_secret(path):
|
||||
return {"connected": True, "mode": "external", "auth_path": str(path)}
|
||||
except OSError as exc:
|
||||
return {"connected": False, "mode": "", "auth_path": "", "error": str(exc)}
|
||||
|
||||
auth_dir = home / "auth"
|
||||
try:
|
||||
if auth_dir.is_dir():
|
||||
for path in auth_dir.iterdir():
|
||||
if path.is_file() and _file_has_secret(path):
|
||||
return {
|
||||
"connected": True,
|
||||
"mode": "external",
|
||||
"auth_path": str(auth_dir),
|
||||
}
|
||||
except OSError as exc:
|
||||
return {"connected": False, "mode": "", "auth_path": "", "error": str(exc)}
|
||||
|
||||
return {"connected": False, "mode": "", "auth_path": str(home)}
|
||||
|
||||
|
||||
def _file_has_secret(path: Path) -> bool:
|
||||
if not path.is_file() or path.stat().st_size <= 0:
|
||||
return False
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if path.suffix == ".toml":
|
||||
return _toml_has_secret(text)
|
||||
try:
|
||||
return _contains_secret(json.loads(text))
|
||||
except ValueError:
|
||||
return _text_has_secret(text)
|
||||
|
||||
|
||||
def _toml_has_secret(text: str) -> bool:
|
||||
for line in text.splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or raw.startswith("#") or "=" not in raw:
|
||||
continue
|
||||
key, value = raw.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip("'\"")
|
||||
if key == "api_key" and value:
|
||||
return True
|
||||
if key == "env_key" and value and os.environ.get(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _text_has_secret(text: str) -> bool:
|
||||
return any(f'"{key}"' in text or f"{key} =" in text for key in _SECRET_KEYS)
|
||||
|
||||
|
||||
def _contains_secret(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if str(key) in _SECRET_KEYS and isinstance(item, str) and item.strip():
|
||||
return True
|
||||
if _contains_secret(item):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return any(_contains_secret(item) for item in value)
|
||||
return False
|
||||
122
plugins/_orchestrator/helpers/adapters/hermes.py
Normal file
122
plugins/_orchestrator/helpers/adapters/hermes.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
|
||||
|
||||
_AUTH_ENV_VARS = {
|
||||
"ALIBABA_CODING_PLAN_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN",
|
||||
"ARCEEAI_API_KEY",
|
||||
"AZURE_FOUNDRY_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"DASHSCOPE_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GLM_API_KEY",
|
||||
"GMI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"HF_TOKEN",
|
||||
"KILOCODE_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"KIMI_CODING_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"NVIDIA_API_KEY",
|
||||
"NOVITA_API_KEY",
|
||||
"OLLAMA_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"STEPFUN_API_KEY",
|
||||
"TOKENHUB_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
}
|
||||
|
||||
_SECRET_KEYS = {
|
||||
"access_token",
|
||||
"agent_key",
|
||||
"api_key",
|
||||
"id_token",
|
||||
"refresh_token",
|
||||
"runtime_api_key",
|
||||
"token",
|
||||
}
|
||||
|
||||
|
||||
class HermesAgentAdapter(TerminalAgentAdapter):
|
||||
id = "hermes"
|
||||
title = "Hermes Agent"
|
||||
binary = "hermes"
|
||||
install_hint = "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||||
description = "Nous Research Hermes Agent CLI in quiet headless chat mode."
|
||||
|
||||
def _home(self) -> Path:
|
||||
configured = os.environ.get("HERMES_HOME", "").strip()
|
||||
return Path(configured).expanduser() if configured else Path.home() / ".hermes"
|
||||
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
env_var = next((name for name in sorted(_AUTH_ENV_VARS) if os.environ.get(name)), "")
|
||||
if env_var:
|
||||
return {"connected": True, "mode": "env", "auth_path": env_var}
|
||||
|
||||
home = self._home()
|
||||
env_path = home / ".env"
|
||||
try:
|
||||
if _env_file_has_key(env_path):
|
||||
return {"connected": True, "mode": "external", "auth_path": str(env_path)}
|
||||
except OSError as exc:
|
||||
return {"connected": False, "mode": "", "auth_path": "", "error": str(exc)}
|
||||
|
||||
auth_path = home / "auth.json"
|
||||
try:
|
||||
if _auth_store_has_credentials(auth_path):
|
||||
return {"connected": True, "mode": "external", "auth_path": str(auth_path)}
|
||||
except OSError as exc:
|
||||
return {"connected": False, "mode": "", "auth_path": "", "error": str(exc)}
|
||||
|
||||
return {"connected": False, "mode": "", "auth_path": ""}
|
||||
|
||||
|
||||
def _env_file_has_key(path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or raw.startswith("#") or "=" not in raw:
|
||||
continue
|
||||
key, value = raw.split("=", 1)
|
||||
if key.strip() in _AUTH_ENV_VARS and value.strip().strip("'\""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _auth_store_has_credentials(path: Path) -> bool:
|
||||
if not path.is_file() or path.stat().st_size <= 0:
|
||||
return False
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
return False
|
||||
return _contains_secret(data)
|
||||
|
||||
|
||||
def _contains_secret(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if str(key) in _SECRET_KEYS and isinstance(item, str) and len(item.strip()) > 3:
|
||||
return True
|
||||
if _contains_secret(item):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return any(_contains_secret(item) for item in value)
|
||||
return False
|
||||
74
plugins/_orchestrator/helpers/adapters/opencode.py
Normal file
74
plugins/_orchestrator/helpers/adapters/opencode.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
|
||||
|
||||
_ENV_KEYS = {
|
||||
"ANTHROPIC_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GOOGLE_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
}
|
||||
|
||||
_SECRET_KEYS = {"access_token", "api_key", "refresh_token", "token"}
|
||||
|
||||
|
||||
class OpenCodeAdapter(TerminalAgentAdapter):
|
||||
id = "opencode"
|
||||
title = "OpenCode"
|
||||
binary = "opencode"
|
||||
install_hint = "curl -fsSL https://opencode.ai/install | bash"
|
||||
description = "OpenCode CLI in non-interactive run mode."
|
||||
|
||||
def auth_status(self, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
env_var = next((name for name in sorted(_ENV_KEYS) if os.environ.get(name)), "")
|
||||
if env_var:
|
||||
return {"connected": True, "mode": "env", "auth_path": env_var}
|
||||
|
||||
path = _auth_path()
|
||||
try:
|
||||
if _auth_store_has_credentials(path):
|
||||
return {"connected": True, "mode": "external", "auth_path": str(path)}
|
||||
except OSError as exc:
|
||||
return {"connected": False, "mode": "", "auth_path": "", "error": str(exc)}
|
||||
|
||||
return {"connected": False, "mode": "", "auth_path": str(path)}
|
||||
|
||||
|
||||
def _auth_path() -> Path:
|
||||
data_home = os.environ.get("XDG_DATA_HOME", "").strip()
|
||||
root = Path(data_home).expanduser() if data_home else Path.home() / ".local" / "share"
|
||||
return root / "opencode" / "auth.json"
|
||||
|
||||
|
||||
def _auth_store_has_credentials(path: Path) -> bool:
|
||||
if not path.is_file() or path.stat().st_size <= 0:
|
||||
return False
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
return False
|
||||
return _contains_secret(data)
|
||||
|
||||
|
||||
def _contains_secret(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if str(key) in _SECRET_KEYS and isinstance(item, str) and item.strip():
|
||||
return True
|
||||
if _contains_secret(item):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return any(_contains_secret(item) for item in value)
|
||||
return False
|
||||
44
plugins/_orchestrator/helpers/registry.py
Normal file
44
plugins/_orchestrator/helpers/registry.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.a0 import AgentZeroAdapter
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
from plugins._orchestrator.helpers.adapters.claude import ClaudeCodeAdapter
|
||||
from plugins._orchestrator.helpers.adapters.codex import CodexAdapter
|
||||
from plugins._orchestrator.helpers.adapters.cursor import CursorCliAdapter
|
||||
from plugins._orchestrator.helpers.adapters.grok import GrokBuildAdapter
|
||||
from plugins._orchestrator.helpers.adapters.hermes import HermesAgentAdapter
|
||||
from plugins._orchestrator.helpers.adapters.opencode import OpenCodeAdapter
|
||||
|
||||
# Register new terminal agent adapters here.
|
||||
_ADAPTERS: dict[str, TerminalAgentAdapter] = {
|
||||
adapter.id: adapter
|
||||
for adapter in (
|
||||
AgentZeroAdapter(),
|
||||
CodexAdapter(),
|
||||
ClaudeCodeAdapter(),
|
||||
CursorCliAdapter(),
|
||||
GrokBuildAdapter(),
|
||||
HermesAgentAdapter(),
|
||||
OpenCodeAdapter(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def get_adapter(agent_id: str) -> TerminalAgentAdapter:
|
||||
adapter = _ADAPTERS.get(str(agent_id or "").strip().lower())
|
||||
if adapter is None:
|
||||
known = ", ".join(sorted(_ADAPTERS))
|
||||
raise ValueError(f"Unknown terminal agent '{agent_id}'. Available: {known}")
|
||||
return adapter
|
||||
|
||||
|
||||
def list_adapters() -> list[TerminalAgentAdapter]:
|
||||
return list(_ADAPTERS.values())
|
||||
|
||||
|
||||
def adapter_config(agent_id: str, plugin_config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
source = plugin_config if isinstance(plugin_config, dict) else {}
|
||||
raw = source.get(agent_id)
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
8
plugins/_orchestrator/plugin.yaml
Normal file
8
plugins/_orchestrator/plugin.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: _orchestrator
|
||||
title: Orchestrator
|
||||
description: Load-on-demand skill and status UI for external headless terminal coding agents such as Agent Zero, Codex, Claude Code, Cursor CLI, Grok Build, Hermes Agent, and OpenCode.
|
||||
version: 0.1.0
|
||||
settings_sections:
|
||||
- external
|
||||
per_project_config: false
|
||||
per_agent_config: false
|
||||
42
plugins/_orchestrator/skills/AGENTS.md
Normal file
42
plugins/_orchestrator/skills/AGENTS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Orchestrator Skills - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Own the plugin's load-on-demand skill collection.
|
||||
- Keep external-agent orchestration instructions available only when the user asks for terminal/headless agent delegation.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns skill folders under `skills/`, currently `orchestrator/`.
|
||||
- Does not own Python adapter code, settings UI, or credential storage.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Skills must use Agent Zero's ordinary shell/code execution tools, either container `code_execution_tool` or A0 Connector `code_execution_remote`, not a plugin-specific terminal agent tool.
|
||||
- Skill instructions plus their local reference files must be self-contained enough for the agent to run install checks, login/setup, smoke prompts, real tasks, and verification.
|
||||
- Generic orchestration belongs in `SKILL.md`; per-agent details belong in the nearest `references/` file.
|
||||
- Non-A0 agents first choose host-vs-container execution. Unknown preferences must be asked once and saved with memory when available.
|
||||
- Host-machine agents use A0 CLI remote execution; container agents use the human-in-the-loop setup loop.
|
||||
- A0 Headless remains the setup exception and must ask for same-instance vs another/spun-up instance when the target is ambiguous.
|
||||
- The skill must instruct agents not to paste or request secrets in chat when safer browser/device/login-prompt paths exist.
|
||||
- The skill must instruct agents not to use Computer Use to operate coding-agent terminals or TUIs.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Keep the SKILL.md text operational, not encyclopedic.
|
||||
- Add or update per-agent reference files when CLI-specific commands or auth behavior change.
|
||||
- When CLI flags change, update the exact command blocks and the regression tests that assert important phrases.
|
||||
- If a loaded chat keeps old behavior, start a fresh chat or reload `orchestrator`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the skill text contract test:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
| Child | Scope |
|
||||
| --- | --- |
|
||||
| [orchestrator/AGENTS.md](orchestrator/AGENTS.md) | The `orchestrator` skill manifest and delegation workflow body. |
|
||||
48
plugins/_orchestrator/skills/orchestrator/AGENTS.md
Normal file
48
plugins/_orchestrator/skills/orchestrator/AGENTS.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# orchestrator Skill - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Teach Agent Zero how to delegate work to external terminal/headless coding agents only after the skill is loaded.
|
||||
- Keep generic orchestration rules in `SKILL.md` and agent-specific command runbooks in `references/`.
|
||||
- Provide copy-ready command patterns for A0 Headless, Codex CLI, Claude Code, Cursor CLI, Grok Build, Hermes Agent, and OpenCode through reference files.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns `SKILL.md`.
|
||||
- Owns per-agent references under `references/`.
|
||||
- Does not own adapter status detection, UI state, or installation APIs.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Front matter must allow `code_execution_tool`, `code_execution_remote`, `memory_load`, and `memory_save`.
|
||||
- The body must explicitly state that no `terminal_agent` tool exists.
|
||||
- The setup flow must ask or recall host-vs-container preference for each coding agent before running non-A0 adapters.
|
||||
- The host flow must use A0 CLI `code_execution_remote`; the container flow must check install, install only the requested CLI if missing, probe version/help, run a smoke prompt, handle auth/setup with the user, then retry before the real task.
|
||||
- Agent-specific commands, auth quirks, install hints, and smoke prompts belong in the matching `references/<agent>.md` file.
|
||||
- `SKILL.md` must tell agents to read only the relevant reference file before acting.
|
||||
- Claude Code reference guidance must avoid plain `claude` and bare `claude auth login` until the user chooses `--claudeai`, `--console`, `--sso`, or external API-key auth.
|
||||
- Claude Code API-key auth must source Agent Zero secrets from `/a0/usr/.env`, not from the workdir.
|
||||
- If a full-screen Claude Code TUI is already open, the Claude reference must reset the terminal session instead of sending Enter or `/login`.
|
||||
- Root shells must not use Claude Code `bypassPermissions`; non-root shells may use it as the default non-interactive mode.
|
||||
- Long-running commands should stay in a shell session and be polled there.
|
||||
- Computer Use must not be used to drive coding-agent terminals or TUIs.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Put exact commands in fenced bash blocks when the agent needs to execute them.
|
||||
- State boundaries in imperative language so Agent Zero follows them during chat.
|
||||
- Keep expected smoke responses deterministic.
|
||||
- Keep `SKILL.md` short; put growing per-agent details in references.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
| Child | Scope |
|
||||
| --- | --- |
|
||||
| [references/AGENTS.md](references/AGENTS.md) | Per-agent command, setup, auth, smoke, and real-task runbooks. |
|
||||
105
plugins/_orchestrator/skills/orchestrator/SKILL.md
Normal file
105
plugins/_orchestrator/skills/orchestrator/SKILL.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
---
|
||||
name: orchestrator
|
||||
description: Use when delegating coding or repository work to external terminal coding agents such as the user's host Claude Code/Codex/Cursor CLI or container-installed pal agents.
|
||||
triggers:
|
||||
- "terminal agent"
|
||||
- "external coding agent"
|
||||
- "delegate to codex"
|
||||
- "delegate to claude code"
|
||||
- "delegate to cursor"
|
||||
- "delegate to cursor cli"
|
||||
- "delegate to grok build"
|
||||
- "delegate to a0 headless"
|
||||
- "delegate to hermes"
|
||||
- "delegate to opencode"
|
||||
allowed_tools:
|
||||
- code_execution_tool
|
||||
- code_execution_remote
|
||||
- memory_load
|
||||
- memory_save
|
||||
---
|
||||
|
||||
# Orchestrator
|
||||
|
||||
Use shell/code execution to run external terminal agents. There is no `terminal_agent` tool: this skill exists so these heavy delegation instructions load only when the task needs them.
|
||||
|
||||
Prefer the user's own host-machine CLI when that is what they mean. Container-installed agents are the pal scenario: useful and supported, but secondary.
|
||||
|
||||
## Rules
|
||||
|
||||
- For Codex, Claude Code, Cursor CLI, Grok Build, Hermes Agent, and OpenCode, first decide the execution place: the user's local machine through A0 CLI, or the Agent Zero Docker/container shell.
|
||||
- If the user did not specify local/host versus container, check memory for this coding agent's execution-place preference. If no current preference is known, ask: "Do you want me to use your own local <agent> through A0 CLI, or the <agent> installed inside the Agent Zero container?"
|
||||
- After the user chooses, save a stable per-agent preference with `memory_save`, for example: "For orchestrator, the user prefers Claude Code to run on the host machine through A0 CLI by default." If memory tools are unavailable, continue without saving.
|
||||
- Never use Computer Use to drive coding-agent terminals, menus, or TUIs. Use headless CLI commands through `code_execution_remote` or `code_execution_tool`; if that is not possible, stop and ask.
|
||||
- ACP may be available as a community plugin, but do not assume it is installed. Mention it only if the user explicitly asks for ACP or the direct CLI path is unsuitable.
|
||||
- For Codex, Claude Code, Cursor CLI, Grok Build, Hermes Agent, and OpenCode, setup is part of the workflow: check whether the CLI is installed, install only the requested CLI if missing, probe its version/help, run a tiny smoke prompt, then run the real task.
|
||||
- Keep authentication human-in-the-loop. You may start the CLI login/setup command, relay the exact URL, device code, browser step, or prompt to the user, then wait for the user to confirm completion before retrying the smoke prompt.
|
||||
- Never start a full-screen CLI/TUI as a login fallback. If you accidentally opened one and see welcome, theme, provider, or unreadable menu output, reset the terminal session instead of sending keys into it.
|
||||
- If login/setup shows a menu or provider choices, show those choices to the user in chat and ask which one to select. Keep the terminal session open, then send the user's selected number/key back to that session.
|
||||
- Never ask the user to paste secrets into chat unless there is no safer path. Prefer browser/device login, the CLI's own prompt, or an environment variable set outside chat.
|
||||
- Always choose an explicit working directory for repository work. Prefer the user's project path over Agent Zero's default workdir.
|
||||
- Optional command defaults may be stored in `/a0/usr/plugins/_orchestrator/config.json`. Read only the adapter block you need, and never print secrets.
|
||||
- For long-running commands, start the CLI in a shell session and poll that session's output. Do not add your own timeout wrapper around the terminal agent.
|
||||
- Pass a self-contained task brief: goal, target files or repo path, constraints, verification commands, and expected output.
|
||||
- After the terminal agent finishes, inspect its output and verify important changes yourself before reporting success.
|
||||
|
||||
## Host CLI Flow
|
||||
|
||||
Use this when the user wants their own local Claude Code, Codex, Cursor CLI, Grok Build, Hermes Agent, or OpenCode.
|
||||
|
||||
1. Use `code_execution_remote`, not `code_execution_tool`, because paths, shell, login, and installed CLIs belong to the A0 CLI host machine.
|
||||
2. If `code_execution_remote` is unavailable or reports no connected CLI / remote execution disabled, tell the user exactly:
|
||||
|
||||
```text
|
||||
To use your local coding agents, open a terminal on your computer and install A0 CLI if needed.
|
||||
|
||||
macOS / Linux:
|
||||
curl -LsSf https://cli.agent-zero.ai/install.sh | sh
|
||||
|
||||
Windows PowerShell:
|
||||
irm https://cli.agent-zero.ai/install.ps1 | iex
|
||||
|
||||
If A0 CLI is already installed but not running, open a terminal and run:
|
||||
a0
|
||||
|
||||
Connect it to this Agent Zero instance. Select the chat/session, or type the Agent Zero URL manually if this is a remote/VPS instance. In A0 CLI, press F4 to allow Remote Code Execution. Press F3 too if the task needs host file writes. Then ask me again to use your local <agent>.
|
||||
```
|
||||
|
||||
3. Once remote execution is available, optionally load `host-code-execution` for host-shell safety rules.
|
||||
4. Read the relevant agent reference file. Run the same install/probe, smoke, auth, and real-task commands with `code_execution_remote`.
|
||||
5. Keep workdir semantics host-side: `cd` to the host project path, not `/a0/usr/workdir`, unless the user explicitly wants the container workspace.
|
||||
|
||||
## Container Pal Flow
|
||||
|
||||
Use this when the user chooses the Agent Zero container or explicitly wants a pal agent inside Docker.
|
||||
|
||||
For Agent Zero Headless, read `references/a0.md` and follow its target-selection flow instead of the non-A0 setup loop.
|
||||
|
||||
For every non-A0 container adapter:
|
||||
|
||||
1. Read only the relevant reference file:
|
||||
|
||||
```bash
|
||||
# Use skills_tool action=read_file on the matching file under references/.
|
||||
```
|
||||
|
||||
2. Check install with the reference command.
|
||||
3. If missing, install only that requested CLI with the command listed in its reference, then run the install check again.
|
||||
4. Probe `--version` or `--help`.
|
||||
5. Run the reference smoke prompt.
|
||||
6. If the smoke prompt reports missing auth, run only the login/setup command named in that reference; do not run the bare CLI as a login fallback. Relay URLs, device codes, prompts, and visible menu choices to the user in chat, then wait. If a prior command left a TUI open, reset the terminal session before continuing.
|
||||
7. After the user confirms, rerun the smoke prompt. Only then run the real task.
|
||||
|
||||
You can consult both host and container agents in one workflow. Keep their shell sessions separate, label which agent/location produced each answer, and compare results before acting.
|
||||
|
||||
## Reference Files
|
||||
|
||||
- Agent Zero Headless: `references/a0.md`
|
||||
- Codex CLI: `references/codex.md`
|
||||
- Claude Code: `references/claude.md`
|
||||
- Cursor CLI: `references/cursor.md`
|
||||
- Grok Build: `references/grok.md`
|
||||
- Hermes Agent: `references/hermes.md`
|
||||
- OpenCode: `references/opencode.md`
|
||||
|
||||
Keep generic orchestration rules here. Put agent-specific commands, auth quirks, install hints, and smoke prompts in the matching reference file.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Terminal Agent References - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Own per-agent command references for the `orchestrator` skill.
|
||||
- Keep `SKILL.md` generic while preserving exact install, auth, smoke, and run commands for each supported terminal agent.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns one markdown file per agent id, currently `a0.md`, `codex.md`, `claude.md`, `cursor.md`, `grok.md`, `hermes.md`, and `opencode.md`.
|
||||
- Does not own adapter status code, settings UI, or global orchestration rules.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Each reference must include when to use the agent, install/probe commands, auth/setup behavior, smoke command, and real-task command shape.
|
||||
- Generic rules such as no bare CLI fallback, no secrets in chat, explicit workdir, and smoke-before-real stay in parent `SKILL.md`.
|
||||
- Reference filenames must match adapter ids where possible.
|
||||
- When adding a reference, update the parent `SKILL.md` reference index, README adding-agent steps, and tests.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Keep each file operational and short. The agent should be able to copy the command block safely.
|
||||
- Redact or avoid secrets; use environment variables and Agent Zero secret paths without printing values.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
This folder has no child DOX files.
|
||||
31
plugins/_orchestrator/skills/orchestrator/references/a0.md
Normal file
31
plugins/_orchestrator/skills/orchestrator/references/a0.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Agent Zero Headless
|
||||
|
||||
Use `a0 headless` when the user wants another Agent Zero runtime to help, or as a secondary use case when this same Agent Zero instance should ask itself a focused question through the local headless interface.
|
||||
|
||||
A0 is the setup exception. Do not run a generic login flow. If the user did not specify the target, ask whether they want to use this same Agent Zero instance or another/spun-up instance. For another instance, ask for or use a configured host. For protected hosts, use configured shell environment credentials when present.
|
||||
|
||||
Avoid recursive loops. Tell the target Agent Zero instance to answer directly and not to delegate back through terminal agents.
|
||||
|
||||
## Install And Probe
|
||||
|
||||
```bash
|
||||
command -v a0 >/dev/null || test -x /opt/venv/bin/a0
|
||||
a0 --help >/dev/null || /opt/venv/bin/a0 --help >/dev/null
|
||||
```
|
||||
|
||||
## New Chat
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
a0 headless --host http://localhost:80 --no-docker-discovery --output jsonl --new-chat --workspace "$WORKDIR" -p "$TASK"
|
||||
```
|
||||
|
||||
Use a configured remote host instead of `http://localhost:80` when the settings screen or user provides one.
|
||||
|
||||
## Continue Chat
|
||||
|
||||
```bash
|
||||
a0 headless --host "$A0_HOST" --no-docker-discovery --output jsonl --chat "$CONTEXT_ID" --workspace "$WORKDIR" -p "$TASK"
|
||||
```
|
||||
|
||||
For protected hosts, use the CLI's supported environment variables, for example `A0_USERNAME` and `A0_PASSWORD`, rather than inventing username/password flags.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Claude Code
|
||||
|
||||
Use Claude Code in print mode. Skip permissions is the default non-interactive mode only when the shell is not root; Claude Code rejects that mode under root/sudo.
|
||||
|
||||
## Install And Probe
|
||||
|
||||
```bash
|
||||
command -v claude >/dev/null || npm install -g @anthropic-ai/claude-code
|
||||
claude --version
|
||||
claude auth status || true
|
||||
```
|
||||
|
||||
## Smoke Prompt
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
claude -p "Respond exactly: TERMINAL_AGENT_SMOKE_OK" --output-format json
|
||||
else
|
||||
claude -p "Respond exactly: TERMINAL_AGENT_SMOKE_OK" --output-format json --permission-mode bypassPermissions --allowedTools Bash,Read,Edit
|
||||
fi
|
||||
```
|
||||
|
||||
## Login
|
||||
|
||||
If Claude Code reports `Not logged in` or asks for `/login`, do not open the TUI. First ask the user which auth mode to use:
|
||||
|
||||
1. Claude subscription: `claude auth login --claudeai`
|
||||
2. Anthropic Console/API billing: `claude auth login --console`
|
||||
3. SSO: `claude auth login --sso`
|
||||
4. API key outside chat: ask the user to open Settings > External Services > Secrets Management and enter the key in `/a0/usr/.env`, right after `ANTHROPIC_API_KEY=`. The Agent Zero `.env` file is `/a0/usr/.env`, not `$WORKDIR/.env`.
|
||||
|
||||
After the user chooses, run only that command and relay its browser/device instructions. If the user gives an email to prefill, add `--email "<email>"`. Wait for confirmation, then retry the smoke prompt. Do not redirect them to a Docker shell just to choose a menu option. Do not start plain `claude` for login; it opens the first-run TUI (theme/provider menus) and is not a safe agent-driven login surface. Do not run bare `claude auth login` when provider choice is still unknown.
|
||||
|
||||
If a previous attempt already opened plain `claude` and the terminal shows theme/provider menus or no readable login URL, stop. Reset that terminal session; do not press Enter, do not send `/login`, and do not keep polling the stuck TUI. Then ask for the auth mode above and run the matching `claude auth login ...` command in a fresh terminal session.
|
||||
|
||||
## Agent Zero Secrets
|
||||
|
||||
When using an API key from Agent Zero secrets, source `/a0/usr/.env` without printing it. Some Agent Zero installs store the Anthropic key as `API_KEY_ANTHROPIC`; map it to the CLI variable before running Claude Code:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. /a0/usr/.env
|
||||
set +a
|
||||
[ -z "${ANTHROPIC_API_KEY:-}" ] && [ -n "${API_KEY_ANTHROPIC:-}" ] && export ANTHROPIC_API_KEY="$API_KEY_ANTHROPIC"
|
||||
```
|
||||
|
||||
## Real Task
|
||||
|
||||
Replace the smoke prompt with `$TASK`.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Codex CLI
|
||||
|
||||
Use Codex for autonomous coding tasks in a repository. Inside the Agent Zero Docker runtime, the Codex sandbox may fail, so the Docker-safe default uses `--dangerously-bypass-approvals-and-sandbox`.
|
||||
|
||||
## Install And Probe
|
||||
|
||||
```bash
|
||||
command -v codex >/dev/null || npm install -g @openai/codex
|
||||
codex --version
|
||||
```
|
||||
|
||||
## Optional Plugin-Owned Login
|
||||
|
||||
If the settings screen connected Codex with plugin-owned device login, run Codex with:
|
||||
|
||||
```bash
|
||||
export CODEX_HOME=/a0/usr/plugins/_orchestrator/data/codex
|
||||
```
|
||||
|
||||
If Codex reports missing auth, prefer the plugin settings device-code login when available. Otherwise run `codex login`, relay the device/browser instructions to the user, wait for confirmation, and retry the smoke prompt.
|
||||
|
||||
## Smoke Prompt
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "Respond exactly: TERMINAL_AGENT_SMOKE_OK"
|
||||
```
|
||||
|
||||
## Real Task
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
codex exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$TASK"
|
||||
```
|
||||
|
||||
If the runtime supports Codex's own sandbox, omit `--dangerously-bypass-approvals-and-sandbox`. If the settings screen has a model override, add `-m "$MODEL"`.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Cursor CLI
|
||||
|
||||
Use Cursor CLI for headless Cursor Agent tasks. The official binary is `agent`; running it without `-p` starts the interactive terminal UI, so use `agent -p` for Agent Zero delegation.
|
||||
|
||||
## Install And Probe
|
||||
|
||||
```bash
|
||||
command -v agent >/dev/null || curl https://cursor.com/install -fsS | bash
|
||||
agent --version
|
||||
agent status || true
|
||||
```
|
||||
|
||||
## Smoke Prompt
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
agent -p --output-format text "Respond exactly: TERMINAL_AGENT_SMOKE_OK"
|
||||
```
|
||||
|
||||
## Login
|
||||
|
||||
For scripts and containers, prefer API-key auth:
|
||||
|
||||
```bash
|
||||
export CURSOR_API_KEY="..."
|
||||
```
|
||||
|
||||
Do not ask the user to paste the key into chat. Ask them to set `CURSOR_API_KEY` in the runtime environment, or to add it through Settings > External Services > Secrets Management when that maps into `/a0/usr/.env`.
|
||||
|
||||
When using Agent Zero secrets, source `/a0/usr/.env` without printing it. If the key is stored as `API_KEY_CURSOR`, map it to the CLI variable before running Cursor CLI:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. /a0/usr/.env
|
||||
set +a
|
||||
[ -z "${CURSOR_API_KEY:-}" ] && [ -n "${API_KEY_CURSOR:-}" ] && export CURSOR_API_KEY="$API_KEY_CURSOR"
|
||||
```
|
||||
|
||||
For browser login in a container or remote terminal, run:
|
||||
|
||||
```bash
|
||||
NO_OPEN_BROWSER=1 agent login
|
||||
```
|
||||
|
||||
Relay the printed URL to the user, wait for confirmation, then retry the smoke prompt. Use `agent status` to check auth and `agent logout` only when the user explicitly asks to clear Cursor CLI credentials.
|
||||
|
||||
## Real Task
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
agent -p --force --output-format text "$TASK"
|
||||
```
|
||||
|
||||
Use `--output-format json` when the caller needs structured output, or `--output-format stream-json --stream-partial-output` when polling progress. Select models with Cursor CLI's `/model` command in an interactive setup, not with an invented flag.
|
||||
57
plugins/_orchestrator/skills/orchestrator/references/grok.md
Normal file
57
plugins/_orchestrator/skills/orchestrator/references/grok.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Grok Build
|
||||
|
||||
Use Grok Build for headless xAI coding-agent tasks. Running `grok` with no arguments starts the interactive TUI, so use `-p` for Agent Zero delegation.
|
||||
|
||||
## Install And Probe
|
||||
|
||||
```bash
|
||||
command -v grok >/dev/null || curl -fsSL https://x.ai/cli/install.sh | bash
|
||||
grok version || grok --version || grok --help
|
||||
```
|
||||
|
||||
If the installer host is blocked, use the official npm distribution instead:
|
||||
|
||||
```bash
|
||||
npm install -g @xai-official/grok
|
||||
```
|
||||
|
||||
## Smoke Prompt
|
||||
|
||||
```bash
|
||||
grok --no-auto-update --cwd "$WORKDIR" -p "Respond exactly: TERMINAL_AGENT_SMOKE_OK" --output-format json --always-approve --no-alt-screen
|
||||
```
|
||||
|
||||
## Login
|
||||
|
||||
If Grok Build needs auth, prefer API-key auth for headless automation:
|
||||
|
||||
```bash
|
||||
export XAI_API_KEY="xai-..."
|
||||
```
|
||||
|
||||
Do not ask the user to paste the key into chat. Ask them to set `XAI_API_KEY` in the runtime environment, or to add it through Settings > External Services > Secrets Management when that maps into `/a0/usr/.env`.
|
||||
|
||||
When using Agent Zero secrets, source `/a0/usr/.env` without printing it. Some Agent Zero installs store the xAI key as `API_KEY_XAI`; map it to the CLI variable before running Grok Build:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. /a0/usr/.env
|
||||
set +a
|
||||
[ -z "${XAI_API_KEY:-}" ] && [ -n "${API_KEY_XAI:-}" ] && export XAI_API_KEY="$API_KEY_XAI"
|
||||
```
|
||||
|
||||
For account login in a container or remote terminal, run:
|
||||
|
||||
```bash
|
||||
grok login --device-auth
|
||||
```
|
||||
|
||||
Relay the printed URL and user code to the user, wait for confirmation, then retry the smoke prompt. Do not run bare `grok`; it opens the full-screen TUI.
|
||||
|
||||
## Real Task
|
||||
|
||||
```bash
|
||||
grok --no-auto-update --cwd "$WORKDIR" -p "$TASK" --output-format json --always-approve --no-alt-screen
|
||||
```
|
||||
|
||||
Add `-m "$MODEL"` only when the user or settings provide a model override. For session continuation, use `--session-id`, `--resume`, or `--continue` only when the user asks for it.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Hermes Agent
|
||||
|
||||
Use Hermes Agent for quiet headless chat. Skip approvals by default for non-interactive delegation.
|
||||
|
||||
## Install And Probe
|
||||
|
||||
```bash
|
||||
command -v hermes >/dev/null || curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
hermes --version || hermes --help
|
||||
```
|
||||
|
||||
## Smoke Prompt
|
||||
|
||||
```bash
|
||||
cd "$WORKDIR"
|
||||
hermes chat --quiet --source tool -q "Respond exactly: TERMINAL_AGENT_SMOKE_OK" --yolo
|
||||
```
|
||||
|
||||
## Login
|
||||
|
||||
If Hermes needs setup, run `hermes setup --portal` when available, relay the OAuth/browser instructions to the user, wait for confirmation, and retry the smoke prompt. If it asks for a provider API key, have the user type it into the CLI prompt or set it as an environment variable outside chat.
|
||||
|
||||
## Real Task
|
||||
|
||||
Replace the smoke prompt with `$TASK`. Add `--model`, `--provider`, or `--toolsets` only when the user or settings provide them.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# OpenCode
|
||||
|
||||
OpenCode has a non-interactive run mode suitable for this skill.
|
||||
|
||||
## Install And Probe
|
||||
|
||||
```bash
|
||||
command -v opencode >/dev/null || curl -fsSL https://opencode.ai/install | bash
|
||||
opencode --version || opencode --help
|
||||
```
|
||||
|
||||
## Smoke Prompt
|
||||
|
||||
```bash
|
||||
opencode run --dir "$WORKDIR" --auto "Respond exactly: TERMINAL_AGENT_SMOKE_OK"
|
||||
```
|
||||
|
||||
## Login
|
||||
|
||||
If OpenCode needs auth, run `opencode auth login`, relay the provider/browser/key prompt to the user, wait for confirmation, and retry the smoke prompt. If that command is unavailable, run `opencode`, use `/connect`, and keep the same human-in-the-loop boundary.
|
||||
|
||||
## Real Task
|
||||
|
||||
Replace the smoke prompt with `$TASK`. Add `--model` or `--agent` only when the user or settings provide them.
|
||||
34
plugins/_orchestrator/tests/AGENTS.md
Normal file
34
plugins/_orchestrator/tests/AGENTS.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Orchestrator Tests - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Provide lightweight regression checks for the plugin's source contracts.
|
||||
- Catch accidental reintroduction of the old tool/runner architecture and important skill instruction drift.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns direct test scripts under `tests/`, currently `test_status_adapters.py`.
|
||||
- Does not own generated caches or live CLI credentials.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Tests must run with the Agent Zero framework runtime, not the agent execution runtime.
|
||||
- Keep tests self-contained and deterministic. They may inspect files and adapter metadata but should not call network login endpoints or run real terminal agents.
|
||||
- When adding an adapter, assert registry order and status-only behavior as needed.
|
||||
- When changing setup/login guidance, add or update assertions for the phrases that protect the workflow.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Prefer simple assert-based checks that can run as a script inside Docker.
|
||||
- Avoid broad snapshots of full docs; assert the contract phrases that matter.
|
||||
|
||||
## Verification
|
||||
|
||||
- Main check:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
This folder has no child DOX files.
|
||||
230
plugins/_orchestrator/tests/test_status_adapters.py
Normal file
230
plugins/_orchestrator/tests/test_status_adapters.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
repo_root = next(
|
||||
parent for parent in Path(__file__).resolve().parents if (parent / "agent.py").is_file()
|
||||
)
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
from plugins._orchestrator.helpers.adapters.cursor import CursorCliAdapter
|
||||
from plugins._orchestrator.helpers.adapters.grok import _toml_has_secret
|
||||
from plugins._orchestrator.helpers.adapters.grok import GrokBuildAdapter
|
||||
from plugins._orchestrator.helpers.adapters.base import TerminalAgentAdapter
|
||||
from plugins._orchestrator.helpers.registry import list_adapters
|
||||
|
||||
|
||||
class DummyAdapter(TerminalAgentAdapter):
|
||||
id = "dummy"
|
||||
binary = "missing-dummy-agent"
|
||||
|
||||
def auth_status(self, config=None):
|
||||
return {"connected": True, "mode": "external", "auth_path": ""}
|
||||
|
||||
|
||||
def test_configured_absolute_binary_is_installed():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
binary = Path(tmp) / "agent"
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
binary.chmod(0o755)
|
||||
|
||||
adapter = DummyAdapter()
|
||||
config = {"binary": str(binary)}
|
||||
assert adapter.resolve_binary(config) == str(binary)
|
||||
assert adapter.is_installed(config)
|
||||
|
||||
|
||||
def test_registry_order_puts_a0_first():
|
||||
assert [adapter.id for adapter in list_adapters()] == [
|
||||
"a0",
|
||||
"codex",
|
||||
"claude",
|
||||
"cursor",
|
||||
"grok",
|
||||
"hermes",
|
||||
"opencode",
|
||||
]
|
||||
|
||||
|
||||
def test_adapters_are_status_only():
|
||||
for adapter in list_adapters():
|
||||
assert not hasattr(adapter, "install_command")
|
||||
assert not hasattr(adapter, "build_command")
|
||||
assert not hasattr(adapter, "parse_session_id")
|
||||
assert not hasattr(adapter, "format_output")
|
||||
|
||||
|
||||
def test_tool_runner_files_are_removed():
|
||||
plugin_root = Path(__file__).resolve().parents[1]
|
||||
assert not (plugin_root / "tools" / "terminal_agent.py").exists()
|
||||
assert not (plugin_root / "helpers" / "runner.py").exists()
|
||||
assert not (
|
||||
plugin_root / "prompts" / "agent.system.tool.terminal_agent.md"
|
||||
).exists()
|
||||
|
||||
|
||||
def test_claude_defaults_skip_permissions():
|
||||
config_text = (Path(__file__).resolve().parents[1] / "default_config.yaml").read_text()
|
||||
assert "permission_mode: bypassPermissions" in config_text
|
||||
|
||||
|
||||
def test_grok_defaults_headless_automation():
|
||||
config_text = (Path(__file__).resolve().parents[1] / "default_config.yaml").read_text()
|
||||
assert "grok:" in config_text
|
||||
assert "output_format: json" in config_text
|
||||
assert "always_approve: true" in config_text
|
||||
assert "no_auto_update: true" in config_text
|
||||
|
||||
|
||||
def test_cursor_defaults_headless_automation():
|
||||
config_text = (Path(__file__).resolve().parents[1] / "default_config.yaml").read_text()
|
||||
assert "cursor:" in config_text
|
||||
assert "binary: agent" in config_text
|
||||
assert "output_format: text" in config_text
|
||||
assert "force: true" in config_text
|
||||
|
||||
|
||||
def test_cursor_detects_agent_zero_cursor_env_key():
|
||||
old_cursor = os.environ.pop("CURSOR_API_KEY", None)
|
||||
old_a0_cursor = os.environ.get("API_KEY_CURSOR")
|
||||
try:
|
||||
os.environ["API_KEY_CURSOR"] = "secret"
|
||||
assert CursorCliAdapter().auth_status()["auth_path"] == "API_KEY_CURSOR"
|
||||
finally:
|
||||
if old_cursor is not None:
|
||||
os.environ["CURSOR_API_KEY"] = old_cursor
|
||||
if old_a0_cursor is None:
|
||||
os.environ.pop("API_KEY_CURSOR", None)
|
||||
else:
|
||||
os.environ["API_KEY_CURSOR"] = old_a0_cursor
|
||||
|
||||
|
||||
def test_grok_env_key_requires_present_environment_value():
|
||||
old_value = os.environ.pop("GROK_TEST_KEY", None)
|
||||
try:
|
||||
assert not _toml_has_secret('env_key = "GROK_TEST_KEY"')
|
||||
os.environ["GROK_TEST_KEY"] = "secret"
|
||||
assert _toml_has_secret('env_key = "GROK_TEST_KEY"')
|
||||
finally:
|
||||
if old_value is None:
|
||||
os.environ.pop("GROK_TEST_KEY", None)
|
||||
else:
|
||||
os.environ["GROK_TEST_KEY"] = old_value
|
||||
|
||||
|
||||
def test_grok_detects_agent_zero_xai_env_key():
|
||||
old_xai = os.environ.pop("XAI_API_KEY", None)
|
||||
old_a0_xai = os.environ.get("API_KEY_XAI")
|
||||
try:
|
||||
os.environ["API_KEY_XAI"] = "secret"
|
||||
assert GrokBuildAdapter().auth_status()["auth_path"] == "API_KEY_XAI"
|
||||
finally:
|
||||
if old_xai is not None:
|
||||
os.environ["XAI_API_KEY"] = old_xai
|
||||
if old_a0_xai is None:
|
||||
os.environ.pop("API_KEY_XAI", None)
|
||||
else:
|
||||
os.environ["API_KEY_XAI"] = old_a0_xai
|
||||
|
||||
|
||||
def test_skill_documents_human_setup_loop_and_a0_exception():
|
||||
skill_root = Path(__file__).resolve().parents[1] / "skills" / "orchestrator"
|
||||
skill_text = (skill_root / "SKILL.md").read_text()
|
||||
references = skill_root / "references"
|
||||
a0_text = (references / "a0.md").read_text()
|
||||
codex_text = (references / "codex.md").read_text()
|
||||
claude_text = (references / "claude.md").read_text()
|
||||
cursor_text = (references / "cursor.md").read_text()
|
||||
grok_text = (references / "grok.md").read_text()
|
||||
hermes_text = (references / "hermes.md").read_text()
|
||||
opencode_text = (references / "opencode.md").read_text()
|
||||
|
||||
assert "setup is part of the workflow" in skill_text
|
||||
assert "code_execution_remote" in skill_text
|
||||
assert "memory_load" in skill_text
|
||||
assert "memory_save" in skill_text
|
||||
assert "own local <agent>" in skill_text
|
||||
assert "Agent Zero Docker/container shell" in skill_text
|
||||
assert "For orchestrator, the user prefers Claude Code" in skill_text
|
||||
assert "Never use Computer Use" in skill_text
|
||||
assert "ACP may be available as a community plugin" in skill_text
|
||||
assert "## Host CLI Flow" in skill_text
|
||||
assert "curl -LsSf https://cli.agent-zero.ai/install.sh | sh" in skill_text
|
||||
assert "irm https://cli.agent-zero.ai/install.ps1 | iex" in skill_text
|
||||
assert "open a terminal and run:" in skill_text
|
||||
assert "press F4 to allow Remote Code Execution" in skill_text
|
||||
assert "Press F3 too if the task needs host file writes" in skill_text
|
||||
assert "host-code-execution" in skill_text
|
||||
assert "not `/a0/usr/workdir`" in skill_text
|
||||
assert "## Container Pal Flow" in skill_text
|
||||
assert "Keep authentication human-in-the-loop" in skill_text
|
||||
assert "show those choices to the user in chat" in skill_text
|
||||
assert "do not run the bare CLI as a login fallback" in skill_text
|
||||
assert "read `references/a0.md`" in skill_text
|
||||
assert "target-selection flow" in skill_text
|
||||
assert "## Reference Files" in skill_text
|
||||
assert "references/a0.md" in skill_text
|
||||
assert "references/codex.md" in skill_text
|
||||
assert "references/claude.md" in skill_text
|
||||
assert "references/cursor.md" in skill_text
|
||||
assert "references/grok.md" in skill_text
|
||||
assert "references/hermes.md" in skill_text
|
||||
assert "references/opencode.md" in skill_text
|
||||
|
||||
assert "A0 is the setup exception" in a0_text
|
||||
assert "--new-chat" in a0_text
|
||||
assert "--chat \"$CONTEXT_ID\"" in a0_text
|
||||
|
||||
assert "CODEX_HOME" in codex_text
|
||||
assert "codex login" in codex_text
|
||||
assert "--dangerously-bypass-approvals-and-sandbox" in codex_text
|
||||
|
||||
assert "claude auth login" in claude_text
|
||||
assert "Do not start plain `claude`" in claude_text
|
||||
assert "Never start a full-screen CLI/TUI" in skill_text
|
||||
assert "Reset that terminal session" in claude_text
|
||||
assert "do not press Enter, do not send `/login`" in claude_text
|
||||
assert "Do not redirect them to a Docker shell just to choose a menu option" in claude_text
|
||||
assert "claude auth login --claudeai" in claude_text
|
||||
assert "claude auth login --console" in claude_text
|
||||
assert "claude auth login --sso" in claude_text
|
||||
assert "Settings > External Services > Secrets Management" in claude_text
|
||||
assert "/a0/usr/.env" in claude_text
|
||||
assert "ANTHROPIC_API_KEY" in claude_text
|
||||
assert "API_KEY_ANTHROPIC" in claude_text
|
||||
assert "Do not run bare `claude auth login`" in claude_text
|
||||
|
||||
assert "agent -p --output-format text" in cursor_text
|
||||
assert "agent -p --force --output-format text" in cursor_text
|
||||
assert "CURSOR_API_KEY" in cursor_text
|
||||
assert "API_KEY_CURSOR" in cursor_text
|
||||
assert "NO_OPEN_BROWSER=1 agent login" in cursor_text
|
||||
assert "agent status" in cursor_text
|
||||
assert "not with an invented flag" in cursor_text
|
||||
|
||||
assert "grok --no-auto-update --cwd \"$WORKDIR\" -p" in grok_text
|
||||
assert "--output-format json" in grok_text
|
||||
assert "--always-approve" in grok_text
|
||||
assert "XAI_API_KEY" in grok_text
|
||||
assert "API_KEY_XAI" in grok_text
|
||||
assert "/a0/usr/.env" in grok_text
|
||||
assert "grok login --device-auth" in grok_text
|
||||
assert "Do not run bare `grok`" in grok_text
|
||||
|
||||
assert "hermes setup --portal" in hermes_text
|
||||
assert "opencode auth login" in opencode_text
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_configured_absolute_binary_is_installed()
|
||||
test_registry_order_puts_a0_first()
|
||||
test_adapters_are_status_only()
|
||||
test_tool_runner_files_are_removed()
|
||||
test_claude_defaults_skip_permissions()
|
||||
test_cursor_defaults_headless_automation()
|
||||
test_cursor_detects_agent_zero_cursor_env_key()
|
||||
test_grok_defaults_headless_automation()
|
||||
test_grok_env_key_requires_present_environment_value()
|
||||
test_grok_detects_agent_zero_xai_env_key()
|
||||
test_skill_documents_human_setup_loop_and_a0_exception()
|
||||
BIN
plugins/_orchestrator/thumbnail.png
Normal file
BIN
plugins/_orchestrator/thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
44
plugins/_orchestrator/webui/AGENTS.md
Normal file
44
plugins/_orchestrator/webui/AGENTS.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Orchestrator WebUI - AGENTS.md
|
||||
|
||||
## Purpose
|
||||
|
||||
- Provide the Settings > External Services configuration screen for Orchestrator.
|
||||
- Show command defaults, detailed auth state, Codex device login, and safe disconnect actions.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owns `config.html` and `orchestrator-store.js`.
|
||||
- Does not own core settings components, global OAuth UI, or terminal execution.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Use the Agent Zero plugin settings modal pattern: local `config`, `$store.pluginSettingsPrototype`, and a gated Alpine store.
|
||||
- Register the Alpine store with `createStore("orchestratorStore", ...)`.
|
||||
- Use `callJsonApi()` for plugin API calls and A0 notification toasts for errors/info/success.
|
||||
- Refresh is a compact icon action consistent with Agent Zero settings UI.
|
||||
- Summary cards do not render connected/needs-login/not-installed pills; detailed auth state belongs inside the expanded card.
|
||||
- Only show `Connect` for adapters with `supports_device_login`; currently that is Codex.
|
||||
- Only show `Disconnect` when the API says `can_disconnect`.
|
||||
- Never render secret values. Auth text may show source type or credential path only when it is safe and already adapter-provided.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
- Keep each agent row readable before expansion: title, id, description, action buttons, expand icon.
|
||||
- Put detailed fields inside the expanded panel and prefer a simple two-column grid that collapses to one column on mobile.
|
||||
- If a new config field is added in the UI, add a default in `default_config.yaml` and align README/skill guidance when relevant.
|
||||
- Browser-test visual changes in the live settings modal when possible.
|
||||
|
||||
## Verification
|
||||
|
||||
- JavaScript syntax:
|
||||
```bash
|
||||
node --check plugins/_orchestrator/webui/orchestrator-store.js
|
||||
```
|
||||
- Status API smoke after WebUI/API coupling changes:
|
||||
```bash
|
||||
docker exec 8dc967046cda bash -lc 'cd /a0 && /opt/venv-a0/bin/python plugins/_orchestrator/tests/test_status_adapters.py'
|
||||
```
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
This folder has no child DOX files.
|
||||
504
plugins/_orchestrator/webui/config.html
Normal file
504
plugins/_orchestrator/webui/config.html
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Orchestrator</title>
|
||||
<script type="module" src="/plugins/_orchestrator/webui/orchestrator-store.js"></script>
|
||||
<style>
|
||||
.ta-settings {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.ta-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ta-refresh.btn-icon-action {
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
.ta-usage {
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
margin-top: 10px;
|
||||
max-width: 760px;
|
||||
opacity: 0.82;
|
||||
}
|
||||
.ta-agent {
|
||||
border: 1px solid var(--color-border, #333);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-background) 28%, transparent);
|
||||
}
|
||||
.ta-agent {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
.ta-panel {
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
summary.ta-agent-head {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
summary.ta-agent-head::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.ta-agent-head {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 14px;
|
||||
}
|
||||
.ta-agent-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ta-agent-title {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ta-agent-title strong {
|
||||
font-size: 0.94rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ta-agent-title code {
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
}
|
||||
.ta-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ta-action {
|
||||
align-items: center;
|
||||
background: #f5f7fa;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #111418;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ta-action.secondary {
|
||||
background: color-mix(in srgb, var(--color-border) 44%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ta-action.danger {
|
||||
background: color-mix(in srgb, #f06464 16%, var(--color-panel, transparent));
|
||||
border: 1px solid color-mix(in srgb, #f06464 40%, var(--color-border));
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ta-action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.ta-action .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.ta-form {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 16px 18px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.ta-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ta-field-toggle {
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.ta-field-toggle .field-control {
|
||||
justify-content: flex-end;
|
||||
width: auto;
|
||||
}
|
||||
.ta-field .field-label {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding-right: 0;
|
||||
}
|
||||
.ta-field .field-title {
|
||||
color: color-mix(in srgb, var(--color-text) 78%, var(--color-primary));
|
||||
font-size: 0.84rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.ta-field .field-description {
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
opacity: 0.78;
|
||||
}
|
||||
.ta-field .field-control {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.ta-readonly-value {
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
min-height: 36px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ta-field input:not([type="checkbox"]),
|
||||
.ta-field select {
|
||||
background: color-mix(in srgb, var(--color-background) 72%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
border-radius: 7px;
|
||||
color: var(--color-text);
|
||||
min-height: 36px;
|
||||
width: 100%;
|
||||
}
|
||||
.ta-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.ta-expand {
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
.ta-agent[open] .ta-expand {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ta-login {
|
||||
background: color-mix(in srgb, var(--color-border) 18%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.ta-login code {
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.ta-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.ta-header,
|
||||
.ta-agent-head {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.ta-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data>
|
||||
<template x-if="config && $store.orchestratorStore">
|
||||
<div
|
||||
class="ta-settings"
|
||||
x-init="$store.orchestratorStore.onOpen()"
|
||||
x-destroy="$store.orchestratorStore.cleanup()"
|
||||
>
|
||||
<div class="ta-header">
|
||||
<div>
|
||||
<div class="section-title">Orchestrator</div>
|
||||
<div class="section-description">
|
||||
Command defaults and advanced settings for the <code>orchestrator</code> skill.
|
||||
</div>
|
||||
<div class="ta-usage">
|
||||
Ask Agent Zero for a coding agent, or describe the session you want to start.
|
||||
For example: “Consult with Codex CLI on this heavy refactor before writing code.”
|
||||
Or: “Create a council with Claude Code and Codex using the models and effort levels I specify.”
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-icon-action ta-refresh"
|
||||
type="button"
|
||||
title="Refresh status"
|
||||
aria-label="Refresh status"
|
||||
:disabled="$store.orchestratorStore.loading"
|
||||
@click="$store.orchestratorStore.refresh()"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.orchestratorStore.loading ? 'progress_activity' : 'refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-for="agent in $store.orchestratorStore.agents" :key="agent.id">
|
||||
<details class="ta-agent" x-init="$store.orchestratorStore.ensureConfig(config, agent)">
|
||||
<summary class="ta-agent-head">
|
||||
<div class="ta-agent-copy">
|
||||
<div class="ta-agent-title">
|
||||
<strong x-text="agent.title"></strong>
|
||||
<code x-text="agent.id"></code>
|
||||
</div>
|
||||
<div class="section-description" x-text="agent.description"></div>
|
||||
</div>
|
||||
<div class="ta-actions">
|
||||
<button
|
||||
class="ta-action"
|
||||
type="button"
|
||||
x-show="agent.installed && !agent.auth?.connected && agent.supports_device_login && (!$store.orchestratorStore.login || $store.orchestratorStore.login.agentId !== agent.id)"
|
||||
@click.stop="$store.orchestratorStore.startLogin(agent.id)"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">login</span>
|
||||
<span>Connect</span>
|
||||
</button>
|
||||
<button
|
||||
class="ta-action danger"
|
||||
type="button"
|
||||
x-show="agent.auth?.connected && agent.can_disconnect"
|
||||
:disabled="$store.orchestratorStore.disconnectingAgent === agent.id"
|
||||
@click.stop="$store.orchestratorStore.disconnect(agent.id)"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.orchestratorStore.disconnectingAgent === agent.id ? 'progress_activity' : 'link_off'"></span>
|
||||
<span x-text="$store.orchestratorStore.disconnectingAgent === agent.id ? 'Disconnecting' : 'Disconnect'"></span>
|
||||
</button>
|
||||
<span class="material-symbols-outlined ta-expand" aria-hidden="true">expand_more</span>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="ta-panel">
|
||||
<div class="ta-form">
|
||||
<div class="ta-field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Binary</div>
|
||||
<div class="field-description">Command name or absolute path.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="text" x-model.trim="config[agent.id].binary" :placeholder="agent.id" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Auth</div>
|
||||
<div class="field-description">Detected connection source.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<div class="ta-readonly-value" x-text="$store.orchestratorStore.authText(agent)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field" x-show="agent.id === 'codex' || agent.id === 'claude' || agent.id === 'grok' || agent.id === 'hermes' || agent.id === 'opencode'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Model</div>
|
||||
<div class="field-description">Optional CLI model override.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="text" x-model.trim="config[agent.id].model" placeholder="default" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field" x-show="agent.id === 'hermes'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Provider</div>
|
||||
<div class="field-description">Optional Hermes provider override.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="text" x-model.trim="config[agent.id].provider" placeholder="openrouter" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field" x-show="agent.id === 'opencode'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Agent</div>
|
||||
<div class="field-description">Optional OpenCode agent name.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="text" x-model.trim="config[agent.id].agent" placeholder="build" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field ta-field-toggle" x-show="agent.id === 'codex'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Bypass sandbox</div>
|
||||
<div class="field-description">Needed in this Docker runtime.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config[agent.id].bypass_sandbox" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field ta-field-toggle" x-show="agent.id === 'hermes'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Skip approvals</div>
|
||||
<div class="field-description">Pass <code>--yolo</code> for non-interactive runs.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config[agent.id].yolo" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field ta-field-toggle" x-show="agent.id === 'cursor'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Force changes</div>
|
||||
<div class="field-description">Pass <code>--force</code> so scripts can modify files.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config[agent.id].force" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field ta-field-toggle" x-show="agent.id === 'grok'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Always approve</div>
|
||||
<div class="field-description">Pass <code>--always-approve</code> for headless runs.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config[agent.id].always_approve" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field ta-field-toggle" x-show="agent.id === 'grok'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">No auto update</div>
|
||||
<div class="field-description">Pass <code>--no-auto-update</code> in automation.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config[agent.id].no_auto_update" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field ta-field-toggle" x-show="agent.id === 'opencode'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Auto approve</div>
|
||||
<div class="field-description">Pass <code>--auto</code> for non-interactive runs.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config[agent.id].auto" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field" x-show="agent.id === 'a0'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Host</div>
|
||||
<div class="field-description">Empty uses the local Agent Zero runtime.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="text" x-model.trim="config[agent.id].host" placeholder="http://localhost:80" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field" x-show="agent.id === 'claude'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Permission mode</div>
|
||||
<div class="field-description">Claude Code permission preset.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<select x-model="config[agent.id].permission_mode">
|
||||
<option value="bypassPermissions">Skip permissions</option>
|
||||
<option value="acceptEdits">Accept edits</option>
|
||||
<option value="default">Default</option>
|
||||
<option value="plan">Plan</option>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="dontAsk">Do not ask</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field" x-show="agent.id === 'claude'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Allowed tools</div>
|
||||
<div class="field-description">Passed to <code>--allowedTools</code>.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="text" x-model.trim="config[agent.id].allowed_tools" placeholder="Bash,Read,Edit" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field ta-field-toggle" x-show="agent.id === 'claude'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Bare mode</div>
|
||||
<div class="field-description">Skip local Claude Code project discovery.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config[agent.id].bare" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ta-field" x-show="agent.id === 'hermes'">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Toolsets</div>
|
||||
<div class="field-description">Comma-separated Hermes toolsets.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="text" x-model.trim="config[agent.id].toolsets" placeholder="terminal,files" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<template x-if="$store.orchestratorStore.login && $store.orchestratorStore.login.agentId === agent.id">
|
||||
<div class="ta-login">
|
||||
<div>
|
||||
1. Open
|
||||
<a :href="$store.orchestratorStore.login.verificationUrl" target="_blank"
|
||||
x-text="$store.orchestratorStore.login.verificationUrl"></a>
|
||||
</div>
|
||||
<div>
|
||||
2. Enter code:
|
||||
<code x-text="$store.orchestratorStore.login.userCode"></code>
|
||||
</div>
|
||||
<div>
|
||||
Waiting for confirmation…
|
||||
<button class="ta-action secondary" type="button" @click="$store.orchestratorStore.cancelLogin()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<div x-show="!$store.orchestratorStore.loading && $store.orchestratorStore.agents.length === 0" style="opacity: 0.7;">
|
||||
No agents registered.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
166
plugins/_orchestrator/webui/orchestrator-store.js
Normal file
166
plugins/_orchestrator/webui/orchestrator-store.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import {
|
||||
toastFrontendError,
|
||||
toastFrontendInfo,
|
||||
toastFrontendSuccess,
|
||||
} from "/components/notifications/notification-store.js";
|
||||
|
||||
const STATUS_API = "/plugins/_orchestrator/status";
|
||||
const START_LOGIN_API = "/plugins/_orchestrator/start_device_login";
|
||||
const POLL_LOGIN_API = "/plugins/_orchestrator/poll_device_login";
|
||||
const DISCONNECT_API = "/plugins/_orchestrator/disconnect";
|
||||
const TOAST_TITLE = "Orchestrator";
|
||||
const MAX_POLL_MS = 15 * 60 * 1000;
|
||||
|
||||
export const store = createStore("orchestratorStore", {
|
||||
agents: [],
|
||||
loading: false,
|
||||
disconnectingAgent: "",
|
||||
login: null, // { agentId, userCode, verificationUrl }
|
||||
_pollTimer: null,
|
||||
_pollStartedAt: 0,
|
||||
|
||||
async onOpen() {
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
this._stopPolling();
|
||||
this.login = null;
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await callJsonApi(STATUS_API, {});
|
||||
this.agents = Array.isArray(data?.agents) ? data.agents : [];
|
||||
} catch (error) {
|
||||
toastFrontendError(`Failed to load status: ${error}`, TOAST_TITLE);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
ensureConfig(config, agent) {
|
||||
if (!config || !agent?.id) return;
|
||||
if (!config[agent.id]) config[agent.id] = {};
|
||||
const cfg = config[agent.id];
|
||||
if (!cfg.binary) cfg.binary = agent.id === "a0" ? "a0" : (agent.id === "cursor" ? "agent" : agent.id);
|
||||
if (agent.id === "codex" && cfg.bypass_sandbox === undefined) {
|
||||
cfg.bypass_sandbox = true;
|
||||
}
|
||||
if (agent.id === "claude") {
|
||||
if (!cfg.permission_mode) cfg.permission_mode = "bypassPermissions";
|
||||
if (!cfg.allowed_tools) cfg.allowed_tools = "Bash,Read,Edit";
|
||||
if (cfg.bare === undefined) cfg.bare = false;
|
||||
}
|
||||
if (agent.id === "cursor") {
|
||||
if (!cfg.binary) cfg.binary = "agent";
|
||||
if (!cfg.output_format) cfg.output_format = "text";
|
||||
if (cfg.force === undefined) cfg.force = true;
|
||||
}
|
||||
if (agent.id === "hermes" && cfg.yolo === undefined) {
|
||||
cfg.yolo = true;
|
||||
}
|
||||
if (agent.id === "grok") {
|
||||
if (!cfg.output_format) cfg.output_format = "json";
|
||||
if (cfg.always_approve === undefined) cfg.always_approve = true;
|
||||
if (cfg.no_auto_update === undefined) cfg.no_auto_update = true;
|
||||
}
|
||||
if (agent.id === "opencode" && cfg.auto === undefined) {
|
||||
cfg.auto = true;
|
||||
}
|
||||
},
|
||||
|
||||
authText(agent) {
|
||||
if (!agent.installed) return "Install and sign in with the CLI, then refresh.";
|
||||
if (!agent.auth?.connected) return agent.auth?.error || "Not authenticated";
|
||||
if (agent.auth.mode === "plugin") return "Plugin-owned login";
|
||||
if (agent.auth.mode === "env") return "Environment API key";
|
||||
return "External CLI login";
|
||||
},
|
||||
|
||||
async startLogin(agentId) {
|
||||
try {
|
||||
const data = await callJsonApi(START_LOGIN_API, { agent_id: agentId });
|
||||
if (!data?.ok) throw new Error(data?.error || "Unknown error");
|
||||
this.login = {
|
||||
agentId,
|
||||
userCode: data.user_code,
|
||||
verificationUrl: data.verification_url,
|
||||
deviceAuthId: data.device_auth_id,
|
||||
};
|
||||
toastFrontendInfo(
|
||||
"Enter the code on the verification page to connect.",
|
||||
TOAST_TITLE
|
||||
);
|
||||
this._startPolling(Math.max(3, Number(data.interval) || 5) * 1000);
|
||||
} catch (error) {
|
||||
toastFrontendError(`Could not start login: ${error}`, TOAST_TITLE);
|
||||
}
|
||||
},
|
||||
|
||||
_startPolling(intervalMs) {
|
||||
this._stopPolling();
|
||||
this._pollStartedAt = Date.now();
|
||||
this._pollTimer = setInterval(() => this._poll(), intervalMs);
|
||||
},
|
||||
|
||||
_stopPolling() {
|
||||
if (this._pollTimer) clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
},
|
||||
|
||||
async _poll() {
|
||||
if (!this.login) return this._stopPolling();
|
||||
if (Date.now() - this._pollStartedAt > MAX_POLL_MS) {
|
||||
this._stopPolling();
|
||||
this.login = null;
|
||||
toastFrontendError("Login timed out. Please try again.", TOAST_TITLE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await callJsonApi(POLL_LOGIN_API, {
|
||||
agent_id: this.login.agentId,
|
||||
device_auth_id: this.login.deviceAuthId,
|
||||
user_code: this.login.userCode,
|
||||
});
|
||||
if (!data?.ok) throw new Error(data?.error || "Unknown error");
|
||||
if (data.completed) {
|
||||
this._stopPolling();
|
||||
this.login = null;
|
||||
toastFrontendSuccess("Account connected successfully.", TOAST_TITLE);
|
||||
await this.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
this._stopPolling();
|
||||
this.login = null;
|
||||
toastFrontendError(`Login failed: ${error}`, TOAST_TITLE);
|
||||
}
|
||||
},
|
||||
|
||||
cancelLogin() {
|
||||
this._stopPolling();
|
||||
this.login = null;
|
||||
},
|
||||
|
||||
async disconnect(agentId) {
|
||||
if (this.disconnectingAgent) return;
|
||||
this.disconnectingAgent = agentId;
|
||||
try {
|
||||
const data = await callJsonApi(DISCONNECT_API, { agent_id: agentId });
|
||||
if (!data?.ok) throw new Error(data?.error || "Unknown error");
|
||||
if (data.removed) {
|
||||
toastFrontendSuccess("Disconnected.", TOAST_TITLE);
|
||||
} else {
|
||||
toastFrontendInfo(data.message || "No stored credentials removed.", TOAST_TITLE);
|
||||
}
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
toastFrontendError(`Disconnect failed: ${error}`, TOAST_TITLE);
|
||||
} finally {
|
||||
this.disconnectingAgent = "";
|
||||
}
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue