diff --git a/README.md b/README.md
index 9a88f34f0..45efd23d1 100644
--- a/README.md
+++ b/README.md
@@ -3,56 +3,58 @@
# Agent Zero
-### A full Linux system for your AI agent.
+### Give your agent a full Linux computer.
-Agent Zero is an open, dynamic, organic agentic framework. One Docker container ships a full Linux system with a desktop and a plugin hub that the agent can extend using Skills.
+Agent Zero is an open agent framework for work that needs more than chat: a Dockerized Linux desktop, a browser with DOM annotation, live document cowork, projects, skills, plugins, and a bridge back to your host machine.
[](https://agent-zero.ai)
[](./docs/)
[](https://discord.gg/B8KZKNsPpj)
[](https://github.com/sponsors/agent0ai)
-
-[Install](#how-to-install) |
-[Launcher](#a0-launcher) |
-[What's Different](#what-makes-agent-zero-different) |
-[A0 CLI](#a0-cli-connector-extend-onto-your-host-machine) |
-[Docs](#documentation)
-
[](https://deepwiki.com/agent0ai/agent-zero)
-[Ask ChatGPT](https://chatgpt.com/?q=Analyze%20this%3A%20https%3A%2F%2Fgithub.com%2Fagent0ai%2Fagent-zero) |
-[Ask Claude](https://claude.ai/new?q=Analyze%20this%3A%20https%3A%2F%2Fgithub.com%2Fagent0ai%2Fagent-zero)
+[Quick Start](#quick-start) |
+[Why Agent Zero](#why-agent-zero) |
+[Try These First](#try-these-first) |
+[Deep Dives](#deep-dives) |
+[Docs](#documentation)
-# How To Install
+# Why Agent Zero
-Choose the install path that matches your machine.
+| Feature | Why it matters |
+| --- | --- |
+| **Full Linux desktop** | The agent can use real GUI software, terminals, files, and desktop apps inside the Canvas. |
+| **Browser DOM annotation** | Click page elements and turn them into inspect, change, lift, or review instructions. |
+| **Live document cowork** | Edit Markdown, Writer, Spreadsheet, and Presentation files together instead of losing work in chat. |
+| **Plugin Hub** | Install 100+ community plugins or publish your own extension points. |
+| **Projects and memory** | Keep files, instructions, secrets, memories, repositories, and model presets isolated per project. |
+| **Host-machine bridge** | Connect with the A0 CLI so the same agent can work in your real local repositories. |
+| **Multi-agent cooperation** | Let agents delegate research, coding, analysis, or review tasks to focused subagents. |
+| **Transparent internals** | Prompts, tools, plugins, skills, and settings are inspectable and editable. |
-| Path | Best for | What it does |
-| --- | --- | --- |
-| **A0 Launcher** | Desktop users who want the guided path | Downloads Agent Zero, creates and manages local Instances, and helps set up the container runtime when needed. |
-| **A0 Install** | Terminals, SSH sessions, servers, and scripted setup | Installs Agent Zero from the command line, reuses an existing Docker-compatible runtime first, and can run headlessly. |
-| **Docker** | Machines that already have Docker ready | Runs the Agent Zero container directly. |
+# Quick Start
-## A0 Launcher
+## Recommended: A0 Launcher
-The desktop **A0 Launcher** is the recommended way to install Agent Zero on a personal machine. Download the Launcher, open it, and let it check your local runtime. If Docker is missing or stopped, the Launcher offers a setup path before it downloads Agent Zero. If you already host Agent Zero elsewhere, add it as a remote Instance and use the Launcher without local Docker setup.
+The desktop **A0 Launcher** is the fastest guided path on a personal machine. Download it, open it, and let it check Docker, create Instances, manage ports, and connect to local or remote Agent Zero installs.
-### Downloads
+Agent Zero runs wherever Docker runs, from a $6 VPS or Raspberry Pi to a local workstation or GPU server.
| Architecture | macOS | Linux | Windows |
| --- | --- | --- | --- |
-| x86 | [Mac Intel](https://github.com/agent0ai/a0-launcher/releases/download/v1.1/a0-launcher-1.1-macos-x64.dmg) | [Linux x86](https://github.com/agent0ai/a0-launcher/releases/download/v1.1/a0-launcher-1.1-linux-x64.AppImage) | [Windows x86](https://github.com/agent0ai/a0-launcher/releases/download/v1.1/a0-launcher-1.1-windows-x64.exe) |
-| ARM64 | [Mac Apple Silicon](https://github.com/agent0ai/a0-launcher/releases/download/v1.1/a0-launcher-1.1-macos-arm64.dmg) | [Linux ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v1.1/a0-launcher-1.1-linux-arm64.AppImage) | [Windows ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v1.1/a0-launcher-1.1-windows-arm64.exe) |
+| x86 | [Mac Intel](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-macos-x64.dmg) | [Linux x86](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-linux-x64.AppImage) | [Windows x86](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-windows-x64.exe) |
+| ARM64 | [Mac Apple Silicon](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-macos-arm64.dmg) | [Linux ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-linux-arm64.AppImage) | [Windows ARM64](https://github.com/agent0ai/a0-launcher/releases/download/v1.2/a0-launcher-1.2-windows-arm64.exe) |
-See the [A0 Launcher v1.1 release](https://github.com/agent0ai/a0-launcher/releases/tag/v1.1) for release notes and updater metadata. See the [Launcher guide](./docs/guides/launcher.md) for the first-run walkthrough.
+See the [A0 Launcher v1.2 release](https://github.com/agent0ai/a0-launcher/releases/tag/v1.2) for release notes and updater metadata. See the [Launcher guide](./docs/guides/launcher.md) for the first-run walkthrough.
+
+
+Other install paths
## A0 Install
@@ -92,13 +94,28 @@ docker run -p 80:80 -v a0_usr:/a0/usr agent0ai/agent-zero
Open the Web UI, configure your LLM provider, and start with a concrete task. For the full setup and onboarding experience, see the [Installation guide](./docs/setup/installation.md).
-# What Makes Agent Zero Different
+
+
+## Troubleshooting
+
+- **Docker is not running:** start Docker Desktop or your Docker service, then reopen the Launcher or rerun the install command.
+- **Port 80 is already in use:** use the Launcher to pick another port, or run Docker directly with `-p 5080:80` and open `http://localhost:5080`.
+- **Installing on a server:** use the A0 Install Quick Start command with `--quick-start --name agent-zero --port 5080`.
+- **Still blocked:** see the [Troubleshooting guide](./docs/guides/troubleshooting.md).
+
+# Try These First
+
+- **Annotate a design you like:** "Open this template site in the Browser. I'll annotate the hero section - re-implement it in my project's React + Tailwind stack."
+- **Cowork on a spreadsheet:** "Create an editable ODS budget model with assumptions and monthly projections."
+- **Drive a desktop app:** "Use the Linux Desktop to open Blender and create a simple 3D logo for me."
+- **Review a web UI:** "Open my local app in the Browser. I will annotate the page with comments; then implement the requested UI fixes."
+- **Create a specialist:** "Create an Agent Profile for financial analysis with cautious reasoning, clear assumptions, and spreadsheet-first deliverables."
+- **Recover a workspace:** "Show me recent Time Travel snapshots and explain what changed before I revert anything."
+
+# Deep Dives
## A Real Linux Desktop in the Canvas
-
-
-
Agent Zero opens its own Linux desktop inside the right-side Canvas. Not a remote VM, not a shared clipboard, but a real XFCE desktop session running in the container.
That means the agent can drive *real desktop software*: open Blender to model a 3D object, jump into a terminal window, manage files visually, run a GUI tool that has no API.
@@ -121,7 +138,7 @@ Annotate mode turns any webpage into an interactive directive surface. Click an
- **Lift it** - see a card, hero, or component on someone else's site that you like? Capture it and have the agent re-implement it in your own project's stack.
- **Comment it** - leave actionable notes pinned to elements during a UI review; the agent reads the comments and ships the fixes.
-The Docker browser is the default live Browser surface. Browser history keeps screenshots of important steps, so older chats can still show what the agent saw. The Browser also supports Chrome extensions inside the Docker browser, and **Bring Your Own Browser** through the A0 CLI Connector lets the agent drive Chrome/Edge/Chromium on your own machine.
+The Docker browser is the default live Browser surface. Browser history keeps screenshots of important steps, so older chats can still show what the agent saw. The Browser also supports Chrome extensions inside the Docker browser, and **Bring Your Own Browser** through the A0 CLI Connector lets the agent drive Chrome, Edge, Brave, Opera, Vivaldi, or Chromium on your own machine.
See the [Browser guide](./docs/guides/browser.md) for screenshots, settings, host-browser setup, and troubleshooting.
@@ -226,31 +243,9 @@ Almost nothing is hidden. Prompts live in `prompts/`, tools live in `tools/` or
Agent Zero supports plugins, MCP, A2A, custom tools, custom prompts, project-scoped configuration, environment-based deployment settings, and a Web UI designed to keep the agent's work readable in real time.
-## Try These First
+## Time Travel
-- **Annotate a design you like:** "Open this template site in the Browser. I'll annotate the hero section - re-implement it in my project's React + Tailwind stack."
-- **Cowork on a spreadsheet:** "Create an editable ODS budget model with assumptions and monthly projections."
-- **Drive a desktop app:** "Use the Linux Desktop to open Blender and create a simple 3D logo for me."
-- **Review a web UI:** "Open my local app in the Browser. I will annotate the page with comments; then implement the requested UI fixes."
-- **Create a specialist:** "Create an Agent Profile for financial analysis with cautious reasoning, clear assumptions, and spreadsheet-first deliverables."
-- **Recover a workspace:** "Show me recent Time Travel snapshots and explain what changed before I revert anything."
-
-## Agent Zero and Space Agent
-
-Agent Zero is the open framework and Linux-powered agent workbench.
-
-[Space Agent](https://github.com/agent0ai/space-agent) is our newer product direction for the agent-shaped workspace: a Space the agent can reshape from inside your browser, with live demos, a desktop app, and a path to running a real server for yourself or your team.
-
-
-
-
-
-If you want the raw power and deep customizability of an agent with a full Linux system, start here with Agent Zero. If you want the polished Space experience for easier personal, team, desktop, or self-hosted use, explore [Space Agent](https://github.com/agent0ai/space-agent).
-
-
-## Time Travel (powered by Space Agent)
-
-Time Travel gives Agent Zero-owned `/a0/usr` workspaces snapshot history, diff inspection, travel, and revert. It is designed for recoverable agent work: see what changed, compare files, inspect a past state, and roll back when needed. Try it in Space Agent as well (link above).
+Time Travel gives Agent Zero-owned `/a0/usr` workspaces snapshot history, diff inspection, travel, and revert. It is designed for recoverable agent work: see what changed, compare files, inspect a past state, and roll back when needed.
@@ -268,17 +263,6 @@ It is not a replacement for Git or backups. It is a practical safety layer for t
- **Client/project isolation:** keep memory, secrets, instructions, files, and model choices separated by project.
- **Scheduled operations:** run recurring checks and monitoring tasks with project-scoped context and credentials.
-## Safety Model
-
-Agent Zero is powerful because it can use a real environment.
-
-- Keep it running inside Docker or another isolated environment.
-- Do not mount your entire home directory unless you understand the risk.
-- Grant A0 CLI Read+Write access and remote code execution only for machines and workspaces you trust.
-- Store credentials in project secrets or settings, not in prompts or public files.
-- Review actions that touch accounts, money, production systems, or private data.
-- Keep backups for important workspaces.
-
## Documentation
| I want to... | Start here |
@@ -314,3 +298,16 @@ You can help by improving docs, creating skills, publishing plugins, testing mod
- [YouTube](https://www.youtube.com/@AgentZeroFW) for demos and tutorials.
- [X](https://x.com/Agent0ai), [LinkedIn](https://www.linkedin.com/company/109758317), and [Warpcast](https://warpcast.com/agent-zero) for updates.
- [GitHub Issues](https://github.com/agent0ai/agent-zero/issues) for bugs and feature requests.
+
+[Space Agent](https://github.com/agent0ai/space-agent) is the related, more polished product direction for the agent-shaped workspace. Agent Zero remains the open framework and Linux-powered workbench.
+
+## Safety Model
+
+Agent Zero is powerful because it can use a real environment.
+
+- Keep it running inside Docker or another isolated environment.
+- Do not mount your entire home directory unless you understand the risk.
+- Grant A0 CLI Read+Write access and remote code execution only for machines and workspaces you trust.
+- Store credentials in project secrets or settings, not in prompts or public files.
+- Review actions that touch accounts, money, production systems, or private data.
+- Keep backups for important workspaces.
diff --git a/conf/model_providers.yaml b/conf/model_providers.yaml
index 541a4888b..58bb2e7cc 100644
--- a/conf/model_providers.yaml
+++ b/conf/model_providers.yaml
@@ -84,6 +84,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:1234"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:1234/v1"
api_key: "lm-studio"
llama_cpp:
@@ -93,6 +94,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:8080"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:8080/v1"
api_key: "llama-cpp"
mistral:
@@ -120,6 +122,7 @@ chat:
format: "ollama"
default_base: "http://host.docker.internal:11434"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:11434"
omlx:
name: oMLX
@@ -128,6 +131,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:8000"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:8000/v1"
api_key: "omlx"
ollama_cloud:
@@ -136,6 +140,7 @@ chat:
models_list:
endpoint_url: "/models"
kwargs:
+ a0_api_mode: chat
api_base: https://ollama.com/v1
openai:
name: OpenAI
@@ -173,6 +178,7 @@ chat:
models_list:
endpoint_url: "https://api.venice.ai/api/v1/models"
kwargs:
+ a0_api_mode: chat
api_base: https://api.venice.ai/api/v1
venice_parameters:
include_venice_system_prompt: false
@@ -183,6 +189,7 @@ chat:
endpoint_url: "/v1/models"
default_base: "http://host.docker.internal:8000"
kwargs:
+ a0_api_mode: chat
api_base: "http://host.docker.internal:8000/v1"
api_key: "vllm"
xai:
@@ -207,6 +214,8 @@ chat:
other:
name: Other OpenAI compatible
litellm_provider: openai
+ kwargs:
+ a0_api_mode: chat
embedding:
huggingface:
diff --git a/docker/run/AGENTS.md b/docker/run/AGENTS.md
index 9077e5a90..b5607ae69 100644
--- a/docker/run/AGENTS.md
+++ b/docker/run/AGENTS.md
@@ -22,6 +22,7 @@
- Do not bake secrets, local `.env` values, or user data into the image.
- Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start.
- Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit.
+- Self-update user-data backups skip Time Travel shadow history under `usr/.time_travel/` and transient Desktop agent state.
## Work Guidance
diff --git a/docker/run/fs/exe/self_update_manager.py b/docker/run/fs/exe/self_update_manager.py
index abf4e595f..4ae23ce28 100644
--- a/docker/run/fs/exe/self_update_manager.py
+++ b/docker/run/fs/exe/self_update_manager.py
@@ -405,6 +405,11 @@ def should_exclude_from_usr_backup(
logger: AttemptLogger,
) -> bool:
parts = relative_dir.parts
+ if parts and parts[0] == ".time_travel":
+ logger.log(
+ f"Skipping Time Travel history during usr backup: {Path('usr') / relative_dir}"
+ )
+ return True
if (
len(parts) >= 6
and parts[0] == "plugins"
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 3f3002b01..c81fb55bd 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -9,6 +9,7 @@
- `README.md`, `quickstart.md`, `guides/`, and `setup/` cover user-facing setup and workflows.
- `developer/` covers compact developer references and source handoffs.
+- `plans/` covers implementation plans, migration notes, and staged technical roadmaps.
- `res/` contains documentation images and other documentation assets.
## Local Contracts
diff --git a/docs/guides/a0-cli-connector.md b/docs/guides/a0-cli-connector.md
index 6dd4c2edc..f0812efc8 100644
--- a/docs/guides/a0-cli-connector.md
+++ b/docs/guides/a0-cli-connector.md
@@ -132,15 +132,22 @@ machine.
- [ ] Keep A0 CLI connected to the Agent Zero chat.
- [ ] In Agent Zero Web UI, open Browser plugin settings and choose **Bring Your
Own Browser**.
-- [ ] If you want Agent Zero to use an already-open personal Chrome window, open
- that browser first.
-- [ ] In that browser, go to `chrome://inspect/#remote-debugging`.
+- [ ] If you want Agent Zero to use an already-open personal browser window,
+ open that browser first.
+- [ ] In that browser, open its remote debugging page.
- [ ] Enable **Allow remote debugging for this browser instance**.
+Remote debugging pages:
+
+| Browser | Page |
+| --- | --- |
+| Chrome, Edge, Brave, Vivaldi, Chromium | `chrome://inspect/#remote-debugging` |
+| Opera | `opera://inspect/#remote-debugging` |
+

When Agent Zero performs its first Browser action against that host browser,
-Chrome asks for confirmation. Click **Allow** if you trust this Agent Zero
+the browser asks for confirmation. Click **Allow** if you trust this Agent Zero
instance and A0 CLI connection.

@@ -149,11 +156,30 @@ A0 CLI does not take over the browser while it is only checking status. Browser
control starts when Agent Zero actually needs to use the browser.
> [!IMPORTANT]
-> Remote debugging gives the connected app full control of that Chrome session,
+> Remote debugging gives the connected app full control of that browser session,
> including access to saved data, cookies, site data, and navigation. Use it only
> with trusted Agent Zero instances and browser windows you intend the agent to
> control.
+The **Host browser** list in Browser settings comes from the connected local A0
+CLI, not from the Agent Zero Web UI server. It shows Automatic, currently
+advertised debug endpoints, and **Custom endpoint**. If a newly authorized
+browser does not appear, restart or reconnect A0 CLI.
+
+If the inspect checkbox is not enough for your browser build, launch it with an
+explicit remote debugging port and a separate profile:
+
+```bash
+opera --remote-debugging-port=9222 --user-data-dir="$HOME/.config/a0-opera-debug"
+```
+
+Then choose **Custom endpoint** in Browser settings, run `/browser ws://...` in
+A0 CLI, or pass the full DevTools websocket endpoint to A0 CLI:
+
+```bash
+export A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS="ws://127.0.0.1:9222/devtools/browser/..."
+```
+
### Browser Profiles
```bash
diff --git a/docs/guides/browser.md b/docs/guides/browser.md
index ed406327a..33d1b719f 100644
--- a/docs/guides/browser.md
+++ b/docs/guides/browser.md
@@ -137,8 +137,8 @@ development, Agent Zero can install it the first time it is needed.
## Bring Your Own Browser
-Bring Your Own Browser lets Agent Zero use Chrome, Edge, or Chromium on your own
-computer through A0 CLI.
+Bring Your Own Browser lets Agent Zero use Chrome, Edge, Brave, Opera, Vivaldi,
+or Chromium on your own computer through A0 CLI.
Use it when the page, login, or browser profile should stay on your machine.
@@ -146,8 +146,35 @@ Requirements:
- [ ] Keep A0 CLI connected to the Agent Zero chat.
- [ ] Choose **Bring Your Own Browser** in Browser settings.
-- [ ] Use Chrome, Edge, or Chromium on the host.
-- [ ] For personal Chrome remote debugging, open the host browser first, go to `chrome://inspect/#remote-debugging`, and enable **Allow remote debugging for this browser instance**.
+- [ ] Use a Chromium-family browser on the host: Chrome, Edge, Brave, Opera, Vivaldi, or Chromium.
+- [ ] For an already-open browser, open its remote debugging page and enable **Allow remote debugging for this browser instance**.
+
+Remote debugging pages:
+
+| Browser | Page |
+| --- | --- |
+| Chrome, Edge, Brave, Vivaldi, Chromium | `chrome://inspect/#remote-debugging` |
+| Opera | `opera://inspect/#remote-debugging` |
+
+The **Host browser** list shows Automatic, currently advertised debug endpoints,
+and **Custom endpoint**. If a browser does not appear after enabling remote
+debugging, restart or reconnect the local A0 CLI. Restarting only the Agent Zero
+Web UI server does not refresh the browser inventory; the list comes from the
+connected CLI.
+
+As a fallback, launch the browser with an explicit debugging port and profile
+directory:
+
+```bash
+opera --remote-debugging-port=9222 --user-data-dir="$HOME/.config/a0-opera-debug"
+```
+
+Then choose **Custom endpoint** in Browser settings, or pass the full DevTools
+websocket endpoint to the CLI:
+
+```bash
+export A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS="ws://127.0.0.1:9222/devtools/browser/..."
+```

diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md
index e41a7141a..21f8e979e 100644
--- a/docs/guides/troubleshooting.md
+++ b/docs/guides/troubleshooting.md
@@ -41,6 +41,7 @@ PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium
If **Bring Your Own Browser** mode fails:
- keep A0 CLI connected to the chat;
+- restart or reconnect A0 CLI after enabling remote debugging in a browser;
- run `/browser status` in A0 CLI;
- check that Browser settings still say **Bring Your Own Browser**;
- check **Page content access** if page text or screenshots are blocked.
diff --git a/extensions/python/message_loop_end/AGENTS.md b/extensions/python/message_loop_end/AGENTS.md
index 8927890a9..2200c4aa3 100644
--- a/extensions/python/message_loop_end/AGENTS.md
+++ b/extensions/python/message_loop_end/AGENTS.md
@@ -12,6 +12,7 @@
- Preserve history consistency before saving chats.
- Do not skip persistence for successful loops unless the hook contract explicitly permits it.
+- History compression that rewrites local history must clear the active Responses provider continuation while preserving stored response IDs for cleanup.
## Work Guidance
diff --git a/extensions/python/message_loop_end/_10_organize_history.py b/extensions/python/message_loop_end/_10_organize_history.py
index d91c9ef80..0cc205bd6 100644
--- a/extensions/python/message_loop_end/_10_organize_history.py
+++ b/extensions/python/message_loop_end/_10_organize_history.py
@@ -1,10 +1,18 @@
from helpers.extension import Extension
from agent import LoopData
from helpers.defer import DeferredTask, THREAD_BACKGROUND
+from helpers.history import clear_responses_provider_state
DATA_NAME_TASK = "_organize_history_task"
+async def compress_history(agent) -> bool:
+ compressed = bool(await agent.history.compress())
+ if compressed:
+ clear_responses_provider_state(agent)
+ return compressed
+
+
class OrganizeHistory(Extension):
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
if not self.agent:
@@ -17,6 +25,6 @@ class OrganizeHistory(Extension):
# start task
task = DeferredTask(thread_name=THREAD_BACKGROUND)
- task.start_task(self.agent.history.compress)
+ task.start_task(compress_history, self.agent)
# set to agent to be able to wait for it
self.agent.set_data(DATA_NAME_TASK, task)
diff --git a/extensions/python/message_loop_prompts_after/AGENTS.md b/extensions/python/message_loop_prompts_after/AGENTS.md
index ba8118a2f..d18b1c5a1 100644
--- a/extensions/python/message_loop_prompts_after/AGENTS.md
+++ b/extensions/python/message_loop_prompts_after/AGENTS.md
@@ -2,20 +2,21 @@
## Purpose
-- Own prompt protocol and extras appended around primary message-loop prompt construction.
+- Own prompt protocol, prompt extras, and history reattachment around primary message-loop prompt construction.
## Ownership
-- Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
-- Active skill instructions belong in prompt protocol.
+- Ordered Python files own current datetime, relevant-skill hints, loaded-skill history reattachment, agent info, parallel job status, and workdir extras injection.
- Explicitly loaded skill bodies belong in tool-result history with metadata so they can survive persistence and be reattached after compaction.
- Explicitly loaded skill IDs are chat-wide context data, not agent-local state.
+- Skills must not write selected or loaded skill bodies into protocol or extras.
## Local Contracts
- Keep injected content bounded and clearly attributed.
- Preserve ordering where later prompt extras depend on earlier recall or load results.
- Do not expose secrets or private files from workdir extras.
+- Relevant-skill recall should search the raw user message when available, not the rendered history wrapper.
## Work Guidance
diff --git a/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py b/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
index c4b2a45e8..a810cece4 100644
--- a/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
+++ b/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
@@ -8,9 +8,13 @@ class RecallRelevantSkills(Extension):
if not self.agent or loop_data.iteration != 0:
return
- user_instruction = (
- loop_data.user_message.output_text() if loop_data.user_message else ""
- ).strip()
+ content = loop_data.user_message.content if loop_data.user_message else ""
+ if isinstance(content, dict):
+ user_instruction = str(content.get("user_message") or "").strip()
+ else:
+ user_instruction = (
+ loop_data.user_message.output_text() if loop_data.user_message else ""
+ ).strip()
if len(user_instruction) < 8:
return
diff --git a/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py b/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
index d7effd627..6c604005a 100644
--- a/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
+++ b/extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
@@ -14,9 +14,6 @@ class IncludeLoadedSkills(Extension):
if not self.agent:
return
- loop_data.protocol_persistent.pop("loaded_skills", None)
- loop_data.extras_persistent.pop("loaded_skills", None)
-
skill_names = skills.get_loaded_skill_names(self.agent)
if not skill_names:
return
diff --git a/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py b/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py
index b2c925fe8..08966d2b1 100644
--- a/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py
+++ b/extensions/python/message_loop_prompts_before/_90_organize_history_wait.py
@@ -1,6 +1,9 @@
from helpers.extension import Extension
from agent import LoopData
-from extensions.python.message_loop_end._10_organize_history import DATA_NAME_TASK
+from extensions.python.message_loop_end._10_organize_history import (
+ DATA_NAME_TASK,
+ compress_history,
+)
from helpers.defer import DeferredTask, THREAD_BACKGROUND
MAX_SYNC_COMPRESSION_PASSES = 64
@@ -33,7 +36,7 @@ class OrganizeHistoryWait(Extension):
else:
# no task was running, start and wait
self.agent.context.log.set_progress("Compressing history...")
- compressed = await self.agent.history.compress()
+ compressed = await compress_history(self.agent)
after_tokens = self.agent.history.get_tokens()
if not compressed or after_tokens >= before_tokens:
diff --git a/extensions/python/system_prompt/AGENTS.md b/extensions/python/system_prompt/AGENTS.md
index 34b90f78c..c4aa1bfd8 100644
--- a/extensions/python/system_prompt/AGENTS.md
+++ b/extensions/python/system_prompt/AGENTS.md
@@ -7,7 +7,7 @@
## Ownership
- Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections.
-- Active project instruction bodies are moved into prompt protocol; the system prompt keeps project metadata and stable project rules.
+- Active project instruction bodies and active-project AGENTS.md path-chain guidance are moved into prompt protocol; the system prompt keeps project metadata and stable project rules.
## Local Contracts
diff --git a/extensions/python/system_prompt/_14_project_prompt.py b/extensions/python/system_prompt/_14_project_prompt.py
index cd0363b4c..4afa620cd 100644
--- a/extensions/python/system_prompt/_14_project_prompt.py
+++ b/extensions/python/system_prompt/_14_project_prompt.py
@@ -25,9 +25,16 @@ async def build_prompt(agent: Agent, loop_data: LoopData | None = None) -> str:
result = agent.read_prompt("agent.system.projects.main.md")
project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
if loop_data:
+ loop_data.protocol_persistent.pop("agents_md_instructions", None)
loop_data.protocol_persistent.pop("project_instructions", None)
if project_name:
project_vars = projects.build_system_prompt_vars(project_name)
+ if loop_data and project_vars.get("include_agents_md", True):
+ agents_md_protocol = projects.build_agents_md_protocol(project_name)
+ if agents_md_protocol:
+ loop_data.protocol_persistent["agents_md_instructions"] = (
+ agents_md_protocol
+ )
if loop_data and project_vars.get("project_instructions"):
loop_data.protocol_persistent["project_instructions"] = agent.read_prompt(
"agent.protocol.projects.instructions.md",
diff --git a/helpers/AGENTS.md b/helpers/AGENTS.md
index dc7584d32..39d24d71e 100644
--- a/helpers/AGENTS.md
+++ b/helpers/AGENTS.md
@@ -15,7 +15,7 @@
- Preserve public helper APIs used by core code and plugins unless all callers, docs, and tests are updated.
- Use structured parsers and serializers for YAML, JSON, paths, and URLs instead of ad hoc string handling.
- Keep path handling constrained to intended roots for user files, uploads, downloads, projects, and workdirs.
-- Project metadata defaults must remain backwards-compatible; missing `include_agents_md` is treated as enabled and project instruction file content is injected with an explicit source path.
+- Project metadata defaults must remain backwards-compatible; missing `include_agents_md` is treated as enabled, project instruction file content is injected with an explicit source path, and active-project AGENTS.md path-chain guidance is assembled into prompt protocol without duplicating the project root AGENTS.md.
- Do not hardcode secrets, provider keys, local absolute paths, or environment-specific values.
- Use `RepairableException` for errors an agent may be able to fix.
- This directory is a file-documented DOX profile: every direct `*.py` helper module must have a same-directory `*.py.dox.md` file named by appending `.dox.md` to the full Python filename.
diff --git a/helpers/backup.py b/helpers/backup.py
index 47e41c5f8..70ad7b082 100644
--- a/helpers/backup.py
+++ b/helpers/backup.py
@@ -63,6 +63,7 @@ class BackupService:
return f"""# User data
# All persistent user data is now centralized in /usr for easier backup and restore
{agent_root}/usr/**
+!{agent_root}/usr/.time_travel/**
"""
def _get_agent_zero_version(self) -> str:
diff --git a/helpers/backup.py.dox.md b/helpers/backup.py.dox.md
index e08f7a74e..5612447ec 100644
--- a/helpers/backup.py.dox.md
+++ b/helpers/backup.py.dox.md
@@ -26,6 +26,7 @@
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, settings/state persistence, secret handling.
- Imported dependency areas include: `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `json`, `os`, `pathspec`, `platform`, `tempfile`, `typing`, `zipfile`.
- `test_patterns(..., max_files=None)` is the unlimited scan mode. UI preview and dry-run callers may pass bounded limits, but real backup creation and restore clean-before-restore must use unlimited matching so archives and cleanup are not silently truncated.
+- Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`.
## Key Concepts
diff --git a/helpers/history.py b/helpers/history.py
index c579c501d..1ff802d87 100644
--- a/helpers/history.py
+++ b/helpers/history.py
@@ -723,6 +723,28 @@ def output_text(messages: list[OutputMessage], ai_label="ai", human_label="human
return "\n".join(_stringify_output(o, ai_label, human_label) for o in messages)
+def clear_responses_provider_state(agent) -> None:
+ key = getattr(agent, "DATA_NAME_RESPONSES_STATE", "responses_state")
+ get_data = getattr(agent, "get_data", None)
+ set_data = getattr(agent, "set_data", None)
+ if not callable(get_data) or not callable(set_data):
+ return
+
+ state = get_data(key)
+ if not isinstance(state, dict):
+ return
+
+ state = dict(state)
+ removed = False
+ for field in ("response_id", "previous_response_id"):
+ if field in state:
+ state.pop(field, None)
+ removed = True
+
+ if removed:
+ set_data(key, state)
+
+
def _merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent:
if isinstance(a, str) and isinstance(b, str):
return a + "\n" + b
diff --git a/helpers/history.py.dox.md b/helpers/history.py.dox.md
index 0e07d4a9e..53a7149e0 100644
--- a/helpers/history.py.dox.md
+++ b/helpers/history.py.dox.md
@@ -65,6 +65,7 @@
- `group_messages_abab(messages: list[BaseMessage]) -> list[BaseMessage]`
- `output_langchain(messages: list[OutputMessage])`
- `output_text(messages: list[OutputMessage], ai_label=..., human_label=...)`
+- `clear_responses_provider_state(agent) -> None`
- `_merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent`
- `_merge_properties(a: Dict[str, MessageContent], b: Dict[str, MessageContent]) -> Dict[str, MessageContent]`
- `_is_raw_message(obj: object) -> bool`
@@ -77,6 +78,7 @@
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
+- `clear_responses_provider_state(agent)` removes the active provider continuation IDs after local history rewrites while preserving stored response ID lists for later cleanup.
- Observed side-effect areas: filesystem writes, filesystem deletion, model calls, plugin state, settings/state persistence, secret handling.
- Imported dependency areas include: `abc`, `asyncio`, `collections`, `collections.abc`, `enum`, `helpers`, `json`, `langchain_core.messages`, `math`, `plugins._model_config.helpers.model_config`, `typing`, `uuid`.
diff --git a/helpers/mcp_handler.py b/helpers/mcp_handler.py
index b1f888ec1..e30c3916c 100644
--- a/helpers/mcp_handler.py
+++ b/helpers/mcp_handler.py
@@ -21,6 +21,7 @@ from contextlib import AsyncExitStack
from shutil import which
from datetime import timedelta
import json
+import shlex
import uuid
from helpers import errors
from helpers import settings
@@ -112,6 +113,44 @@ def _normalize_disabled_tools(value: Any) -> list[str]:
return [str(item).strip() for item in value if str(item).strip()]
+def _split_stdio_command(command: Any) -> tuple[str, list[str]]:
+ text = str(command or "").strip()
+ if not text:
+ return "", []
+ try:
+ parts = shlex.split(text)
+ except ValueError:
+ return text, []
+ if not parts:
+ return "", []
+ return parts[0], parts[1:]
+
+
+def _split_stdio_arg_fragment(arg: str) -> list[str]:
+ try:
+ parts = shlex.split(arg)
+ except ValueError:
+ return [arg]
+ if len(parts) <= 1:
+ return parts or []
+ if parts[0].startswith("-") and "=" not in parts[0]:
+ return parts
+ if any(part.startswith("-") for part in parts[1:]):
+ return parts
+ return [arg]
+
+
+def _normalize_stdio_args(value: Any) -> list[str]:
+ if not isinstance(value, list):
+ return []
+ args: list[str] = []
+ for item in value:
+ text = str(item).strip()
+ if text:
+ args.extend(_split_stdio_arg_fragment(text))
+ return args
+
+
def initialize_mcp(mcp_servers_config: str):
if not MCPConfig.get_instance().is_initialized():
try:
@@ -649,6 +688,15 @@ class MCPServerLocal(BaseModel):
def update(self, config: dict[str, Any]) -> "MCPServerLocal":
with self.__lock:
+ command = self.command
+ command_args: list[str] = []
+ args = list(self.args)
+ if "command" in config:
+ command, command_args = _split_stdio_command(config.get("command"))
+ args = [*command_args, *args]
+ if "args" in config:
+ args = [*command_args, *_normalize_stdio_args(config.get("args"))]
+
for key, value in config.items():
if key in [
"name",
@@ -665,11 +713,15 @@ class MCPServerLocal(BaseModel):
"disabled_tools",
"scope",
]:
+ if key in ["command", "args"]:
+ continue
if key == "name":
value = normalize_name(value)
if key == "disabled_tools":
value = _normalize_disabled_tools(value)
setattr(self, key, value)
+ self.command = command
+ self.args = args
return self
async def initialize(self) -> "MCPServerLocal":
diff --git a/helpers/mcp_handler.py.dox.md b/helpers/mcp_handler.py.dox.md
index 4faff9fe1..1cab10612 100644
--- a/helpers/mcp_handler.py.dox.md
+++ b/helpers/mcp_handler.py.dox.md
@@ -66,6 +66,9 @@
- `_is_streaming_http_type(server_type: str) -> bool`: Check if the server type is a streaming HTTP variant.
- `_split_qualified_tool_name(tool_name: str) -> tuple[str, str]`: Split `server.tool` names while preserving dots inside MCP tool names.
- `_normalize_disabled_tools(value: Any) -> list[str]`: Normalize the optional per-server disabled tool list.
+- `_split_stdio_command(command: Any) -> tuple[str, list[str]]`: Split shell-style local MCP command lines into an executable plus leading arguments.
+- `_split_stdio_arg_fragment(arg: str) -> list[str]`: Split collapsed option/value argument fragments while preserving obvious single values with spaces.
+- `_normalize_stdio_args(value: Any) -> list[str]`: Normalize local MCP argument lists after manager/raw JSON parsing.
- `initialize_mcp(mcp_servers_config: str)`
- Notable constants/configuration names: `DEFAULT_MCP_SERVERS_CONFIG`, `MCP_MEDIA_TOKENS_ESTIMATE`, `MAX_MCP_RESOURCE_TEXT_CHARS`, `MCP_SESSION_CLEANUP_TIMEOUT_SECONDS`, `MCP_OPERATION_TIMEOUT_GRACE_SECONDS`, `T`.
@@ -81,12 +84,13 @@
- MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
- Servers may define `disabled_tools` as a list of MCP tool names. Disabled tools are omitted from agent-facing prompts, status counts, `has_tool`, and calls, while detail views can still retrieve them through `get_all_tools()` with a `disabled` flag so users can re-enable them.
- Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
+- Local stdio server configs accept either strict MCP JSON (`command: "uvx", args: [...]`) or manager-style command lines (`command: "uvx package"`) and normalize them before spawning the process.
- MCP image and image-resource content is materialized to scoped artifact files and returned both as model-visible image attachments and as path metadata (`attachments`/`media_paths`) for downstream delivery.
- MCP config locks must not be held across awaited server initialization or tool-call operations. Slow or wedged MCP servers must not block status reads, prompt construction, unrelated MCP servers, or later tool calls through the shared config lock.
- MCP client session work runs inside disposable isolated `DeferredTask` workers with an outer timeout. Normal `AsyncExitStack` cleanup is also bounded; if cleanup or transport shutdown does not finish, the operation reports failure or warning while Agent Zero keeps control of the agent loop.
- Server status marks initialized server objects with cached initialization errors as disconnected, even if the config object exists.
- Observed side-effect areas: filesystem writes, network calls, WebSocket state, settings/state persistence, secret handling.
-- Imported dependency areas include: `abc`, `anyio.streams.memory`, `asyncio`, `contextlib`, `datetime`, `helpers`, `helpers.defer`, `helpers.log`, `helpers.print_style`, `helpers.tool`, `httpx`, `json`, `mcp`, `mcp.client.sse`, `mcp.client.stdio`, `mcp.client.streamable_http`, `mcp.shared.message`.
+- Imported dependency areas include: `abc`, `anyio.streams.memory`, `asyncio`, `contextlib`, `datetime`, `helpers`, `helpers.defer`, `helpers.log`, `helpers.print_style`, `helpers.tool`, `httpx`, `json`, `mcp`, `mcp.client.sse`, `mcp.client.stdio`, `mcp.client.streamable_http`, `mcp.shared.message`, `shlex`.
## Key Concepts
diff --git a/helpers/parallel_tools.py b/helpers/parallel_tools.py
index e9d830bef..0b9c9f72a 100644
--- a/helpers/parallel_tools.py
+++ b/helpers/parallel_tools.py
@@ -487,37 +487,36 @@ async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
worker_agent = worker_context.agent0
worker_agent.loop_data = LoopData()
- return await execute_tool_call(worker_agent, job.tool_name, job.tool_args)
+ return await execute_tool_call(
+ worker_agent,
+ job.tool_name,
+ job.tool_args,
+ log_item=job.log_item,
+ )
finally:
if worker_context:
await _remove_context(worker_context.id)
-async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str, Any]) -> str:
+async def execute_tool_call(
+ agent: "Agent",
+ tool_name: str,
+ tool_args: dict[str, Any],
+ *,
+ log_item: "LogItem | None" = None,
+) -> str:
if tool_name == "parallel":
raise ValueError("`parallel` cannot be nested inside a parallel worker.")
- tool = None
- try:
- import helpers.mcp_handler as mcp_helper
-
- tool = mcp_helper.MCPConfig.get_instance().get_tool(agent, tool_name)
- except ImportError:
- tool = None
- except Exception as exc:
- PrintStyle.warning(f"Failed to initialize MCP tool '{tool_name}' for parallel job: {exc}")
-
- if not tool:
- tool = agent.get_tool(
- name=tool_name,
- method=None,
- args=tool_args,
- message=json.dumps({"tool_name": tool_name, "tool_args": tool_args}),
- loop_data=agent.loop_data,
- )
+ tool = _resolve_parallel_tool(agent, tool_name, tool_args, strict=True)
if not tool:
raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
+ original_get_log_object = None
+ if log_item is not None:
+ original_get_log_object = tool.get_log_object
+ tool.get_log_object = lambda: log_item
+
agent.loop_data.current_tool = tool
try:
await agent.handle_intervention()
@@ -541,9 +540,63 @@ async def execute_tool_call(agent: "Agent", tool_name: str, tool_args: dict[str,
await agent.handle_intervention()
return response.message
finally:
+ if original_get_log_object is not None:
+ tool.get_log_object = original_get_log_object
agent.loop_data.current_tool = None
+def _resolve_parallel_tool(
+ agent: "Agent",
+ tool_name: str,
+ tool_args: dict[str, Any],
+ *,
+ strict: bool = False,
+):
+ message = json.dumps({"tool_name": tool_name, "tool_args": tool_args})
+
+ tool = None
+ try:
+ import helpers.mcp_handler as mcp_helper
+
+ tool = mcp_helper.MCPConfig.get_instance().get_tool(agent, tool_name)
+ except ImportError:
+ tool = None
+ except Exception as exc:
+ if strict:
+ raise
+ PrintStyle.warning(f"Failed to initialize MCP tool '{tool_name}' for parallel job: {exc}")
+
+ if not tool:
+ get_tool = getattr(agent, "get_tool", None)
+ if not callable(get_tool):
+ if strict:
+ raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
+ return None
+ try:
+ tool = get_tool(
+ name=tool_name,
+ method=None,
+ args=tool_args,
+ message=message,
+ loop_data=getattr(agent, "loop_data", None),
+ )
+ except Exception as exc:
+ if strict:
+ raise
+ PrintStyle.warning(f"Failed to initialize tool '{tool_name}' for parallel job: {exc}")
+ tool = None
+
+ if not tool:
+ return None
+
+ try:
+ tool.args = dict(tool_args)
+ except Exception:
+ pass
+
+ return tool
+
+
async def _cancel_job(
job: ParallelJob,
*,
@@ -600,6 +653,17 @@ def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None:
)
return
+ tool = _resolve_parallel_tool(agent, job.tool_name, job.tool_args)
+ if tool is not None:
+ try:
+ job.log_item = tool.get_log_object()
+ if job.log_item is not None:
+ return
+ except Exception as exc:
+ PrintStyle.warning(
+ f"Failed to derive parallel child log for {job.tool_name}: {exc}"
+ )
+
heading = f"icon://construction {agent.agent_name}: Using tool '{job.tool_name}'"
job.log_item = agent.context.log.log(
type="tool",
diff --git a/helpers/parallel_tools.py.dox.md b/helpers/parallel_tools.py.dox.md
index 39e65c4cc..d2456f2f2 100644
--- a/helpers/parallel_tools.py.dox.md
+++ b/helpers/parallel_tools.py.dox.md
@@ -31,6 +31,8 @@
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
- Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.
- Child tool logs mirror normal tool-call visible args; job ids remain available through wrapper results and prompt extras rather than visible process-step args.
+- Wrapped tool child logs use each tool's native `get_log_object()` output when available, preserving special log rendering (for example: `code_execution_tool` uses `code_exe`, `wait` uses `progress`, MCP tools use `mcp`, and regular tools use `tool`).
+- Direct parallel worker execution reuses the parent-visible child log item so tool `before_execution()` cannot create a second generic worker log or lose the native badge type.
- Job IDs are stable handles for later await, collect, or cancel operations.
- Prompt extras must stay bounded and expose only job IDs, tool names, status, and compact result/error summaries.
diff --git a/helpers/projects.py b/helpers/projects.py
index f146d02a6..41bbf9d44 100644
--- a/helpers/projects.py
+++ b/helpers/projects.py
@@ -1,5 +1,5 @@
import os
-from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING, cast
+from typing import NotRequired, TypedDict, TYPE_CHECKING, cast
from helpers import files, dirty_json, persist_chat, file_tree, extension
from helpers.print_style import PrintStyle
@@ -15,7 +15,13 @@ PROJECT_KNOWLEDGE_DIR = "knowledge"
PROJECT_SKILLS_DIR = "skills"
PROJECT_HEADER_FILE = "project.json"
PROJECT_MCP_SERVERS_FILE = "mcp_servers.json"
-PROJECT_AGENTS_MD_FILES = ("AGENTS.md", "Agents.md", "agents.md")
+PROJECT_AGENTS_MD_FILES = (
+ "AGENTS.override.md",
+ "AGENTS.Override.md",
+ "AGENTS.md",
+ "Agents.md",
+ "agents.md",
+)
DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
CONTEXT_DATA_KEY_PROJECT = "project"
@@ -466,9 +472,10 @@ def deactivate_project_in_chats(name: str):
def build_system_prompt_vars(name: str):
project_data = load_basic_project_data(name)
main_instructions = project_data.get("instructions", "") or ""
+ include_agents_md = project_data.get("include_agents_md", True)
instruction_files = get_project_instruction_files(
name,
- include_agents_md=project_data.get("include_agents_md", True),
+ include_agents_md=include_agents_md,
)
instruction_parts = [
main_instructions,
@@ -481,11 +488,75 @@ def build_system_prompt_vars(name: str):
"project_name": project_data.get("title", ""),
"project_description": project_data.get("description", ""),
"project_instructions": complete_instructions or "",
+ "include_agents_md": include_agents_md,
"project_path": files.normalize_a0_path(get_project_folder(name)),
"project_git_url": project_data.get("git_url", ""),
}
+def get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]:
+ root_real = os.path.realpath(files.fix_dev_path(root))
+ target_real = os.path.realpath(files.fix_dev_path(target))
+ if os.path.isfile(target_real):
+ target_real = os.path.dirname(target_real)
+
+ if files.is_in_dir(target_real, root_real):
+ dirs = []
+ cursor = target_real
+ while True:
+ dirs.append(cursor)
+ if cursor == root_real:
+ break
+ parent = os.path.dirname(cursor)
+ if parent == cursor:
+ break
+ cursor = parent
+ dirs.reverse()
+ else:
+ dirs = [root_real]
+
+ chain = []
+ for dir_path in dirs:
+ for filename in PROJECT_AGENTS_MD_FILES:
+ matches = files.read_text_files_in_dir(dir_path, pattern=filename)
+ if filename not in matches:
+ continue
+ chain.append((files.get_abs_path(dir_path, filename), matches[filename]))
+ break
+ return chain
+
+
+def build_agents_md_protocol(name: str, target: str | None = None) -> str:
+ project_folder = get_project_folder(name)
+ project_agents_md = get_project_agents_md_instruction_file(name)
+ project_agents_md_path = (
+ os.path.realpath(files.fix_dev_path(project_agents_md[0]))
+ if project_agents_md
+ else ""
+ )
+ entries = [
+ (path, content)
+ for path, content in get_agents_md_chain(
+ files.get_abs_path(""),
+ target or project_folder,
+ )
+ if os.path.realpath(path) != project_agents_md_path
+ ]
+ if not entries:
+ return ""
+
+ instructions = []
+ for path, content in entries:
+ instructions.append(
+ f"### path: {files.normalize_a0_path(path)}\n\n{content.strip()}"
+ )
+ return files.read_prompt_file(
+ "agent.protocol.projects.agents_md.md",
+ _directories=["prompts"],
+ agents_md_instructions="\n\n".join(instructions),
+ ).strip()
+
+
def get_additional_instructions_files(name: str):
instructions_folder = files.get_abs_path(
get_project_folder(name), PROJECT_META_DIR, PROJECT_INSTRUCTIONS_DIR
@@ -522,11 +593,8 @@ def get_project_instruction_files(
def get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None:
project_folder = get_project_folder(name)
- for filename in PROJECT_AGENTS_MD_FILES:
- matches = files.read_text_files_in_dir(project_folder, pattern=filename)
- if filename in matches:
- path = files.get_abs_path(project_folder, filename)
- return (files.normalize_a0_path(path), matches[filename])
+ for path, content in get_agents_md_chain(project_folder, project_folder):
+ return (files.normalize_a0_path(path), content)
return None
diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md
index aae7514d0..e1e1b592e 100644
--- a/helpers/projects.py.dox.md
+++ b/helpers/projects.py.dox.md
@@ -47,6 +47,8 @@
- `reactivate_project_in_chats(name: str)`
- `deactivate_project_in_chats(name: str)`
- `build_system_prompt_vars(name: str)`
+- `get_agents_md_chain(root: str, target: str) -> list[tuple[str, str]]`
+- `build_agents_md_protocol(name: str, target: str | None=...) -> str`
- `get_additional_instructions_files(name: str)`
- `get_project_instruction_files(name: str, include_agents_md: bool=...) -> list[tuple[str, str]]`
- `get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None`
@@ -63,6 +65,8 @@
- Project extension data may add named top-level sections such as `llm`, but it must not overwrite core project fields owned by `EditProjectData`.
- Project extension save payloads exclude core project fields and transient inputs such as `git_token`; plugins needing core metadata should load it by project name.
- Project metadata setup creates and repairs `.a0proj/instructions`, `.a0proj/knowledge`, and `.a0proj/skills` so settings surfaces can open those folders consistently.
+- AGENTS.md discovery is a linear root-to-target chain walk with `AGENTS.override.md` precedence; sibling directories are not scanned.
+- Active-project AGENTS.md protocol guidance excludes the exact project root AGENTS.md because `build_system_prompt_vars(...)` already loads it into project instructions; prose for that protocol block lives in `prompts/agent.protocol.projects.agents_md.md`.
- Project MCP config uses the same JSON string shape as global MCP settings: an object with `mcpServers`.
- Project MCP load/save paths validate project names as simple folder basenames before touching `.a0proj/mcp_servers.json`.
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
diff --git a/helpers/skills.py b/helpers/skills.py
index 74488da9d..1eeaa8504 100644
--- a/helpers/skills.py
+++ b/helpers/skills.py
@@ -543,11 +543,15 @@ def search_skills(
if not q:
return []
- raw_terms = [t for t in re.split(r"\s+", q) if t]
+ raw_terms = re.findall(r"[a-z0-9][a-z0-9_-]*", q)
terms = [
t for t in raw_terms
- if len(t) >= 3 or any(ch.isdigit() for ch in t)
- ] or raw_terms
+ if len(t) >= 4 or any(ch.isdigit() for ch in t)
+ ]
+ long_terms = [
+ t for t in raw_terms
+ if len(t) >= 6 or any(ch.isdigit() for ch in t)
+ ]
candidates = list_skills(agent, include_hidden=include_hidden)
scored: List[Tuple[int, Skill]] = []
@@ -568,14 +572,13 @@ def search_skills(
score += 4
if any(q in tag for tag in tags):
score += 3
- if any(q in trigger for trigger in triggers):
+ if any(q in trigger or trigger in q for trigger in triggers):
score += 8
for term in terms:
if term in name:
score += 3
- if term in desc:
- score += 2
+ for term in long_terms:
if any(term in tag for tag in tags):
score += 1
if any(term in trigger for trigger in triggers):
@@ -1132,7 +1135,6 @@ def hide_chat_skill(agent: Agent, entry: Any) -> list[ActiveSkillEntry]:
CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS,
visible_entries,
)
- unload_agent_skill(agent, normalized)
return get_hidden_skills(agent)
@@ -1183,8 +1185,7 @@ def clear_chat_skill_overrides(agent: Agent) -> list[ActiveSkillEntry]:
def build_active_skills_prompt(agent: Agent | None) -> str:
- items = _resolve_active_skill_entries(agent, get_active_skills(agent))
- return "\n\n".join(item["content"] for item in items if item.get("content")).strip()
+ return ""
def _format_skill_prompt(skill: Skill) -> str:
diff --git a/helpers/skills.py.dox.md b/helpers/skills.py.dox.md
index d269bb205..902caf3fc 100644
--- a/helpers/skills.py.dox.md
+++ b/helpers/skills.py.dox.md
@@ -56,6 +56,9 @@
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
- Loaded skill names are chat-wide context data under `CONTEXT_DATA_NAME_LOADED_SKILLS`; legacy agent-local `loaded_skills` lists are migrated into context data and cleared when read.
+- Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger.
+- `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol.
+- `search_skills()` normalizes query words, scores normal terms against skill names, and scores only long terms against tags/triggers; descriptions match only full query phrases so generic prose does not produce irrelevant suggestions.
- Invalid `SKILL.md` frontmatter emits a once-per-path scan warning with the skipped skill path/name and a line number when the parser can identify one directly.
- Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md
index 768a203b0..0d42f9124 100644
--- a/plugins/AGENTS.md
+++ b/plugins/AGENTS.md
@@ -70,6 +70,7 @@ Direct child DOX files:
| [_browser/AGENTS.md](_browser/AGENTS.md) | Playwright browser tool, helpers, viewer, and browser panel UI. |
| [_chat_branching/AGENTS.md](_chat_branching/AGENTS.md) | Chat branching from an existing message. |
| [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. |
+| [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. |
| [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. |
| [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. |
| [_discovery/AGENTS.md](_discovery/AGENTS.md) | Welcome-screen plugin discovery cards and promotions. |
@@ -77,6 +78,7 @@ Direct child DOX files:
| [_editor/AGENTS.md](_editor/AGENTS.md) | Native Markdown editor surface and sessions. |
| [_email_integration/AGENTS.md](_email_integration/AGENTS.md) | IMAP/Exchange polling and SMTP reply integration. |
| [_error_retry/AGENTS.md](_error_retry/AGENTS.md) | Critical exception retry lifecycle hooks. |
+| [_goal/AGENTS.md](_goal/AGENTS.md) | Built-in chat goal strip, `/goal` slash command, and agent-facing goal tools. |
| [_infection_check/AGENTS.md](_infection_check/AGENTS.md) | Prompt-injection safety analysis before tool execution. |
| [_kokoro_tts/AGENTS.md](_kokoro_tts/AGENTS.md) | Kokoro text-to-speech integration. |
| [_memory/AGENTS.md](_memory/AGENTS.md) | Optional persistent recall plugin, knowledge import, tools, and dashboard; do not assume it is enabled outside this plugin. |
@@ -84,6 +86,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. |
diff --git a/plugins/_a0_connector/AGENTS.md b/plugins/_a0_connector/AGENTS.md
index e013c73cf..b81567a24 100644
--- a/plugins/_a0_connector/AGENTS.md
+++ b/plugins/_a0_connector/AGENTS.md
@@ -24,6 +24,7 @@
- File operation results may arrive as chunked JSON/base64
`connector_file_op_result` frames; resolve the pending file operation only
after all chunks for the `op_id` are assembled.
+- Host browser status metadata may advertise `available_browsers` entries with browser ids, labels, CDP endpoints, status, and enabled state; keep older CLI payloads without those fields compatible.
## Work Guidance
diff --git a/plugins/_a0_connector/api/v1/browser_runtime.py b/plugins/_a0_connector/api/v1/browser_runtime.py
index 72467e954..ae9b63829 100644
--- a/plugins/_a0_connector/api/v1/browser_runtime.py
+++ b/plugins/_a0_connector/api/v1/browser_runtime.py
@@ -24,6 +24,11 @@ def _normalize_requested_backend(value: object) -> str:
return ""
+def _normalize_host_browser_selection(value: object) -> str:
+ raw = _string(value).lower().replace(" ", "_")
+ return "".join(ch for ch in raw if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200]
+
+
def _normalize_profile_mode(value: object) -> str:
normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}:
@@ -68,6 +73,10 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
mimetype="application/json",
)
settings["runtime_backend"] = runtime_backend
+ if "host_browser_selection" in input or "browser_selection" in input:
+ settings["host_browser_selection"] = _normalize_host_browser_selection(
+ input.get("host_browser_selection", input.get("browser_selection"))
+ )
if "host_browser_profile_mode" in input or "profile_mode" in input:
profile_mode = _normalize_profile_mode(
input.get("host_browser_profile_mode", input.get("profile_mode"))
@@ -86,11 +95,13 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
runtime_backend = settings.get("runtime_backend") or "container"
profile_mode = _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
+ browser_selection = _normalize_host_browser_selection(settings.get("host_browser_selection"))
return {
"ok": True,
"runtime_backend": runtime_backend,
"host_browser_profile_mode": profile_mode,
+ "host_browser_selection": browser_selection,
"label": _runtime_label(runtime_backend),
"project_name": project_name,
"agent_profile": "",
diff --git a/plugins/_a0_connector/helpers/ws_runtime.py b/plugins/_a0_connector/helpers/ws_runtime.py
index 2e6b7034f..e35948359 100644
--- a/plugins/_a0_connector/helpers/ws_runtime.py
+++ b/plugins/_a0_connector/helpers/ws_runtime.py
@@ -80,6 +80,9 @@ class HostBrowserMetadata:
profile_label: str
profile_path: str
cdp_endpoint: str
+ browser_id: str
+ browser_label: str
+ available_browsers: tuple[dict[str, Any], ...]
content_helper_sha256: str
features: tuple[str, ...]
support_reason: str
@@ -416,6 +419,9 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr
profile_label=str(payload.get("profile_label", "") or "").strip(),
profile_path=str(payload.get("profile_path", "") or "").strip(),
cdp_endpoint=str(payload.get("cdp_endpoint", "") or "").strip(),
+ browser_id=str(payload.get("browser_id", payload.get("browser_selection", "")) or "").strip(),
+ browser_label=str(payload.get("browser_label", "") or "").strip(),
+ available_browsers=_normalize_available_host_browsers(payload.get("available_browsers")),
content_helper_sha256=str(payload.get("content_helper_sha256", "") or "").strip().lower(),
features=features,
support_reason=support_reason,
@@ -445,6 +451,32 @@ def _host_browser_can_prepare(
)
+def _normalize_available_host_browsers(value: Any) -> tuple[dict[str, Any], ...]:
+ if not isinstance(value, (list, tuple)):
+ return ()
+ browsers: list[dict[str, Any]] = []
+ for item in value:
+ if not isinstance(item, dict):
+ continue
+ browser_id = str(item.get("id", item.get("browser_id", item.get("selection", ""))) or "").strip()
+ family = str(item.get("family", item.get("browser_family", "")) or "").strip()
+ label = str(item.get("label", item.get("name", "")) or "").strip()
+ cdp_endpoint = str(item.get("cdp_endpoint", "") or "").strip()
+ status = str(item.get("status", "") or "").strip()
+ enabled = bool(item.get("enabled", True))
+ if not any((browser_id, family, label, cdp_endpoint)):
+ continue
+ browsers.append({
+ "id": browser_id or family or cdp_endpoint,
+ "family": family,
+ "label": label or family or browser_id or cdp_endpoint,
+ "cdp_endpoint": cdp_endpoint,
+ "status": status,
+ "enabled": enabled,
+ })
+ return tuple(browsers)
+
+
def clear_sid_host_browser_metadata(sid: str) -> None:
with _state_lock:
_sid_host_browser_metadata.pop(sid, None)
@@ -464,6 +496,9 @@ def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None:
"profile_label": metadata.profile_label,
"profile_path": metadata.profile_path,
"cdp_endpoint": metadata.cdp_endpoint,
+ "browser_id": metadata.browser_id,
+ "browser_label": metadata.browser_label,
+ "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
"content_helper_sha256": metadata.content_helper_sha256,
"features": list(metadata.features),
"support_reason": metadata.support_reason,
@@ -529,6 +564,10 @@ def all_host_browser_metadata() -> list[dict[str, Any]]:
"browser_family": metadata.browser_family,
"profile_label": metadata.profile_label,
"profile_path": metadata.profile_path,
+ "cdp_endpoint": metadata.cdp_endpoint,
+ "browser_id": metadata.browser_id,
+ "browser_label": metadata.browser_label,
+ "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
"content_helper_sha256": metadata.content_helper_sha256,
"features": list(metadata.features),
"support_reason": metadata.support_reason,
diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md
index 70211d3a0..a0e570a9d 100644
--- a/plugins/_browser/AGENTS.md
+++ b/plugins/_browser/AGENTS.md
@@ -19,7 +19,15 @@
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
- Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
- Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport.
+- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the ``/data URL path for snapshots and fallback rendering.
+- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
+- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
+- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`.
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
+- For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, or an explicit CDP endpoint and must be forwarded to the connector runtime as `browser_selection`.
+- Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
+- Browser Settings keeps the Host browser dropdown focused on automatic selection, advertised debug endpoints, and a validated Custom endpoint field instead of listing every installed local profile.
+- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
- Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
- Do not hardcode user-specific browser paths or secrets.
@@ -34,6 +42,7 @@
## Verification
- Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes.
+- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path.
- Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills.
## Child DOX Index
diff --git a/plugins/_browser/api/ws_browser.py b/plugins/_browser/api/ws_browser.py
index 6e24b268c..be7092826 100644
--- a/plugins/_browser/api/ws_browser.py
+++ b/plugins/_browser/api/ws_browser.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import base64
import contextlib
import time
from typing import Any, ClassVar
@@ -8,14 +9,20 @@ from typing import Any, ClassVar
from agent import AgentContext
from helpers.ws import WsHandler
from helpers.ws_manager import WsResult
+from plugins._browser.helpers.config import (
+ DEFAULT_BROWSER_TAB_SCOPE,
+ TAB_SCOPE_KEY,
+ get_browser_config,
+)
from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
-FRAME_IDLE_POLL_SECONDS = 0.05
+FRAME_READ_TIMEOUT_SECONDS = 0.5
FRAME_RETRY_DELAY_SECONDS = 0.5
FRAME_STATE_REFRESH_SECONDS = 0.75
SNAPSHOT_STATE_POLL_SECONDS = 0.75
-SCREENCAST_QUALITY = 92
+SCREENCAST_STREAM_QUALITY = 80
+SCREENSHOT_QUALITY = 92
VIEWER_TRANSPORT_SCREENCAST = "screencast"
VIEWER_TRANSPORT_SNAPSHOT = "snapshot"
VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT}
@@ -97,24 +104,40 @@ class WsBrowser(WsHandler):
existing.cancel()
viewer_id = str(data.get("viewer_id") or "")
viewer_transport = self._viewer_transport(data)
+ binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames")))
+ slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames)))
+ capture_scale = self._capture_scale_from_data(data)
snapshot = None
if runtime:
if viewer_transport == VIEWER_TRANSPORT_SCREENCAST:
- stream_task = self._stream_frames(sid, context_id, active_id, viewer_id)
+ stream_task = self._stream_frames(
+ sid,
+ context_id,
+ active_id,
+ viewer_id,
+ binary_frames=binary_frames,
+ slim_frames=slim_frames,
+ capture_scale=capture_scale,
+ )
else:
stream_task = self._stream_state(sid, context_id, active_id, viewer_id)
self._streams[stream_key] = asyncio.create_task(stream_task)
snapshot = await self._snapshot_for_browser(runtime, active_id)
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
+
return {
"context_id": context_id,
"active_browser_context_id": context_id,
"active_browser_id": active_id,
"snapshot": snapshot,
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"viewer_id": viewer_id,
"viewer_transport": viewer_transport,
+ "binary_frames": binary_frames,
+ "slim_frames": slim_frames,
}
def _unsubscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -127,10 +150,23 @@ class WsBrowser(WsHandler):
return {"context_id": context_id, "unsubscribed": True}
async def _sessions(self, data: dict[str, Any]) -> dict[str, Any]:
+ context_id = self._context_id(data)
+ tab_scope = self._tab_scope()
+ if tab_scope == "shared":
+ return {
+ "context_id": context_id,
+ "browsers": await self._all_browser_tabs(),
+ "all_browsers": True,
+ "tab_scope": tab_scope,
+ }
+
+ runtime = await get_runtime(context_id, create=False) if context_id else None
+ listing = await runtime.call("list") if runtime else {}
return {
- "context_id": self._context_id(data),
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "context_id": context_id,
+ "browsers": listing.get("browsers") or [],
+ "all_browsers": False,
+ "tab_scope": tab_scope,
}
async def _snapshot(self, data: dict[str, Any]) -> dict[str, Any] | WsResult:
@@ -142,13 +178,15 @@ class WsBrowser(WsHandler):
runtime = await get_runtime(context_id, create=False)
if not runtime:
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, [])
return {
"context_id": context_id,
"active_browser_context_id": context_id,
"active_browser_id": None,
"snapshot": None,
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
}
listing = await runtime.call("list")
@@ -157,9 +195,9 @@ class WsBrowser(WsHandler):
snapshot = None
if active_id:
try:
- quality = int(data.get("quality") or SCREENCAST_QUALITY)
+ quality = int(data.get("quality") or SCREENSHOT_QUALITY)
except (TypeError, ValueError):
- quality = SCREENCAST_QUALITY
+ quality = SCREENSHOT_QUALITY
with contextlib.suppress(Exception):
snapshot = await runtime.call(
"screenshot",
@@ -167,13 +205,16 @@ class WsBrowser(WsHandler):
quality=quality,
)
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
+
return {
"context_id": context_id,
"active_browser_context_id": context_id,
"active_browser_id": active_id,
"snapshot": snapshot,
- "browsers": await self._all_browser_tabs(),
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
}
async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -208,7 +249,10 @@ class WsBrowser(WsHandler):
listing = await runtime.call("list")
last_interacted_browser_id = listing.get("last_interacted_browser_id")
snapshot = await self._snapshot_for_result(runtime, result)
- all_browsers = await self._all_browser_tabs()
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(
+ context_id,
+ listing.get("browsers") or [],
+ )
await self.emit_to(
sid,
"browser_viewer_state",
@@ -220,8 +264,9 @@ class WsBrowser(WsHandler):
"browser_id": browser_id,
"result": result,
"snapshot": snapshot,
- "browsers": all_browsers,
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"last_interacted_browser_id": last_interacted_browser_id,
"viewer_transport": self._viewer_transport(data),
},
@@ -230,8 +275,9 @@ class WsBrowser(WsHandler):
return {
"result": result,
"snapshot": snapshot,
- "browsers": all_browsers,
- "all_browsers": True,
+ "browsers": browsers,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"active_browser_context_id": context_id,
"last_interacted_browser_id": last_interacted_browser_id,
"command": command,
@@ -347,7 +393,7 @@ class WsBrowser(WsHandler):
if not browser_id:
return None
with contextlib.suppress(Exception):
- return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY)
+ return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY)
return None
async def _snapshot_for_browser(
@@ -358,9 +404,27 @@ class WsBrowser(WsHandler):
if not browser_id:
return None
with contextlib.suppress(Exception):
- return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY)
+ return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY)
return None
+ @staticmethod
+ def _tab_scope() -> str:
+ scope = str(
+ (get_browser_config() or {}).get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE)
+ or DEFAULT_BROWSER_TAB_SCOPE
+ ).strip().lower().replace("-", "_")
+ return "shared" if scope == "shared" else DEFAULT_BROWSER_TAB_SCOPE
+
+ async def _tabs_for_scope(
+ self,
+ context_id: str,
+ browsers: list[dict[str, Any]] | None,
+ ) -> tuple[list[dict[str, Any]], bool, str]:
+ tab_scope = self._tab_scope()
+ if tab_scope == "shared":
+ return await self._all_browser_tabs(), True, tab_scope
+ return browsers or [], False, tab_scope
+
async def _all_browser_tabs(self) -> list[dict[str, Any]]:
browsers: list[dict[str, Any]] = []
for session in await list_runtime_sessions():
@@ -377,6 +441,10 @@ class WsBrowser(WsHandler):
context_id: str,
browser_id: int | str | None,
viewer_id: str = "",
+ *,
+ binary_frames: bool = False,
+ slim_frames: bool = False,
+ capture_scale: float = 1.0,
) -> None:
runtime = None
stream_id = None
@@ -397,12 +465,14 @@ class WsBrowser(WsHandler):
browsers = listing.get("browsers") or []
active_id = self._active_browser_id(listing, browser_id)
if not active_id:
- await self._emit_empty_frame(
+ await self._emit_viewer_state(
sid,
context_id,
+ active_id,
browsers=browsers,
viewer_id=viewer_id,
- frame_source=VIEWER_TRANSPORT_SCREENCAST,
+ state=None,
+ viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
)
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
continue
@@ -410,30 +480,60 @@ class WsBrowser(WsHandler):
screencast = await runtime.call(
"start_screencast",
active_id,
- quality=SCREENCAST_QUALITY,
+ quality=SCREENCAST_STREAM_QUALITY,
every_nth_frame=1,
+ capture_scale=capture_scale,
)
stream_id = screencast["stream_id"]
active_id = screencast["browser_id"]
state = screencast.get("state")
- await self.emit_to(
+ await self._emit_viewer_state(
sid,
- "browser_viewer_frame",
- {
- "context_id": context_id,
- "viewer_id": viewer_id,
- "browser_id": active_id,
- "browsers": browsers,
- "image": "",
- "mime": "",
- "state": state,
- "frame_source": "state",
- "viewer_transport": VIEWER_TRANSPORT_SCREENCAST,
- },
+ context_id,
+ active_id,
+ browsers=browsers,
+ viewer_id=viewer_id,
+ state=state,
+ viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
)
last_state_refresh = 0.0
+ last_state_signature = self._state_signature(active_id, browsers)
+ frame_sequence = 0
+ server_loop = asyncio.get_running_loop()
+ stop_event = asyncio.Event()
+
+ async def emit_frame(frame: dict[str, Any]) -> None:
+ nonlocal frame_sequence
+ try:
+ frame_sequence += 1
+ payload = self._frame_payload(
+ frame,
+ context_id=context_id,
+ viewer_id=viewer_id,
+ browser_id=active_id,
+ sequence=frame_sequence,
+ binary_frames=binary_frames,
+ )
+ if not slim_frames:
+ payload["browsers"] = browsers
+ payload["state"] = state
+ await self._emit_to_connected_viewer(sid, "browser_viewer_frame", payload)
+ except BaseException:
+ stop_event.set()
+ raise
+
+ def frame_consumer(frame: dict[str, Any]):
+ return asyncio.run_coroutine_threadsafe(emit_frame(frame), server_loop)
+
+ def stop_consumer() -> None:
+ server_loop.call_soon_threadsafe(stop_event.set)
+
+ await runtime.call("attach_screencast_consumer", stream_id, frame_consumer, stop_consumer)
+
while True:
+ if stop_event.is_set():
+ break
now = time.monotonic()
if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
listing = await runtime.call("list")
@@ -442,24 +542,25 @@ class WsBrowser(WsHandler):
if str(active_id) not in browser_ids:
break
state = self._state_for_browser(browsers, active_id, state)
+ state_signature = self._state_signature(active_id, browsers)
+ if state_signature != last_state_signature:
+ await self._emit_viewer_state(
+ sid,
+ context_id,
+ active_id,
+ browsers=browsers,
+ viewer_id=viewer_id,
+ state=state,
+ viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
+ )
+ last_state_signature = state_signature
last_state_refresh = now
try:
- frame = await runtime.call("pop_screencast_frame", stream_id)
- except KeyError:
+ await asyncio.wait_for(stop_event.wait(), timeout=FRAME_READ_TIMEOUT_SECONDS)
break
- if frame is None:
- await asyncio.sleep(FRAME_IDLE_POLL_SECONDS)
+ except TimeoutError:
continue
-
- frame["context_id"] = context_id
- frame["viewer_id"] = viewer_id
- frame["browser_id"] = active_id
- frame["browsers"] = browsers
- frame["state"] = state
- frame["frame_source"] = VIEWER_TRANSPORT_SCREENCAST
- frame["viewer_transport"] = VIEWER_TRANSPORT_SCREENCAST
- await self.emit_to(sid, "browser_viewer_frame", frame)
except asyncio.CancelledError:
raise
except Exception:
@@ -512,20 +613,14 @@ class WsBrowser(WsHandler):
),
)
if signature != last_signature:
- await self.emit_to(
+ await self._emit_viewer_state(
sid,
- "browser_viewer_frame",
- {
- "context_id": context_id,
- "viewer_id": viewer_id,
- "browser_id": active_id,
- "browsers": browsers,
- "image": "",
- "mime": "",
- "state": state,
- "frame_source": VIEWER_TRANSPORT_SNAPSHOT,
- "viewer_transport": VIEWER_TRANSPORT_SNAPSHOT,
- },
+ context_id,
+ active_id,
+ browsers=browsers,
+ viewer_id=viewer_id,
+ state=state,
+ viewer_transport=VIEWER_TRANSPORT_SNAPSHOT,
)
last_signature = signature
await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
@@ -564,6 +659,107 @@ class WsBrowser(WsHandler):
return browser
return current_state
+ @staticmethod
+ def _state_signature(
+ active_id: int | str | None,
+ browsers: list[dict[str, Any]],
+ ) -> tuple[str, tuple[tuple[str, str, str, str, bool], ...]]:
+ return (
+ str(active_id or ""),
+ tuple(
+ (
+ str(browser.get("context_id") or ""),
+ str(browser.get("id") or ""),
+ str(browser.get("currentUrl") or ""),
+ str(browser.get("title") or ""),
+ bool(browser.get("loading")),
+ )
+ for browser in browsers
+ ),
+ )
+
+ @staticmethod
+ def _frame_payload(
+ frame: dict[str, Any],
+ *,
+ context_id: str,
+ viewer_id: str,
+ browser_id: int | str,
+ sequence: int,
+ binary_frames: bool,
+ ) -> dict[str, Any]:
+ image = str(frame.get("image") or "")
+ payload: dict[str, Any] = {
+ "context_id": context_id,
+ "viewer_id": viewer_id,
+ "browser_id": browser_id,
+ "seq": sequence,
+ "mime": frame.get("mime") or "image/jpeg",
+ "frame_source": VIEWER_TRANSPORT_SCREENCAST,
+ "viewer_transport": VIEWER_TRANSPORT_SCREENCAST,
+ }
+ dimensions = WsBrowser._frame_dimensions(frame.get("metadata"))
+ if dimensions:
+ payload.update(dimensions)
+ if binary_frames:
+ try:
+ payload["image"] = base64.b64decode(image, validate=False)
+ payload["encoding"] = "binary"
+ except Exception:
+ payload["image"] = image
+ payload["encoding"] = "base64"
+ else:
+ payload["image"] = image
+ payload["encoding"] = "base64"
+ return payload
+
+ @staticmethod
+ def _frame_dimensions(metadata: Any) -> dict[str, int]:
+ if not isinstance(metadata, dict):
+ return {}
+ for width_key, height_key in (
+ ("expectedWidth", "expectedHeight"),
+ ("deviceWidth", "deviceHeight"),
+ ("jpegWidth", "jpegHeight"),
+ ):
+ try:
+ width = int(metadata.get(width_key) or 0)
+ height = int(metadata.get(height_key) or 0)
+ except (TypeError, ValueError):
+ continue
+ if width > 0 and height > 0:
+ return {"width": width, "height": height}
+ return {}
+
+ async def _emit_viewer_state(
+ self,
+ sid: str,
+ context_id: str,
+ browser_id: int | str | None,
+ *,
+ browsers: list[dict[str, Any]] | None = None,
+ viewer_id: str = "",
+ state: dict[str, Any] | None = None,
+ viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
+ ) -> None:
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or [])
+ await self._emit_to_connected_viewer(
+ sid,
+ "browser_viewer_state",
+ {
+ "context_id": context_id,
+ "active_browser_context_id": context_id,
+ "viewer_id": viewer_id,
+ "browser_id": browser_id,
+ "active_browser_id": browser_id,
+ "browsers": browsers or [],
+ "state": state,
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
+ "viewer_transport": viewer_transport,
+ },
+ )
+
async def _emit_empty_frame(
self,
sid: str,
@@ -573,7 +769,8 @@ class WsBrowser(WsHandler):
viewer_id: str = "",
frame_source: str = "",
) -> None:
- await self.emit_to(
+ browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers or [])
+ await self._emit_to_connected_viewer(
sid,
"browser_viewer_frame",
{
@@ -581,6 +778,8 @@ class WsBrowser(WsHandler):
"viewer_id": viewer_id,
"browser_id": None,
"browsers": browsers or [],
+ "all_browsers": all_browsers,
+ "tab_scope": tab_scope,
"image": "",
"mime": "",
"state": None,
@@ -589,6 +788,20 @@ class WsBrowser(WsHandler):
},
)
+ async def _emit_to_connected_viewer(
+ self,
+ sid: str,
+ event: str,
+ data: dict[str, Any],
+ ) -> None:
+ manager = getattr(self, "_manager", None)
+ if manager is not None:
+ with manager.lock:
+ connected = (getattr(self, "namespace", "/ws"), sid) in manager.connections
+ if not connected:
+ raise asyncio.CancelledError()
+ await self.emit_to(sid, event, data)
+
@staticmethod
def _viewer_transport(data: dict[str, Any]) -> str:
raw = (
@@ -620,6 +833,20 @@ class WsBrowser(WsHandler):
"height": max(200, min(4096, height)),
}
+ @staticmethod
+ def _capture_scale_from_data(data: dict[str, Any]) -> float:
+ try:
+ scale = float(
+ data.get("device_pixel_ratio")
+ or data.get("devicePixelRatio")
+ or data.get("pixel_ratio")
+ or data.get("pixelRatio")
+ or 1
+ )
+ except (TypeError, ValueError):
+ return 1.0
+ return max(1.0, min(2.0, scale))
+
@staticmethod
def _context_id(data: dict[str, Any]) -> str:
return str(data.get("context_id") or data.get("context") or "").strip()
diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml
index b87c040e1..896239304 100644
--- a/plugins/_browser/default_config.yaml
+++ b/plugins/_browser/default_config.yaml
@@ -8,6 +8,11 @@ default_homepage: "about:blank"
# When the Browser surface is already open, keep it synced to agent Browser tool results.
autofocus_active_page: true
+# Browser tab visibility in the WebUI:
+# - per_context: each chat shows only its own Browser tabs.
+# - shared: show Browser tabs from all active chats.
+browser_tab_scope: "per_context"
+
# Maximum number of Browser tabs/pages a single chat context may keep open.
# Raise this only for deliberate parallel browsing workflows.
max_open_tabs: 32
@@ -28,6 +33,11 @@ host_browser_privacy_policy: "allow"
# - agent: use a clean A0-controlled browser profile on the host.
host_browser_profile_mode: "existing"
+# Optional host browser target when using an existing browser.
+# Empty means A0 CLI chooses the first supported/active browser.
+# Values may be browser family ids (chrome, edge, chromium) or CLI-advertised ids/endpoints.
+host_browser_selection: ""
+
# Optional _model_config preset used by Browser-owned model helpers.
# Empty uses the effective Main Model.
model_preset: ""
diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py
index f39e52e51..5279f6215 100644
--- a/plugins/_browser/helpers/config.py
+++ b/plugins/_browser/helpers/config.py
@@ -11,13 +11,17 @@ PLUGIN_NAME = "_browser"
MODEL_PRESET_KEY = "model_preset"
DEFAULT_HOMEPAGE_KEY = "default_homepage"
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
+TAB_SCOPE_KEY = "browser_tab_scope"
MAX_OPEN_TABS_KEY = "max_open_tabs"
RUNTIME_BACKEND_KEY = "runtime_backend"
HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
+HOST_BROWSER_SELECTION_KEY = "host_browser_selection"
RUNTIME_BACKENDS = {"container", "host_required"}
+BROWSER_TAB_SCOPES = {"per_context", "shared"}
HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
+DEFAULT_BROWSER_TAB_SCOPE = "per_context"
DEFAULT_MAX_OPEN_TABS = 32
MIN_MAX_OPEN_TABS = 1
HARD_MAX_OPEN_TABS = 50
@@ -55,6 +59,15 @@ def _normalize_model_preset(value: Any) -> str:
return str(value or "").strip()
+def _normalize_host_browser_selection(value: Any) -> str:
+ raw = str(value or "").strip()
+ if not raw:
+ return ""
+ normalized = raw.lower().replace(" ", "_")
+ # Keep explicit CLI ids/ports/endpoints usable while avoiding control characters.
+ return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200]
+
+
def _normalize_default_homepage(value: Any) -> str:
homepage = str(value or "").strip()
return homepage or "about:blank"
@@ -117,6 +130,11 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
default=True,
),
+ TAB_SCOPE_KEY: _normalize_choice(
+ raw.get(TAB_SCOPE_KEY, DEFAULT_BROWSER_TAB_SCOPE),
+ allowed=BROWSER_TAB_SCOPES,
+ default=DEFAULT_BROWSER_TAB_SCOPE,
+ ),
MAX_OPEN_TABS_KEY: _normalize_int(
raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS),
default=DEFAULT_MAX_OPEN_TABS,
@@ -136,6 +154,9 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
allowed=HOST_BROWSER_PROFILE_MODES,
default="existing",
),
+ HOST_BROWSER_SELECTION_KEY: _normalize_host_browser_selection(
+ raw.get(HOST_BROWSER_SELECTION_KEY, raw.get("host_browser_choice", ""))
+ ),
MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
}
diff --git a/plugins/_browser/helpers/connector_runtime.py b/plugins/_browser/helpers/connector_runtime.py
index 47b31512b..d5f05cb71 100644
--- a/plugins/_browser/helpers/connector_runtime.py
+++ b/plugins/_browser/helpers/connector_runtime.py
@@ -57,6 +57,11 @@ HOST_BROWSER_PROFILE_MODE_KEY = getattr(
"HOST_BROWSER_PROFILE_MODE_KEY",
"host_browser_profile_mode",
)
+HOST_BROWSER_SELECTION_KEY = getattr(
+ browser_config,
+ "HOST_BROWSER_SELECTION_KEY",
+ "host_browser_selection",
+)
get_browser_config = browser_config.get_browser_config
_LOCAL_PROVIDERS = {"ollama", "lm_studio", "llama_cpp", "omlx", "vllm"}
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
@@ -78,7 +83,8 @@ _REQUIRED_API_NAMES_RE = re.compile(
re.S,
)
_HOST_BROWSER_REMOTE_DEBUGGING_HELP = (
- 'For an already-open Chrome-family browser, open `chrome://inspect/#remote-debugging`, '
+ "For an already-open Chromium-family browser, open its inspect page, such as "
+ "`chrome://inspect/#remote-debugging` or `opera://inspect/#remote-debugging`, "
'enable "Allow remote debugging for this browser instance", run `/browser host on`, '
"and retry."
)
@@ -123,6 +129,7 @@ class ConnectorBrowserRuntime:
"context_id": self.context_id,
"action": action,
"profile_mode": self._host_browser_profile_mode(),
+ "browser_selection": self._host_browser_selection(),
}
if action == "open":
@@ -283,6 +290,7 @@ class ConnectorBrowserRuntime:
"context_id": self.context_id,
"action": "ensure",
"profile_mode": self._host_browser_profile_mode(),
+ "browser_selection": self._host_browser_selection(),
},
),
)
@@ -295,6 +303,10 @@ class ConnectorBrowserRuntime:
mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower()
return "agent" if mode == "agent" else "existing"
+ def _host_browser_selection(self) -> str:
+ config = get_browser_config(self.agent)
+ return str(config.get(HOST_BROWSER_SELECTION_KEY) or "").strip()
+
def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
return self._with_browser_helpers(sid, payload)
@@ -508,7 +520,10 @@ class ConnectorBrowserRuntime:
if not message:
message = "Host browser operation failed"
normalized = message.lower()
- if "chrome://inspect/#remote-debugging" in normalized:
+ if (
+ "chrome://inspect/#remote-debugging" in normalized
+ or "opera://inspect/#remote-debugging" in normalized
+ ):
return _append_docker_browser_recovery(message)
if any(token in normalized for token in _REMOTE_DEBUGGING_ERROR_TOKENS):
return _append_docker_browser_recovery(
diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py
index e7455d81a..994c6f04e 100644
--- a/plugins/_browser/helpers/runtime.py
+++ b/plugins/_browser/helpers/runtime.py
@@ -317,8 +317,11 @@ class _BrowserScreencast:
self.browser_id = browser_id
self.session = session
self.mime = mime
+ self.frame_consumer: Any | None = None
+ self.stop_callback: Any | None = None
self.queue = asyncio.Queue(maxsize=1)
self.stopped = False
+ self._closed = False
self._ack_tasks: set[asyncio.Task] = set()
self._expected_width = 0
self._expected_height = 0
@@ -329,10 +332,14 @@ class _BrowserScreencast:
quality: int,
every_nth_frame: int,
viewport: dict[str, int],
+ capture_scale: float = 1.0,
) -> None:
self.session.on("Page.screencastFrame", self._on_frame)
width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"])))
height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"])))
+ scale = max(1.0, min(2.0, float(capture_scale or 1.0)))
+ max_width = max(320, min(SCREENCAST_MAX_WIDTH, int(round(width * scale))))
+ max_height = max(200, min(SCREENCAST_MAX_HEIGHT, int(round(height * scale))))
self._expected_width = width
self._expected_height = height
with contextlib.suppress(Exception):
@@ -343,8 +350,8 @@ class _BrowserScreencast:
{
"format": "jpeg",
"quality": max(20, min(95, int(quality))),
- "maxWidth": SCREENCAST_MAX_WIDTH,
- "maxHeight": SCREENCAST_MAX_HEIGHT,
+ "maxWidth": max_width,
+ "maxHeight": max_height,
"everyNthFrame": max(1, int(every_nth_frame)),
},
)
@@ -394,10 +401,21 @@ class _BrowserScreencast:
raise RuntimeError("Browser screencast stopped.")
return frame
+ async def attach_consumer(self, frame_consumer: Any, stop_callback: Any | None = None) -> None:
+ self.frame_consumer = frame_consumer
+ self.stop_callback = stop_callback
+ frame = await self.pop_frame()
+ if frame:
+ await self._deliver_frame(frame)
+
async def stop(self) -> None:
- if self.stopped:
+ if self._closed:
return
+ was_stopped = self.stopped
+ self._closed = True
self.stopped = True
+ if not was_stopped:
+ self._notify_stopped()
self._drop_queued_frames()
with contextlib.suppress(asyncio.QueueFull):
self.queue.put_nowait(None)
@@ -419,6 +437,8 @@ class _BrowserScreencast:
task.add_done_callback(self._ack_tasks.discard)
async def _handle_frame(self, params: dict[str, Any]) -> None:
+ stop_after_ack = False
+ notify_stop = False
try:
data = params.get("data") or ""
if data:
@@ -428,7 +448,7 @@ class _BrowserScreencast:
metadata["jpegWidth"], metadata["jpegHeight"] = size
metadata["expectedWidth"] = self._expected_width
metadata["expectedHeight"] = self._expected_height
- self._queue_latest(
+ await self._deliver_frame(
{
"browser_id": self.browser_id,
"mime": self.mime,
@@ -436,6 +456,14 @@ class _BrowserScreencast:
"metadata": metadata,
}
)
+ except asyncio.CancelledError:
+ stop_after_ack = True
+ except Exception:
+ if self.frame_consumer:
+ stop_after_ack = True
+ notify_stop = True
+ else:
+ raise
finally:
session_id = params.get("sessionId")
if session_id is not None and not self.stopped:
@@ -444,6 +472,24 @@ class _BrowserScreencast:
"Page.screencastFrameAck",
{"sessionId": int(session_id)},
)
+ if stop_after_ack:
+ self.stopped = True
+ if notify_stop:
+ self._notify_stopped()
+
+ def _notify_stopped(self) -> None:
+ if not self.stop_callback:
+ return
+ with contextlib.suppress(Exception):
+ self.stop_callback()
+
+ async def _deliver_frame(self, frame: dict[str, Any]) -> None:
+ if not self.frame_consumer:
+ self._queue_latest(frame)
+ return
+ future = self.frame_consumer(frame)
+ if future is not None:
+ await asyncio.wrap_future(future)
def _queue_latest(self, frame: dict[str, Any]) -> None:
self._drop_queued_frames()
@@ -1616,6 +1662,7 @@ class _BrowserRuntimeCore:
*,
quality: int = 78,
every_nth_frame: int = 1,
+ capture_scale: float = 1.0,
) -> dict[str, Any]:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
@@ -1634,6 +1681,7 @@ class _BrowserRuntimeCore:
quality=quality,
every_nth_frame=every_nth_frame,
viewport=page.viewport_size or DEFAULT_VIEWPORT,
+ capture_scale=capture_scale,
)
except Exception:
self.screencasts.pop(stream_id, None)
@@ -1663,6 +1711,17 @@ class _BrowserRuntimeCore:
raise KeyError("Browser screencast is not active.")
return await screencast.pop_frame()
+ async def attach_screencast_consumer(
+ self,
+ stream_id: str,
+ frame_consumer: Any,
+ stop_callback: Any | None = None,
+ ) -> None:
+ screencast = self.screencasts.get(str(stream_id or ""))
+ if not screencast:
+ raise KeyError("Browser screencast is not active.")
+ await screencast.attach_consumer(frame_consumer, stop_callback)
+
async def stop_screencast(self, stream_id: str) -> None:
screencast = self.screencasts.pop(str(stream_id or ""), None)
if screencast:
diff --git a/plugins/_browser/prompts/agent.system.tool.browser.md b/plugins/_browser/prompts/agent.system.tool.browser.md
index 21cd8279a..402ef6efa 100644
--- a/plugins/_browser/prompts/agent.system.tool.browser.md
+++ b/plugins/_browser/prompts/agent.system.tool.browser.md
@@ -3,7 +3,7 @@ Rendered browser automation for pages that need interaction, JavaScript, forms,
Prefer `search_engine` or `document_query` for plain text research. The tool must not open a Browser surface automatically. Use the tool headlessly unless the user opens the Browser surface or asks for the optional visible WebUI viewer.
-When the user asks for "my browser", "host browser", "local browser", local Chrome, or opening a URL in their host browser, use this `browser` tool. Do not substitute `computer_use_remote`, `code_execution_remote`, `xdg-open`, `sensible-browser`, or Python `webbrowser.open`. If setup fails and mentions remote debugging, tell the user to open `chrome://inspect/#remote-debugging`, enable "Allow remote debugging for this browser instance", run `/browser host on`, and retry.
+When the user asks for "my browser", "host browser", "local browser", a local Chromium browser, or opening a URL in their host browser, use this `browser` tool. Do not substitute `computer_use_remote`, `code_execution_remote`, `xdg-open`, `sensible-browser`, or Python `webbrowser.open`. If setup fails and mentions remote debugging, tell the user to open the browser inspect page, such as `chrome://inspect/#remote-debugging` or `opera://inspect/#remote-debugging`, enable "Allow remote debugging for this browser instance", run `/browser host on`, and retry.
For rendered browsing workflows, multi-step interaction, screenshots, downloads, uploads, forms, or host/container mode decisions, first load `browser-automation` with `skills_tool:load`, then call this tool using the loaded instructions. For fragile forms, `browser-automation` links to `browser-form-workflows`; load it when selects, checkboxes, radios, uploads, contenteditable fields, validation, or submission state are central.
diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js
index 81fee2e85..4f32fe0ac 100644
--- a/plugins/_browser/webui/browser-config-store.js
+++ b/plugins/_browser/webui/browser-config-store.js
@@ -4,8 +4,14 @@ import { callJsonApi } from "/js/api.js";
const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
const BROWSER_STATUS_API = "/plugins/_browser/status";
const RUNTIME_BACKENDS = new Set(["container", "host_required"]);
+const BROWSER_TAB_SCOPES = new Set(["per_context", "shared"]);
const HOST_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]);
const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
+const DEFAULT_MAX_OPEN_TABS = 32;
+const MIN_MAX_OPEN_TABS = 1;
+const HARD_MAX_OPEN_TABS = 50;
+const HOST_BROWSER_STATUS_REFRESH_MS = 1000;
+const CUSTOM_HOST_BROWSER_SELECTION = "__custom_endpoint__";
function normalizePathList(value) {
const source = Array.isArray(value)
@@ -27,6 +33,8 @@ function ensureConfig(config) {
config.extension_paths = normalizePathList(config.extension_paths);
config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true);
+ config.browser_tab_scope = normalizeChoice(config.browser_tab_scope, BROWSER_TAB_SCOPES, "per_context");
+ config.max_open_tabs = normalizeInt(config.max_open_tabs, DEFAULT_MAX_OPEN_TABS, MIN_MAX_OPEN_TABS, HARD_MAX_OPEN_TABS);
config.runtime_backend = normalizeRuntimeBackend(config.runtime_backend);
config.host_browser_privacy_policy = normalizeChoice(
config.host_browser_privacy_policy,
@@ -38,6 +46,7 @@ function ensureConfig(config) {
HOST_PROFILE_MODES,
"existing",
);
+ config.host_browser_selection = normalizeHostBrowserSelection(config.host_browser_selection);
config.model_preset = String(config.model_preset || "").trim();
delete config.model;
return config;
@@ -48,12 +57,55 @@ function normalizeChoice(value, allowed, fallback) {
return allowed.has(normalized) ? normalized : fallback;
}
+function normalizeInt(value, fallback, minimum, maximum) {
+ const number = Number.parseInt(value, 10);
+ if (!Number.isFinite(number)) return fallback;
+ return Math.max(minimum, Math.min(maximum, number));
+}
+
function normalizeRuntimeBackend(value) {
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
if (normalized === "host_when_available") return "host_required";
return RUNTIME_BACKENDS.has(normalized) ? normalized : "container";
}
+function normalizeHostBrowserSelection(value) {
+ return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").slice(0, 200);
+}
+
+function normalizeCustomHostBrowserEndpoint(value) {
+ const raw = String(value || "").trim();
+ if (!raw) return "";
+ const candidate = raw.includes("://") ? raw : `ws://${raw}`;
+ try {
+ const url = new URL(candidate);
+ if (!["ws:", "wss:"].includes(url.protocol) || !url.host || !url.pathname.startsWith("/devtools/browser/")) {
+ return "";
+ }
+ return normalizeHostBrowserSelection(`${url.protocol}//${url.host}${url.pathname}${url.search || ""}`);
+ } catch (_error) {
+ return "";
+ }
+}
+
+function isCustomHostBrowserEndpoint(value) {
+ return Boolean(normalizeCustomHostBrowserEndpoint(value));
+}
+
+function debugPortVersionUrl(value) {
+ const raw = String(value || "").trim();
+ if (!raw) return "";
+ const candidate = raw.includes("://") ? raw : `ws://${raw}`;
+ try {
+ const url = new URL(candidate);
+ if (!["ws:", "wss:"].includes(url.protocol) || !url.host) return "";
+ if (url.pathname && url.pathname !== "/") return "";
+ return `http://${url.host}/json/version`;
+ } catch (_error) {
+ return "";
+ }
+}
+
function normalizeBoolean(value, fallback = true) {
if (value === undefined || value === null || value === "") return fallback;
if (typeof value === "boolean") return value;
@@ -74,6 +126,9 @@ function hostBrowserFamilyLabel(value) {
chromium: "Chromium",
edge: "Edge",
"edge-dev": "Edge Dev",
+ brave: "Brave",
+ opera: "Opera",
+ vivaldi: "Vivaldi",
};
const label = labels[base] || "Host browser";
if (remoteDebugging) return `${label} (allowed)`;
@@ -99,13 +154,18 @@ export const store = createStore("browserConfig", {
extensionDeleteLoadingPath: "",
hostBrowserStatus: null,
hostBrowserStatusLoading: false,
+ hostBrowserStatusRefreshTimer: null,
+ hostBrowserCustomEndpoint: "",
+ hostBrowserCustomMode: false,
async init(config) {
this.bindConfig(config);
await Promise.all([this.loadExtensionsList(), this.loadHostBrowserStatus()]);
+ this.startHostBrowserStatusRefresh();
},
cleanup() {
+ this.stopHostBrowserStatusRefresh();
this.config = null;
this.extensionsList = [];
this.extensionsError = "";
@@ -113,6 +173,22 @@ export const store = createStore("browserConfig", {
this.extensionDeleteLoadingPath = "";
this.hostBrowserStatus = null;
this.hostBrowserStatusLoading = false;
+ this.hostBrowserCustomEndpoint = "";
+ this.hostBrowserCustomMode = false;
+ },
+
+ startHostBrowserStatusRefresh() {
+ this.stopHostBrowserStatusRefresh();
+ this.hostBrowserStatusRefreshTimer = window.setInterval(
+ () => this.loadHostBrowserStatus(),
+ HOST_BROWSER_STATUS_REFRESH_MS,
+ );
+ },
+
+ stopHostBrowserStatusRefresh() {
+ if (!this.hostBrowserStatusRefreshTimer) return;
+ window.clearInterval(this.hostBrowserStatusRefreshTimer);
+ this.hostBrowserStatusRefreshTimer = null;
},
bindConfig(config) {
@@ -120,6 +196,9 @@ export const store = createStore("browserConfig", {
if (!safeConfig) return;
if (this.config === safeConfig) return;
this.config = safeConfig;
+ if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) {
+ this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection;
+ }
},
setAutofocusActivePage(enabled) {
@@ -132,6 +211,27 @@ export const store = createStore("browserConfig", {
return this.config?.autofocus_active_page === false ? "Off" : "On";
},
+ setBrowserTabScope(value) {
+ const safeConfig = ensureConfig(this.config);
+ if (!safeConfig) return;
+ safeConfig.browser_tab_scope = normalizeChoice(value, BROWSER_TAB_SCOPES, "per_context");
+ },
+
+ browserTabScopeLabel() {
+ return this.config?.browser_tab_scope === "shared" ? "Shared" : "Per chat";
+ },
+
+ normalizeMaxOpenTabs() {
+ const safeConfig = ensureConfig(this.config);
+ if (!safeConfig) return;
+ safeConfig.max_open_tabs = normalizeInt(
+ safeConfig.max_open_tabs,
+ DEFAULT_MAX_OPEN_TABS,
+ MIN_MAX_OPEN_TABS,
+ HARD_MAX_OPEN_TABS,
+ );
+ },
+
runtimeBackendLabel() {
const value = this.config?.runtime_backend || "container";
if (value === "host_required") return "Bring Your Own Browser";
@@ -145,6 +245,93 @@ export const store = createStore("browserConfig", {
return "Local Models Only";
},
+ hostBrowserOptions() {
+ const connectors = Array.isArray(this.hostBrowserStatus?.connectors)
+ ? this.hostBrowserStatus.connectors
+ : [];
+ const options = [{ value: "", label: "Automatic (A0 CLI chooses)" }];
+ const seen = new Set([""]);
+ for (const connector of connectors) {
+ const advertised = Array.isArray(connector?.available_browsers)
+ ? connector.available_browsers
+ : [];
+ for (const browser of advertised) {
+ const value = normalizeCustomHostBrowserEndpoint(browser?.cdp_endpoint || browser?.id);
+ if (!value || seen.has(value)) continue;
+ seen.add(value);
+ const label = browser?.label || hostBrowserFamilyLabel(browser?.family || value);
+ const status = browser?.status ? ` - ${hostBrowserStatusLabel(browser.status)}` : "";
+ options.push({ value, label: `${label}${status}` });
+ }
+ const fallbackValue = normalizeCustomHostBrowserEndpoint(connector?.cdp_endpoint || connector?.browser_id);
+ if (fallbackValue && !seen.has(fallbackValue)) {
+ seen.add(fallbackValue);
+ const label = connector?.browser_label || hostBrowserFamilyLabel(connector?.browser_family || fallbackValue);
+ options.push({ value: fallbackValue, label });
+ }
+ }
+ const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection);
+ if (selected && !seen.has(selected) && !isCustomHostBrowserEndpoint(selected)) {
+ seen.add(selected);
+ options.push({ value: selected, label: `Saved: ${selected}` });
+ }
+ options.push({ value: CUSTOM_HOST_BROWSER_SELECTION, label: "Custom endpoint" });
+ return options;
+ },
+
+ hostBrowserSelectValue() {
+ if (this.hostBrowserCustomMode) return CUSTOM_HOST_BROWSER_SELECTION;
+ const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection);
+ if (!selected) return "";
+ if (this.hostBrowserOptions().some((option) => option.value === selected)) return selected;
+ if (isCustomHostBrowserEndpoint(selected)) return CUSTOM_HOST_BROWSER_SELECTION;
+ return selected;
+ },
+
+ setHostBrowserSelection(value) {
+ const safeConfig = ensureConfig(this.config);
+ if (!safeConfig) return;
+ if (value === CUSTOM_HOST_BROWSER_SELECTION) {
+ this.hostBrowserCustomMode = true;
+ if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) {
+ this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection;
+ } else {
+ safeConfig.host_browser_selection = "";
+ }
+ return;
+ }
+ this.hostBrowserCustomMode = false;
+ safeConfig.host_browser_selection = normalizeHostBrowserSelection(value);
+ },
+
+ showCustomHostBrowserEndpoint() {
+ return this.hostBrowserSelectValue() === CUSTOM_HOST_BROWSER_SELECTION;
+ },
+
+ setCustomHostBrowserEndpoint(value) {
+ this.hostBrowserCustomMode = true;
+ this.hostBrowserCustomEndpoint = String(value || "").trim();
+ const safeConfig = ensureConfig(this.config);
+ if (!safeConfig) return;
+ const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint);
+ if (endpoint || !this.hostBrowserCustomEndpoint) {
+ safeConfig.host_browser_selection = endpoint;
+ }
+ },
+
+ customHostBrowserEndpointDiagnostic() {
+ if (!this.hostBrowserCustomEndpoint) {
+ return "Paste a ws://.../devtools/browser/... endpoint from the browser inspect page.";
+ }
+ const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint);
+ if (endpoint) return `Using ${endpoint}`;
+ const versionUrl = debugPortVersionUrl(this.hostBrowserCustomEndpoint);
+ if (versionUrl) {
+ return `This looks like a debug port. Open ${versionUrl} and copy webSocketDebuggerUrl.`;
+ }
+ return "Endpoint must be a ws:// or wss:// URL ending in /devtools/browser/...";
+ },
+
hostBrowserProfileModeLabel() {
const value = this.config?.host_browser_profile_mode || "existing";
if (value === "agent") return "Clean Agent Profile";
diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html
index 7ad3902f5..c92a1acec 100644
--- a/plugins/_browser/webui/browser-panel.html
+++ b/plugins/_browser/webui/browser-panel.html
@@ -16,7 +16,7 @@
-
+
-
+
captive_portal
+ >
+
+
+
No models found. You can still type the model name manually.
@@ -282,7 +290,10 @@
-
+
@@ -991,7 +1002,9 @@
}
.oauth-model-item {
- display: block;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
width: 100%;
min-height: 34px;
padding: 8px 10px;
@@ -1004,6 +1017,19 @@
cursor: pointer;
}
+ .oauth-model-name {
+ font-weight: 650;
+ }
+
+ .oauth-model-item small {
+ overflow: hidden;
+ color: var(--color-text-secondary);
+ font-size: 0.72rem;
+ line-height: 1.3;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
.oauth-model-item:hover,
.oauth-model-item:focus-visible {
background: color-mix(in srgb, var(--color-border) 42%, transparent);
diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js
index c77701631..004093662 100644
--- a/plugins/_oauth/webui/oauth-config-store.js
+++ b/plugins/_oauth/webui/oauth-config-store.js
@@ -107,6 +107,7 @@ export const store = createStore("oauthConfig", {
disconnecting: false,
loadingModels: false,
providerModels: {},
+ providerModelMetadata: {},
providerUi: providerUiDefaults(),
connectingProvider: "",
disconnectingProvider: "",
@@ -150,6 +151,7 @@ export const store = createStore("oauthConfig", {
this.disconnecting = false;
this.loadingModels = false;
this.providerModels = {};
+ this.providerModelMetadata = {};
this.providerUi = providerUiDefaults();
this.connectingProvider = "";
this.disconnectingProvider = "";
@@ -556,6 +558,14 @@ export const store = createStore("oauthConfig", {
return this.providerModels[this.activeModelProvider] || [];
},
+ modelMetadata(providerId, model) {
+ return this.providerModelMetadata[providerId]?.[model] || {};
+ },
+
+ modelDescription(providerId, model) {
+ return this.modelMetadata(providerId, model).description || "";
+ },
+
activeModelsDescription() {
if (!this.providerConnected(this.activeModelProvider)) return "";
return `Available models from ${this.providerLabel(this.activeModelProvider)}`;
@@ -967,12 +977,20 @@ export const store = createStore("oauthConfig", {
throw new Error(response?.error || `Could not load ${this.providerLabel(selectedProvider)} models.`);
}
const models = Array.isArray(response.models) ? response.models : [];
+ const metadata = Array.isArray(response.model_metadata) ? response.model_metadata : [];
+ const metadataMap = metadata.reduce((result, item) => {
+ const key = String(item?.slug || item?.id || "");
+ if (key) result[key] = item;
+ return result;
+ }, {});
this.providerModels = { ...this.providerModels, [selectedProvider]: models };
+ this.providerModelMetadata = { ...this.providerModelMetadata, [selectedProvider]: metadataMap };
this.models = models;
if (openDropdown) this.openModelDropdown(openDropdown);
if (!silent) void toastFrontendSuccess(`${this.providerLabel(selectedProvider)} models loaded.`, "OAuth Connections");
} catch (error) {
this.providerModels = { ...this.providerModels, [selectedProvider]: [] };
+ this.providerModelMetadata = { ...this.providerModelMetadata, [selectedProvider]: {} };
this.models = [];
if (!silent) void toastFrontendError(messageOf(error), "OAuth Connections");
} finally {
@@ -999,6 +1017,9 @@ export const store = createStore("oauthConfig", {
const providerModels = { ...this.providerModels };
delete providerModels[providerId];
this.providerModels = providerModels;
+ const providerModelMetadata = { ...this.providerModelMetadata };
+ delete providerModelMetadata[providerId];
+ this.providerModelMetadata = providerModelMetadata;
if (this.activeModelProvider === providerId) this.models = [];
this.clearProviderDevice(providerId);
if (this.connectingProvider === providerId) this.connectingProvider = "";
diff --git a/plugins/_orchestrator/.gitignore b/plugins/_orchestrator/.gitignore
new file mode 100644
index 000000000..c431750d4
--- /dev/null
+++ b/plugins/_orchestrator/.gitignore
@@ -0,0 +1,5 @@
+config.json
+data/
+__pycache__/
+*.py[cod]
+.pytest_cache/
diff --git a/plugins/_orchestrator/AGENTS.md b/plugins/_orchestrator/AGENTS.md
new file mode 100644
index 000000000..7459089f0
--- /dev/null
+++ b/plugins/_orchestrator/AGENTS.md
@@ -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//` 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. |
diff --git a/plugins/_orchestrator/LICENSE b/plugins/_orchestrator/LICENSE
new file mode 100644
index 000000000..fdd5c49f3
--- /dev/null
+++ b/plugins/_orchestrator/LICENSE
@@ -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.
diff --git a/plugins/_orchestrator/README.md b/plugins/_orchestrator/README.md
new file mode 100644
index 000000000..4e1059c08
--- /dev/null
+++ b/plugins/_orchestrator/README.md
@@ -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/.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/.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.
diff --git a/plugins/_orchestrator/api/AGENTS.md b/plugins/_orchestrator/api/AGENTS.md
new file mode 100644
index 000000000..286103533
--- /dev/null
+++ b/plugins/_orchestrator/api/AGENTS.md
@@ -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.
diff --git a/plugins/_orchestrator/api/__init__.py b/plugins/_orchestrator/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plugins/_orchestrator/api/disconnect.py b/plugins/_orchestrator/api/disconnect.py
new file mode 100644
index 000000000..c8c0f14be
--- /dev/null
+++ b/plugins/_orchestrator/api/disconnect.py
@@ -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)}
diff --git a/plugins/_orchestrator/api/poll_device_login.py b/plugins/_orchestrator/api/poll_device_login.py
new file mode 100644
index 000000000..9136fd1e2
--- /dev/null
+++ b/plugins/_orchestrator/api/poll_device_login.py
@@ -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)}
diff --git a/plugins/_orchestrator/api/start_device_login.py b/plugins/_orchestrator/api/start_device_login.py
new file mode 100644
index 000000000..c0f1767e3
--- /dev/null
+++ b/plugins/_orchestrator/api/start_device_login.py
@@ -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)}
diff --git a/plugins/_orchestrator/api/status.py b/plugins/_orchestrator/api/status.py
new file mode 100644
index 000000000..5603e6d15
--- /dev/null
+++ b/plugins/_orchestrator/api/status.py
@@ -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}
diff --git a/plugins/_orchestrator/default_config.yaml b/plugins/_orchestrator/default_config.yaml
new file mode 100644
index 000000000..d90d6ecc4
--- /dev/null
+++ b/plugins/_orchestrator/default_config.yaml
@@ -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
diff --git a/plugins/_orchestrator/helpers/AGENTS.md b/plugins/_orchestrator/helpers/AGENTS.md
new file mode 100644
index 000000000..c5cc9d8e1
--- /dev/null
+++ b/plugins/_orchestrator/helpers/AGENTS.md
@@ -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. |
diff --git a/plugins/_orchestrator/helpers/__init__.py b/plugins/_orchestrator/helpers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plugins/_orchestrator/helpers/adapters/AGENTS.md b/plugins/_orchestrator/helpers/adapters/AGENTS.md
new file mode 100644
index 000000000..4f852e401
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/AGENTS.md
@@ -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.
diff --git a/plugins/_orchestrator/helpers/adapters/__init__.py b/plugins/_orchestrator/helpers/adapters/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plugins/_orchestrator/helpers/adapters/a0.py b/plugins/_orchestrator/helpers/adapters/a0.py
new file mode 100644
index 000000000..b1a46f8ae
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/a0.py
@@ -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
diff --git a/plugins/_orchestrator/helpers/adapters/base.py b/plugins/_orchestrator/helpers/adapters/base.py
new file mode 100644
index 000000000..e6e94e9b7
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/base.py
@@ -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.")
diff --git a/plugins/_orchestrator/helpers/adapters/claude.py b/plugins/_orchestrator/helpers/adapters/claude.py
new file mode 100644
index 000000000..987faebfb
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/claude.py
@@ -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."}
diff --git a/plugins/_orchestrator/helpers/adapters/codex.py b/plugins/_orchestrator/helpers/adapters/codex.py
new file mode 100644
index 000000000..c65adf516
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/codex.py
@@ -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")
diff --git a/plugins/_orchestrator/helpers/adapters/cursor.py b/plugins/_orchestrator/helpers/adapters/cursor.py
new file mode 100644
index 000000000..cb7e502bf
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/cursor.py
@@ -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
diff --git a/plugins/_orchestrator/helpers/adapters/grok.py b/plugins/_orchestrator/helpers/adapters/grok.py
new file mode 100644
index 000000000..2e3ead1bc
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/grok.py
@@ -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
diff --git a/plugins/_orchestrator/helpers/adapters/hermes.py b/plugins/_orchestrator/helpers/adapters/hermes.py
new file mode 100644
index 000000000..7946fee63
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/hermes.py
@@ -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
diff --git a/plugins/_orchestrator/helpers/adapters/opencode.py b/plugins/_orchestrator/helpers/adapters/opencode.py
new file mode 100644
index 000000000..ec140dee6
--- /dev/null
+++ b/plugins/_orchestrator/helpers/adapters/opencode.py
@@ -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
diff --git a/plugins/_orchestrator/helpers/registry.py b/plugins/_orchestrator/helpers/registry.py
new file mode 100644
index 000000000..b13865f72
--- /dev/null
+++ b/plugins/_orchestrator/helpers/registry.py
@@ -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 {}
diff --git a/plugins/_orchestrator/plugin.yaml b/plugins/_orchestrator/plugin.yaml
new file mode 100644
index 000000000..bece5adfd
--- /dev/null
+++ b/plugins/_orchestrator/plugin.yaml
@@ -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
diff --git a/plugins/_orchestrator/skills/AGENTS.md b/plugins/_orchestrator/skills/AGENTS.md
new file mode 100644
index 000000000..69fc91e3b
--- /dev/null
+++ b/plugins/_orchestrator/skills/AGENTS.md
@@ -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. |
diff --git a/plugins/_orchestrator/skills/orchestrator/AGENTS.md b/plugins/_orchestrator/skills/orchestrator/AGENTS.md
new file mode 100644
index 000000000..ae0acba5b
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/AGENTS.md
@@ -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/.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. |
diff --git a/plugins/_orchestrator/skills/orchestrator/SKILL.md b/plugins/_orchestrator/skills/orchestrator/SKILL.md
new file mode 100644
index 000000000..87ae72461
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/SKILL.md
@@ -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 through A0 CLI, or the 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 .
+```
+
+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.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/AGENTS.md b/plugins/_orchestrator/skills/orchestrator/references/AGENTS.md
new file mode 100644
index 000000000..6825f7bbe
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/AGENTS.md
@@ -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.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/a0.md b/plugins/_orchestrator/skills/orchestrator/references/a0.md
new file mode 100644
index 000000000..89b080ad6
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/a0.md
@@ -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.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/claude.md b/plugins/_orchestrator/skills/orchestrator/references/claude.md
new file mode 100644
index 000000000..82937e54b
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/claude.md
@@ -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 ""`. 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`.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/codex.md b/plugins/_orchestrator/skills/orchestrator/references/codex.md
new file mode 100644
index 000000000..6bec3cffa
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/codex.md
@@ -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"`.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/cursor.md b/plugins/_orchestrator/skills/orchestrator/references/cursor.md
new file mode 100644
index 000000000..9b07ba5da
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/cursor.md
@@ -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.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/grok.md b/plugins/_orchestrator/skills/orchestrator/references/grok.md
new file mode 100644
index 000000000..5280ab494
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/grok.md
@@ -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.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/hermes.md b/plugins/_orchestrator/skills/orchestrator/references/hermes.md
new file mode 100644
index 000000000..4e210c95d
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/hermes.md
@@ -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.
diff --git a/plugins/_orchestrator/skills/orchestrator/references/opencode.md b/plugins/_orchestrator/skills/orchestrator/references/opencode.md
new file mode 100644
index 000000000..867080916
--- /dev/null
+++ b/plugins/_orchestrator/skills/orchestrator/references/opencode.md
@@ -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.
diff --git a/plugins/_orchestrator/tests/AGENTS.md b/plugins/_orchestrator/tests/AGENTS.md
new file mode 100644
index 000000000..578db4659
--- /dev/null
+++ b/plugins/_orchestrator/tests/AGENTS.md
@@ -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.
diff --git a/plugins/_orchestrator/tests/test_status_adapters.py b/plugins/_orchestrator/tests/test_status_adapters.py
new file mode 100644
index 000000000..7424626f2
--- /dev/null
+++ b/plugins/_orchestrator/tests/test_status_adapters.py
@@ -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 " 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()
diff --git a/plugins/_orchestrator/thumbnail.png b/plugins/_orchestrator/thumbnail.png
new file mode 100644
index 000000000..2647a1bf1
Binary files /dev/null and b/plugins/_orchestrator/thumbnail.png differ
diff --git a/plugins/_orchestrator/webui/AGENTS.md b/plugins/_orchestrator/webui/AGENTS.md
new file mode 100644
index 000000000..511b836fe
--- /dev/null
+++ b/plugins/_orchestrator/webui/AGENTS.md
@@ -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.
diff --git a/plugins/_orchestrator/webui/config.html b/plugins/_orchestrator/webui/config.html
new file mode 100644
index 000000000..3e4225151
--- /dev/null
+++ b/plugins/_orchestrator/webui/config.html
@@ -0,0 +1,504 @@
+
+
+ Orchestrator
+
+
+
+
+
+
+
+
+
+
Orchestrator
+
+ Command defaults and advanced settings for the orchestrator skill.
+
+
+ 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.”
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ login
+ Connect
+
+
+
+
+
+ expand_more
+
+
+
+
+
+
+
+
Binary
+
Command name or absolute path.
+
+
+
+
+
+
+
+
+
Auth
+
Detected connection source.
+
+
+
+
+
+
+
+
+
Model
+
Optional CLI model override.
+
+
+
+
+
+
+
+
+
Provider
+
Optional Hermes provider override.
+
+
+
+
+
+
+
+
+
Agent
+
Optional OpenCode agent name.
+
+
+
+
+
+
+
+
+
Bypass sandbox
+
Needed in this Docker runtime.
+
+
+
+
+
+
+
+
+
Skip approvals
+
Pass --yolo for non-interactive runs.
+
+
+
+
+
+
+
+
+
Force changes
+
Pass --force so scripts can modify files.
+
+
+
+
+
+
+
+
+
Always approve
+
Pass --always-approve for headless runs.
+
+
+
+
+
+
+
+
+
No auto update
+
Pass --no-auto-update in automation.
+
+
+
+
+
+
+
+
+
Auto approve
+
Pass --auto for non-interactive runs.
+
+
+
+
+
+
+
+
+
Host
+
Empty uses the local Agent Zero runtime.
+
+
+
+
+
+
+
+
+
Permission mode
+
Claude Code permission preset.
+
+
+
+
+
+
+
+
+
Allowed tools
+
Passed to --allowedTools.
+
+
+
+
+
+
+
+
+
Bare mode
+
Skip local Claude Code project discovery.
+
+
+
+
+
+
+
+
+
Toolsets
+
Comma-separated Hermes toolsets.
+
+
+
+
+
+
+
+
+
+
+
+ 1. Open
+
+
+
+ 2. Enter code:
+
+
+
+ Waiting for confirmation…
+ Cancel
+
+
+
+
+
+
+
+
+ No agents registered.
+
+
+
+
+
+
+
diff --git a/plugins/_orchestrator/webui/orchestrator-store.js b/plugins/_orchestrator/webui/orchestrator-store.js
new file mode 100644
index 000000000..7761cd2a2
--- /dev/null
+++ b/plugins/_orchestrator/webui/orchestrator-store.js
@@ -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 = "";
+ }
+ },
+});
diff --git a/plugins/_skills/AGENTS.md b/plugins/_skills/AGENTS.md
index ebb1af367..eaddb8014 100644
--- a/plugins/_skills/AGENTS.md
+++ b/plugins/_skills/AGENTS.md
@@ -2,25 +2,25 @@
## Purpose
-- Own active and hidden skill configuration injected into prompt protocol on each turn.
+- Own current-chat skill loading and hidden skill configuration.
## Ownership
-- `hooks.py` owns skill prompt injection and plugin lifecycle behavior.
-- `api/skills_catalog.py` owns skill catalog access.
-- `prompts/agent.system.active_skills.md` owns injected active-skill prompt content.
+- `hooks.py` owns skill config normalization.
+- `api/skills_catalog.py` owns skill catalog access and loading selected skills into chat history.
- `webui/` owns skill settings UI and store.
- `default_config.yaml`, `plugin.yaml`, `README.md`, and `LICENSE` own defaults, metadata, docs, and license.
## Local Contracts
-- Keep active skill lists bounded by configured caps.
+- Skills selected in `webui/` load into the current chat history only; do not store them as scope defaults.
+- Loaded skills are append-only from the user UI because their instructions live in chat history.
- Store configured skills in normalized portable paths.
-- Hidden skills affect catalog/search/load visibility but must not be injected as active prompt content.
+- Hidden skills affect catalog/search/load visibility but must not remove loaded skill history.
## Work Guidance
-- Coordinate active-skill resolution changes with core skill loading and settings UI.
+- Coordinate skill loading changes with `skills_tool`, loaded-skill history reattachment, and settings UI.
## Verification
diff --git a/plugins/_skills/README.md b/plugins/_skills/README.md
index 4d27c4f96..66ecbab2a 100644
--- a/plugins/_skills/README.md
+++ b/plugins/_skills/README.md
@@ -1,29 +1,28 @@
# Skills
-Skills is a built-in Agent Zero plugin that manages active skills across scope defaults and the current chat.
+Skills is a built-in Agent Zero plugin that manages skill loading and visibility for the current chat.
## What It Does
-- pins default skills for the current plugin scope
+- loads selected skills into current-chat history
- hides noisy skills from the model-facing available catalog, skill search, and load access
-- injects the effective active skills into prompt protocol on every turn
-- extends the same config screen with a current-chat mode so users can activate or hide skills live per conversation
+- shows loaded skills without offering removal, because loaded skill bodies are part of chat history
+- lets users hide or show skills live per conversation
- supports global and project scoped configurations without agent-profile variants
- links directly to the built-in Skills list
- links directly to the active project's Skills section when a project is active
## Why This Exists
-Agent Zero already supports loading skills dynamically with `skills_tool`, and already has great built-in skill management surfaces. What it did not have was a lightweight way to make a few skills feel "always on" for a specific scope without modifying the core prompt system.
+Agent Zero already supports loading skills dynamically with `skills_tool`, and already has great built-in skill management surfaces. What it did not have was a lightweight way to use that same history-backed skill loading from the Skills screen.
Skills fills that gap as a bundled built-in plugin.
-The shared active-skill state and prompt-resolution logic live in `helpers/skills.py`, and this plugin focuses on configuration, UI, and prompt injection.
+The shared skill discovery and loaded-skill ledger live in `helpers/skills.py`, and this plugin focuses on catalog UI, chat loading, and visibility.
## Notes
-- keep the active list short because every active skill is injected into prompt protocol every turn
-- the default cap is 20 active skills, and it can be raised or lowered in Skills plugin config
-- hidden skills are not capped because they are stored as control data, not injected into the prompt
-- selected skills are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts
-- scope defaults can be hidden or supplemented per chat without creating a new conversation
-- if a configured skill is not visible in the current agent scope, it is skipped quietly instead of breaking the prompt build
+- selected skills are appended to current-chat history using the same metadata shape as `skills_tool`
+- loaded skills are not removable from the UI; future context compaction may reattach their bodies from the loaded-skill ledger
+- hidden skills are stored as control data, not injected into the prompt
+- hidden skill paths are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts
+- if a configured hidden skill is not visible in the current agent scope, it is skipped quietly instead of breaking catalog builds
diff --git a/plugins/_skills/api/skills_catalog.py b/plugins/_skills/api/skills_catalog.py
index 38ec23bb5..104de3332 100644
--- a/plugins/_skills/api/skills_catalog.py
+++ b/plugins/_skills/api/skills_catalog.py
@@ -37,17 +37,36 @@ class SkillsCatalog(ApiHandler):
def _activate(self, input: dict, *, context_id: str) -> dict[str, Any]:
context = self._require_context(context_id)
skill_entry = self._require_skill_entry(input)
- skills.activate_chat_skill(context.get_agent(), skill_entry)
+ agent = context.get_agent()
+ skill = self._resolve_catalog_skill(skill_entry, context=context)
+ skill_name = str(skill.get("name") or skill_entry.get("name") or "").strip()
+ if not skill_name:
+ raise ValueError("Skill name is required")
+
+ skill_path = str(skill.get("path") or skill_entry.get("path") or "").strip()
+ skills.add_loaded_skill_name(agent, skill_name)
+ if not self._visible_skill_loaded(agent, skill_name):
+ content = skills.load_skill_for_agent(skill_name=skill_name, agent=agent)
+ if content.startswith("Error:"):
+ raise ValueError(content)
+ agent.hist_add_tool_result(
+ "skills_tool",
+ content,
+ skill_instructions={
+ "name": skill_name,
+ "path": skill_path,
+ "source": "skills_page:load",
+ "content_included": True,
+ },
+ )
save_tmp_chat(context)
return self._build_state(context_id=context.id)
def _deactivate(self, input: dict, *, context_id: str) -> dict[str, Any]:
- context = self._require_context(context_id)
- skill_entry = self._require_skill_entry(input)
- skills.deactivate_chat_skill(context.get_agent(), skill_entry)
- skills.unload_agent_skill(context.get_agent(), skill_entry)
- save_tmp_chat(context)
- return self._build_state(context_id=context.id)
+ return {
+ "ok": False,
+ "error": "Loaded skills are kept in chat history and cannot be removed.",
+ }
def _hide(self, input: dict, *, context_id: str) -> dict[str, Any]:
context = self._require_context(context_id)
@@ -64,10 +83,10 @@ class SkillsCatalog(ApiHandler):
return self._build_state(context_id=context.id)
def _clear(self, *, context_id: str) -> dict[str, Any]:
- context = self._require_context(context_id)
- skills.clear_chat_skill_overrides(context.get_agent())
- save_tmp_chat(context)
- return self._build_state(context_id=context.id)
+ return {
+ "ok": False,
+ "error": "Loaded skills are kept in chat history and cannot be removed.",
+ }
def _build_state(
self,
@@ -87,26 +106,13 @@ class SkillsCatalog(ApiHandler):
str(skill.get("name") or "").strip().lower(): skill for skill in catalog
}
+ loaded_entries = skills.get_loaded_skill_entries(agent)
scope_entries = skills.get_scope_active_skills(agent)
scope_hidden_entries = skills.get_scope_hidden_skills(agent)
chat_entries = skills.get_chat_active_skills(context)
disabled_entries = skills.get_chat_disabled_skills(context)
visible_entries = skills.get_chat_visible_skills(context)
hidden_entries = skills.get_hidden_skills(agent)
- active_entries = self._merge_entries(
- skills.get_active_skills(agent),
- self._filter_hidden_entries(
- self._get_loaded_skill_entries(agent),
- hidden_entries,
- ),
- )
-
- scope_keys = {
- self._entry_key(entry) for entry in scope_entries if self._entry_key(entry)
- }
- chat_keys = {
- self._entry_key(entry) for entry in chat_entries if self._entry_key(entry)
- }
return {
"ok": True,
@@ -123,15 +129,9 @@ class SkillsCatalog(ApiHandler):
entry,
catalog_by_key,
catalog_by_name,
- state_source=(
- "Pinned + chat"
- if key in scope_keys and key in chat_keys
- else "Pinned default"
- if key in scope_keys
- else "Chat"
- ),
+ state_source="Loaded in chat history",
)
- for entry in active_entries
+ for entry in loaded_entries
if (key := self._entry_key(entry))
],
"scope_skills": [
@@ -139,7 +139,7 @@ class SkillsCatalog(ApiHandler):
entry,
catalog_by_key,
catalog_by_name,
- state_source="Pinned default",
+ state_source="Scope default",
)
for entry in scope_entries
],
@@ -253,11 +253,28 @@ class SkillsCatalog(ApiHandler):
def _entry_key(self, entry: dict[str, Any]) -> str:
return str(entry.get("path") or entry.get("name") or "").strip().lower()
- def _get_loaded_skill_entries(self, agent: Any | None) -> list[dict[str, str]]:
- if not agent:
- return []
+ def _visible_skill_loaded(self, agent: Any, skill_name: str) -> bool:
+ output = getattr(getattr(agent, "history", None), "output", None)
+ if not callable(output):
+ return False
+ return any(
+ skills.skill_instruction_name(message) == skill_name
+ for message in output()
+ )
- return skills.get_loaded_skill_entries(agent)
+ def _resolve_catalog_skill(
+ self,
+ entry: dict[str, Any],
+ *,
+ context: AgentContext,
+ ) -> dict[str, Any]:
+ agent = context.get_agent()
+ project_name = projects.get_context_project_name(context) or ""
+ catalog = skills.list_skill_catalog(project_name=project_name, agent=agent)
+ return next(
+ (item for item in catalog if self._entry_matches_any(entry, [item])),
+ entry,
+ )
def _merge_entries(
self,
diff --git a/plugins/_skills/extensions/python/message_loop_prompts_after/_66_include_active_skills.py b/plugins/_skills/extensions/python/message_loop_prompts_after/_66_include_active_skills.py
deleted file mode 100644
index ea9ced8b5..000000000
--- a/plugins/_skills/extensions/python/message_loop_prompts_after/_66_include_active_skills.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from __future__ import annotations
-
-from helpers import skills
-from agent import LoopData
-from helpers.extension import Extension
-
-
-class IncludeActiveSkills(Extension):
- async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
- if not self.agent:
- return
-
- protocol = loop_data.protocol_persistent
- protocol.pop("active_skills", None)
-
- content = skills.build_active_skills_prompt(self.agent)
- if not content:
- return
-
- protocol["active_skills"] = self.agent.read_prompt(
- "agent.system.active_skills.md",
- skills=content,
- )
diff --git a/plugins/_skills/plugin.yaml b/plugins/_skills/plugin.yaml
index 920d57b6c..f5361e385 100644
--- a/plugins/_skills/plugin.yaml
+++ b/plugins/_skills/plugin.yaml
@@ -1,6 +1,6 @@
name: _skills
title: Skills
-description: Pin skills into prompt protocol on every turn.
+description: Load skills into the current chat history.
version: 1.0.0
always_enabled: true
settings_sections:
diff --git a/plugins/_skills/prompts/agent.system.active_skills.md b/plugins/_skills/prompts/agent.system.active_skills.md
deleted file mode 100644
index a90a62075..000000000
--- a/plugins/_skills/prompts/agent.system.active_skills.md
+++ /dev/null
@@ -1,5 +0,0 @@
-## active skills
-The following skills were manually activated by the User.
-Treat them as already loaded instructions and follow them when relevant.
-
-{{skills}}
diff --git a/plugins/_skills/webui/config-store.js b/plugins/_skills/webui/config-store.js
index 12ce77e69..f90b895fa 100644
--- a/plugins/_skills/webui/config-store.js
+++ b/plugins/_skills/webui/config-store.js
@@ -1,27 +1,9 @@
import * as API from "/js/api.js";
import { store as markdownModalStore } from "/components/modals/markdown/markdown-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
-import {
- toastFrontendError,
- toastFrontendInfo,
-} from "/components/notifications/notification-store.js";
+import { toastFrontendError } from "/components/notifications/notification-store.js";
const CATALOG_API = "/plugins/_skills/skills_catalog";
-const MAX_ACTIVE_SKILLS_FALLBACK = 20;
-
-function normalizeMaxActiveSkills(value) {
- if (typeof value === "boolean") return MAX_ACTIVE_SKILLS_FALLBACK;
-
- const numeric = typeof value === "number"
- ? value
- : Number.parseInt(String(value ?? "").trim(), 10);
-
- if (!Number.isFinite(numeric) || numeric < 1) {
- return MAX_ACTIVE_SKILLS_FALLBACK;
- }
-
- return Math.floor(numeric);
-}
function normalizeEntry(entry) {
if (!entry) return null;
@@ -62,11 +44,8 @@ function entriesMatch(left, right) {
function ensureConfig(config) {
if (!config || typeof config !== "object") return;
- config.max_active_skills = normalizeMaxActiveSkills(config.max_active_skills);
- const activeSkills = Array.isArray(config.active_skills) ? config.active_skills : [];
const hiddenSkills = Array.isArray(config.hidden_skills) ? config.hidden_skills : [];
- config.active_skills = compactEntries(activeSkills, config.max_active_skills);
config.hidden_skills = compactEntries(hiddenSkills);
}
@@ -94,15 +73,13 @@ window.createSkillsConfigModel = (context, config) => ({
mutatingChat: false,
catalog: [],
search: "",
- maxActiveSkills: MAX_ACTIVE_SKILLS_FALLBACK,
selectedSkills: [],
hiddenSkills: [],
chatContextAvailable: false,
initDefaults() {
ensureConfig(config);
- this.maxActiveSkills = config.max_active_skills;
- this.selectedSkills = [...this.activeEntries];
+ this.selectedSkills = [];
this.hiddenSkills = [...this.hiddenEntries];
},
@@ -110,50 +87,11 @@ window.createSkillsConfigModel = (context, config) => ({
return context?.openOptions?.focus === "chat";
},
- get activeEntries() {
- ensureConfig(config);
- return config.active_skills;
- },
-
get hiddenEntries() {
ensureConfig(config);
return config.hidden_skills;
},
- get selectedCount() {
- return this.selectedSkills.length;
- },
-
- async applyMaxActiveSkills(value, { notify = false } = {}) {
- ensureConfig(config);
-
- const nextLimit = normalizeMaxActiveSkills(value);
- const previousLimit = this.maxActiveSkills;
- const previousCount = this.selectedSkills.length;
-
- config.max_active_skills = nextLimit;
- this.maxActiveSkills = nextLimit;
-
- if (previousCount > nextLimit) {
- this._setSelectedSkills(this.selectedSkills.slice(0, nextLimit));
-
- if (notify) {
- await toastFrontendInfo(
- `Trimmed ${previousCount - nextLimit} pinned skill${previousCount - nextLimit === 1 ? "" : "s"} to match the new cap of ${nextLimit}.`,
- "Skills"
- );
- }
- return;
- }
-
- if (notify && previousLimit !== nextLimit) {
- await toastFrontendInfo(
- `Pinned skill cap set to ${nextLimit}.`,
- "Skills"
- );
- }
- },
-
get catalogMap() {
const byKey = new Map();
for (const skill of this.catalog) {
@@ -188,15 +126,13 @@ window.createSkillsConfigModel = (context, config) => ({
},
pinnedSubtitle() {
- return this.isChatMode
- ? "These skills are currently pinned into the prompt for this chat."
- : "These skills are pinned by default in this scope.";
+ return "These skills are loaded into the current chat history.";
},
allSkillsSubtitle() {
return this.isChatMode
- ? "Check a skill to pin it for this chat. Use the eye control to hide or show it in this chat."
- : "Check a skill to pin it by default. Use the eye control to hide or show it in this scope.";
+ ? "Check a skill to load it into this chat. Use the eye control to hide or show it in this chat."
+ : "Check a skill to load it into the current chat. Use the eye control to hide or show it in this scope.";
},
hiddenStateLabel() {
@@ -223,7 +159,7 @@ window.createSkillsConfigModel = (context, config) => ({
},
isPinDisabled(skill) {
- return this.mutatingChat || (!this.isSelected(skill) && this.selectedCount >= this.maxActiveSkills);
+ return this.mutatingChat || !this.chatContextAvailable || this.isSelected(skill);
},
isVisibilityDisabled() {
@@ -260,7 +196,7 @@ window.createSkillsConfigModel = (context, config) => ({
return name ? this.catalogMap.get(name) || null : null;
},
- _setSelectedSkills(entries, { writeConfig = true } = {}) {
+ _setSelectedSkills(entries) {
const normalized = [];
const seen = new Set();
@@ -270,13 +206,9 @@ window.createSkillsConfigModel = (context, config) => ({
if (!item || !key || seen.has(key)) continue;
seen.add(key);
normalized.push(item);
- if (normalized.length >= this.maxActiveSkills) break;
}
this.selectedSkills = normalized;
- if (writeConfig) {
- config.active_skills = compactEntries(normalized, this.maxActiveSkills);
- }
},
_setHiddenSkills(entries, { writeConfig = true } = {}) {
@@ -313,54 +245,28 @@ window.createSkillsConfigModel = (context, config) => ({
},
async togglePinnedSkill(skill, selected) {
- const nextEntries = this.selectedSkills.filter((entry) => !entriesMatch(entry, skill));
-
- if (selected) {
- if (this.selectedCount >= this.maxActiveSkills && !this.isSelected(skill)) {
- await toastFrontendInfo(
- `You can activate at most ${this.maxActiveSkills} skills.`,
- "Skills"
- );
- return;
- }
-
- nextEntries.push({
- name: String(skill.name || "").trim(),
- path: String(skill.path || "").trim(),
- });
+ if (!selected || this.isSelected(skill)) {
+ await this.loadCatalog();
+ return;
}
- this._setSelectedSkills(nextEntries, { writeConfig: !this.isChatMode });
-
- if (this.isChatMode && this.chatContextAvailable) {
- await this.submitChatAction(selected ? "activate" : "deactivate", skill);
+ if (!this.chatContextAvailable) {
+ await toastFrontendError("Open a chat before loading a skill.", "Skills");
+ await this.loadCatalog();
+ return;
}
- },
- async removeEntry(entry) {
- await this.togglePinnedSkill(entry, false);
- },
-
- async clearSelections() {
- const previous = [...this.selectedSkills];
- this._setSelectedSkills([], { writeConfig: !this.isChatMode });
- if (this.isChatMode && this.chatContextAvailable) {
- for (const entry of previous) {
- await this.submitChatAction("deactivate", entry);
- }
- }
+ await this.submitChatAction("activate", skill);
},
applyCatalogState(response) {
this.chatContextAvailable = !!response?.context_available;
const activeFromChat = Array.isArray(response?.active_skills) ? response.active_skills : null;
- const activeFromConfig = this.activeEntries;
const hiddenFromChat = Array.isArray(response?.hidden_skills) ? response.hidden_skills : null;
const hiddenFromConfig = this.hiddenEntries;
this._setSelectedSkills(
- this.isChatMode ? activeFromChat || [] : activeFromConfig,
- { writeConfig: !this.isChatMode },
+ activeFromChat || [],
);
this._setHiddenSkills(
this.isChatMode ? hiddenFromChat || [] : hiddenFromConfig,
@@ -374,7 +280,7 @@ window.createSkillsConfigModel = (context, config) => ({
const response = await API.callJsonApi(CATALOG_API, {
action: "list",
project_name: context.projectName || "",
- context_id: this.isChatMode ? chatsStore.selectedContext?.id || "" : "",
+ context_id: chatsStore.selectedContext?.id || "",
});
if (!response?.ok) {
@@ -382,17 +288,12 @@ window.createSkillsConfigModel = (context, config) => ({
}
this.catalog = Array.isArray(response.skills) ? response.skills : [];
- this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills);
- if (!this.isChatMode) {
- config.max_active_skills = this.maxActiveSkills;
- }
this.applyCatalogState(response);
} catch (error) {
this.catalog = [];
ensureConfig(config);
- this.maxActiveSkills = config.max_active_skills;
this.chatContextAvailable = false;
- this._setSelectedSkills(this.activeEntries);
+ this._setSelectedSkills([]);
this._setHiddenSkills(this.hiddenEntries);
await toastFrontendError(error?.message || "Failed to load skills", "Skills");
} finally {
@@ -422,10 +323,6 @@ window.createSkillsConfigModel = (context, config) => ({
}
this.catalog = Array.isArray(response.skills) ? response.skills : this.catalog;
- this.maxActiveSkills = normalizeMaxActiveSkills(response.max_active_skills);
- if (!this.isChatMode) {
- config.max_active_skills = this.maxActiveSkills;
- }
this.chatContextAvailable = !!response.context_available;
this.applyCatalogState(response);
return true;
diff --git a/plugins/_skills/webui/config.html b/plugins/_skills/webui/config.html
index 7885368ae..6965c0e18 100644
--- a/plugins/_skills/webui/config.html
+++ b/plugins/_skills/webui/config.html
@@ -15,26 +15,6 @@
Skills
-
-
-
-
Pinned skill cap
-
- Maximum number of pinned skills stored in this scope. Higher values inject more instructions into every prompt.
-