Compare commits

...

44 commits
v2.2 ... main

Author SHA1 Message Date
Alessandro
3bb40576af Improve README conversion flow
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Lead with a sharper Linux-computer positioning, desktop hero, and scannable feature table.

Put the Launcher first, collapse alternate install paths, add basic troubleshooting, and move starter prompts above the deep dives.

Trim the mid-README Space Agent detour to a footer note while keeping the safety and documentation sections.
2026-07-09 17:59:27 +02:00
Alessandro
c3f44b2c88 Merge branch 'main' into ready 2026-07-09 17:49:56 +02:00
Alessandro
fa05710da7 Reuse native parallel child logs
Pass each parent-visible child log into direct parallel worker execution so tool before_execution reuses it instead of creating a second generic tool log.

This keeps native badge types such as wait/progress (HDL) intact through execution, updates the parallel helper DOX contract, and adds a regression test for log reuse.
2026-07-09 17:42:41 +02:00
Alessandro
8d79f556c6 Fix parallel child log typing
Resolve tool-specific log objects for all parallel child jobs, removing the code_execution_tool-only special case and using native get_log_object() when available, with generic fallback.

Update parallel_tools docs to reflect the shared logging contract and add coverage for wait/ fallback behavior in parallel-tool tests.
2026-07-09 17:34:48 +02:00
Alessandro
1a6d0eb614 Ignore removed Time Travel workspaces
Drop queued debounced snapshots when the workspace directory is gone before the timer flushes.

This keeps transient delete/rename races from surfacing as Time Travel snapshot errors while preserving normal snapshot failures for existing workspaces.
2026-07-09 17:26:39 +02:00
Alessandro
1bd741ff7f Migrate saved Venice model config
Repair saved _model_config user config and presets so Venice model slots use chat completions with the Venice system prompt disabled. Leave a0_venice slots untouched and add regression coverage for parsing and slot preservation.
2026-07-09 17:19:33 +02:00
Alessandro
84c13dab01 Add built-in goal management plugin
Adds the always-enabled _goal plugin with per-chat goal storage, WebUI goal strip, /goal slash command, and agent-facing goal tools.

Includes command-picker integration, focused plugin tests, and the generated 256x256 thumbnail asset.
2026-07-09 17:09:08 +02:00
Alessandro
9c9a4e00ca Add built-in slash commands plugin
Introduce the _commands plugin with command storage, picker UI, bundled canonical command pack, and legacy migration into the built-in namespace.

Polish the Web UI picker behavior, hide WebUI-only-inappropriate commands from the popover, make /models always open model configuration, and add regression coverage for command CRUD, plugin discovery, migration, canonical names, hidden commands, and picker flows.
2026-07-09 16:00:30 +02:00
Alessandro
3d87998821 Exclude Time Travel history from backups
Skip usr/.time_travel during self-update user-data backups and add the same Time Travel shadow-history exclusion to default BackupService metadata.\n\nAdd focused regressions proving regular user files stay in backup coverage while Time Travel shadow repositories are omitted.
2026-07-09 13:27:03 +02:00
Alessandro
bc96386260 Add External Services plugin settings section
Expose plugins that declare the external settings section from the External Services settings page.

Wire the sidebar item, subsection mount, and settings DOX contract so plugin sections use matching tab IDs.
2026-07-09 13:20:48 +02:00
Alessandro
f64b6490c5 Add bundled Orchestrator plugin
Promote the terminal agent work into the built-in plugins tree as _orchestrator with Orchestrator branding and the orchestrator skill.

Include adapter status APIs, settings UI, per-agent skill references, thumbnail asset, tests, and DOX coverage for the bundled plugin.
2026-07-09 13:18:44 +02:00
Alessandro
f1787b65f5 Fix code execution PTY reset hangs
Start local PTY shells in a new process session and make close() escalate from SIGTERM to SIGKILL when a foreground command refuses to exit. This keeps code_execution_tool reset from blocking indefinitely on stuck terminal processes.\n\nAdd regression coverage for closing a TERM-resistant shell and document the reset lifecycle contract.
2026-07-09 03:29:52 +02:00
Alessandro
5f14435093 Normalize Codex Responses input shape
Ensure the Codex OAuth Responses proxy forwards list-shaped input for empty continuation turns while preserving string prompts as user input items. Add regression coverage for the gpt-5.5 continuation case and document the proxy contract.
2026-07-09 02:41:03 +02:00
Alessandro
a7255d9773 Await cached component module imports
Ensure x-component loading awaits cached dynamic import promises before appending deferred markup, matching first-load behavior and preventing Alpine bindings from running before imported stores register.

Document the loader contract in the WebUI JS DOX notes.
2026-07-08 20:53:56 +02:00
Alessandro
4f80ded531 Fix transient UI layering over modals
Raise notification toasts and Bootstrap tooltips above normal and legacy modal overlays while keeping confirmation dialogs on top.

Remove the duplicate tooltip CSS block and add a focused regression for the layer ordering contract.
2026-07-08 15:37:18 +02:00
Alessandro
6dfe33c493 Fix Codex OAuth request metadata
Send current Codex client metadata and compatibility headers on proxied Responses requests so ChatGPT OAuth calls pass upstream call checks.

Return Codex model slugs as the primary list while preserving model metadata for descriptions in the OAuth settings UI.
2026-07-08 15:08:44 +02:00
Alessandro
eb1326ccd3 Document Agent Zero runtime environment
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Teach the agent prompt about the split framework and task Python runtimes so backend checks use /opt/venv-a0 while user-code tasks use /opt/venv.

Document the WebUI JSON API and CSRF token flow so terminal/API work can use the same session cookies and X-CSRF-Token correctly.
2026-07-08 14:38:43 +02:00
Alessandro
bcf2634000 Add AGENTS.md protocol guidance
Load active-project AGENTS.md path-chain guidance into the protocol area without duplicating the project root instructions.

Move the AGENTS.md protocol wording into a prompt template, reuse existing file/path helpers, and cover direct-path discovery plus prompt assembly with focused tests.
2026-07-08 14:29:15 +02:00
Alessandro
f8b06e0b12 Remove legacy skill prompt cleanup
Delete the empty active-skills prompt extension and its unused prompt template now that selected skills are loaded through chat history instead of protocol. Stop clearing legacy loaded_skills keys from protocol/extras in the loaded-skills hook while keeping history reattachment for compacted skill bodies.
2026-07-08 12:42:43 +02:00
Alessandro
0de0fcec0e Simplify relevant skill recall
Search the raw user message when recalling relevant skills instead of the rendered history wrapper.

Replace the stopword catalog with structural matching: names use normal terms, tags/triggers use long terms or phrase matches, and descriptions require phrase matches.
2026-07-08 12:32:19 +02:00
Alessandro
dea64ddad0 Load skills into chat history
Switch the Skills catalog UI/API from scope-wide prompt pins to current-chat history loading, and remove user-facing removal of loaded skills.

Disable legacy active-skill prompt injection so forgotten scope config no longer inflates new-chat prompts.
2026-07-08 01:52:22 +02:00
Alessandro
5ac4f75a47 Stop WebUI startup scripts from blocking render
Load startup vendor scripts with defer and serve Bootstrap locally so the first WebUI mount no longer depends on a parser-blocking CDN request.

Add a focused regression that rejects undeferred classic startup scripts in index.html and documents the vendor/startup contract.
2026-07-08 01:28:36 +02:00
Alessandro
7aba42d46e Default local providers to chat completions
Force chat-completions transport by default for local and broad OpenAI-compatible chat providers whose Responses API implementations are often missing or unstable.\n\nCovers LM Studio, llama.cpp, Ollama, Ollama Cloud, oMLX, vLLM, and Other OpenAI compatible while leaving embedding provider defaults untouched. Updates provider-default regression coverage for the new chat-only defaults.
2026-07-07 17:01:46 +02:00
Alessandro
ad148f24cc Fix Responses state after history compaction
Clear the active Responses provider continuation when automatic history compression or manual chat compaction rewrites local history, while preserving stored response IDs for cleanup.

Add focused regressions for both compression paths so compacted chats do not keep stale provider-side context.
2026-07-07 16:59:49 +02:00
Alessandro
67895f7b44 Default direct Venice to chat completions
Set the shipped direct Venice chat provider default to use Agent Zero's chat-completions transport while preserving the existing Venice API base and disabled Venice system prompt injection.\n\nAdd a focused regression check that direct venice gets the chat default, a0_venice and embeddings stay untouched, and explicit model kwargs can still override the provider default.
2026-07-07 16:52:27 +02:00
Alessandro
96fd5fc2b2 Ignore browser key handling for local editable targets
Prevent Browser keyboard capture from preventing chat composer typing when focus is in editable fields (including contenteditable).
2026-07-07 15:15:36 +02:00
Alessandro
9bb3cb9469 Render parallel code execution as EXE
Use the code_exe log type for code_execution_tool children launched through parallel so the WebUI reuses the existing code execution renderer.

Add a focused regression test and document the parallel child-log contract.
2026-07-07 13:56:38 +02:00
Alessandro
101d84551d Update launcher README links to v1.2
Some checks failed
Build And Publish Docker Images / plan (push) Has been cancelled
Build And Publish Docker Images / build (push) Has been cancelled
Point the root README's A0 Launcher downloads and release note link at the published v1.2 assets without moving the Agent Zero v2.2 tag.
2026-07-06 15:52:19 +02:00
Alessandro
8871bf5001 Update launcher README links to v1.2
Some checks failed
Build And Publish Docker Images / plan (push) Has been cancelled
Build And Publish Docker Images / build (push) Has been cancelled
Point the root README's A0 Launcher downloads and release note link at the published v1.2 assets without moving the Agent Zero v2.2 tag.
2026-07-06 15:28:57 +02:00
Alessandro
6998789ed3 Normalize MCP stdio command lines
Accept shell-style local MCP command values from the manager before spawning stdio clients, so configs like uvx workspace-mcp resolve to the correct executable and args.

Split collapsed option/value argument lines in the MCP manager, document the input contract, and add a regression for the google_workspace MCP shape.
2026-07-05 20:11:19 +02:00
Alessandro
afa5231a5a Refine BYOB host browser endpoint selection
Reduce Browser Settings host-browser choices to automatic and advertised debug endpoints, with a validated Custom endpoint input and faster inventory refresh.

Update Browser docs and regressions to document the custom endpoint workflow and CLI websocket selection.
2026-07-05 19:59:30 +02:00
Alessandro
c719134537 Document host browser debugging setup
Add Browser Settings guidance for supported Chromium-family remote debugging pages, including Opera and explicit remote-debugging endpoint fallback.

Update BYOB docs and troubleshooting to clarify that browser inventory comes from the connected A0 CLI and requires reconnecting the CLI after authorizing a browser.
2026-07-05 18:59:23 +02:00
Alessandro
7168f0984e Refresh BYOB browser inventory
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Poll connected A0 CLI host-browser metadata while Browser Settings is open so newly authorized endpoints appear without saving or reopening.

Extend Browser Settings labels and remote-debugging guidance for Brave, Opera, Vivaldi, and Chromium-family host browsers with regression coverage.
2026-07-04 19:22:57 +02:00
Alessandro
7298a88fda Add BYOB host browser selection
Add host_browser_selection config normalization, Browser Settings UI choices from CLI-advertised inventory, and browser_selection forwarding through connector browser operations.

Expose host browser ids, labels, and available_browsers through A0 connector metadata and the browser_runtime API, with regression coverage for selection normalization.
2026-07-04 18:33:58 +02:00
Alessandro
d018177927 Add configurable Browser tab scope
Expose Browser settings for separate per-chat tabs versus a shared tab strip, and surface the existing maximum-tabs-per-chat cap. Make viewer WebSocket payloads carry the selected tab scope so the Browser panel can render the right tab list without guessing.

Keep the default scoped per chat, preserve the shared mode as an opt-in fallback, and cover both paths with Browser regression tests.
2026-07-04 16:52:39 +02:00
Alessandro
9bfa3b5c81 Fix Browser viewer input after stream restarts
Notify screencast consumers when a stream is stopped so the WebSocket frame supervisor restarts after viewport-driven screencast resets.

Keep the active canvas as the frame/input surface and avoid replacing live screencasts with click snapshots, preserving hover, click, and wheel feedback.

Tested: conda run -n a0 pytest tests/test_browser_agent_regressions.py -q
2026-07-04 16:21:46 +02:00
Alessandro
153ffcc221 Harden browser frame push completion paths
Wake the WebSocket screencast supervisor when the runtime frame task stops before delivering a consumer frame, and keep fatal frame exits eligible for normal CDP teardown.

Avoid resizing the Browser canvas backing store on every frame when bitmap dimensions are unchanged, preventing unnecessary clears and reallocations.
2026-07-03 11:22:23 +02:00
Alessandro
69fb0fca2a Smooth browser viewer rendering and frame push
Render live browser screencast frames through a canvas/ImageBitmap path while keeping the existing image/data-url path for snapshots and fallback rendering.

Attach WebSocket streams as runtime screencast consumers so delivered frames are pushed to the server loop and acknowledged after emit, removing the per-frame read_screencast_frame runtime round trip.
2026-07-03 11:06:14 +02:00
Alessandro
277bcd0783 Prevent browser frame stretch on resize
Keep the live browser frame proportional while the viewer is being resized by using contain sizing instead of stretching the stale frame to the transient surface shape.

Update the regression guard so the viewer contract pins the proportional render path.
2026-07-03 03:16:11 +02:00
Alessandro
727a840e10 Keep WebUI browser frames on base64 fallback
Disable WebUI binary-frame negotiation until its Socket.IO client reconstructs frame attachments as real Blob, ArrayBuffer, or typed-array values.

Reject placeholder binary payloads instead of wrapping them into broken image blobs, preserving the slim-frame path through base64 frames.

Document the transport constraint and pin the fallback behavior in browser regression coverage.
2026-07-03 02:15:54 +02:00
Alessandro
91b3bc9613 Slim browser viewer frame transport
Negotiate binary and slim screencast frames so updated clients avoid base64-in-JSON and repeated viewer metadata on every frame.

Clamp screencast capture to the subscriber viewport and DPR, lower streaming JPEG quality while preserving snapshot quality, and teach the client to manage binary frame URLs safely.

Document the browser viewer performance plan and update regression coverage for the new transport contract.
2026-07-03 01:49:07 +02:00
Alessandro
686e039d1b Wait for Browser screencast frames
Replace the WebSocket viewer frame pump's idle poll with read_screencast_frame so live screencast delivery follows the producer cadence instead of a 50 ms sleep cycle.

Keep idle-page timeouts inside the stream loop to avoid screencast restart flashes, and update the Browser regression test to pin the blocking read path and timeout handling.
2026-07-03 01:25:56 +02:00
Alessandro
306012eb00 Default desktop text files to Writer
Set LibreOffice Writer as the Desktop MIME default for Markdown and plain text while keeping Agent Zero Editor as the secondary Open With association.

Update the Desktop DOX contract and extend the routing regression test to pin the Writer-first association order.
2026-07-03 01:08:02 +02:00
Alessandro
05481c222a Fix browser URL intent routing
Limit the Browser URL-intent handler to web URL schemes so custom Agent Zero schemes fall through to their owning surfaces.

Document the scheme-ownership contract and cover the a0-editor misroute regression in the desktop/editor routing test.
2026-07-03 01:07:49 +02:00
214 changed files with 12510 additions and 705 deletions

131
README.md
View file

@ -3,56 +3,58 @@
<img src="docs/res/a0-vector-graphics/horizontal_banner.svg" alt="Agent Zero Banner" width="100%"/>
# 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.
[![Website](https://img.shields.io/badge/Website-agent--zero.ai-0A192F?style=for-the-badge&logo=vercel&logoColor=white)](https://agent-zero.ai)
[![Docs](https://img.shields.io/badge/Docs-Read%20the%20guides-1F6FEB?style=for-the-badge&logo=readthedocs&logoColor=white)](./docs/)
[![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/B8KZKNsPpj)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsors-Thank%20you-FF69B4?style=for-the-badge&logo=githubsponsors&logoColor=white)](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)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](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)
</div>
<div align="center">
<a href="https://www.youtube.com/watch?v=k78HX_RA9Q0&t=19s">
<img src="docs/res/thumbnail-install.webp" alt="Agent Zero Installation Guide" width="100%"/>
</a>
<img alt="Agent Zero driving Blender in its built-in XFCE desktop" src="docs/res/usage/webui/agentzero-xfce-computer.gif" width="100%" />
</div>
# 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.
<details>
<summary><strong>Other install paths</strong></summary>
## 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
</details>
## 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
<img alt="Agent Zero driving Blender in its built-in XFCE desktop" src="docs/res/usage/webui/agentzero-xfce-computer.gif" />
<br>
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.
<p align="left">
<a href="https://www.youtube.com/watch?v=CNRHxEZ8yqs"><img src="https://github.com/agent0ai/space-agent/raw/main/.github/thumbnail.webp" alt="Watch Space Agent on YouTube" width="560" /></a>
</p>
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.
<img alt="Time Travel" src="docs/res/time-travel.png" />
@ -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.

View file

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

View file

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

View file

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

View file

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

View file

@ -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` |
![Chrome remote debugging setting](../res/usage/browser/host-browser-remote-debugging-setting.png)
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.
![Chrome remote debugging allow prompt](../res/usage/browser/host-browser-remote-debugging-allow.png)
@ -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

View file

@ -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/..."
```
![Host browser remote debugging setting](../res/usage/browser/host-browser-remote-debugging-setting.png)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 `<img>`/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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@
<div class="browser-meta">
<div class="browser-meta-top">
<div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
<template x-for="browser in $store.browserPage.browsers" :key="$store.browserPage.browserTabKey(browser)">
<template x-for="browser in $store.browserPage.visibleBrowsers()" :key="$store.browserPage.browserTabKey(browser)">
<div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
<button type="button" class="browser-tab" role="tab"
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
@ -187,12 +187,17 @@
<div class="browser-stage" tabindex="0" @click="$el.focus()"
:class="{ 'is-annotating': $store.browserPage.annotating }"
@wheel.prevent="$store.browserPage.handleStageWheel($event)">
<canvas class="browser-frame browser-frame-canvas"
x-show="$store.browserPage.frameCanvasReady"
x-init="$store.browserPage.attachFrameCanvas($el)"
@click="$store.browserPage.sendMouse('click', $event)"
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"></canvas>
<template x-if="$store.browserPage.frameSrc">
<img class="browser-frame" :src="$store.browserPage.frameSrc"
<img class="browser-frame browser-frame-image" :src="$store.browserPage.frameSrc"
@click="$store.browserPage.sendMouse('click', $event)"
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
</template>
<template x-if="$store.browserPage.annotating && $store.browserPage.frameSrc">
<template x-if="$store.browserPage.annotating && $store.browserPage.hasFrame()">
<div class="browser-annotation-layer"
:class="{ 'is-busy': $store.browserPage.annotationBusy }"
@pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
@ -267,7 +272,7 @@
@click="$store.browserPage.sendAnnotationsToChat()">Send now</button>
</div>
</div>
<template x-if="!$store.browserPage.frameSrc && !$store.browserPage.isBusy()">
<template x-if="!$store.browserPage.hasFrame() && !$store.browserPage.isBusy()">
<div class="browser-empty">
<span class="material-symbols-outlined">captive_portal</span>
<button class="btn btn-field" @click="$store.browserPage.command('open')">Open
@ -999,7 +1004,7 @@
height: 100%;
min-width: 0;
min-height: 0;
object-fit: fill;
object-fit: contain;
image-rendering: auto;
user-select: none;
background: #fff;

View file

@ -34,6 +34,10 @@ const ANNOTATION_DOM_LIMIT = 1200;
const ANNOTATION_TRAY_MARGIN = 10;
const BROWSER_VISUAL_SHORTCUT_KEYS = new Set(["a", "c", "insert", "v", "x", "y", "z"]);
const LOCAL_EDITABLE_SELECTOR = "input, textarea, select, [contenteditable]";
const BROWSER_BINARY_FRAME_REQUESTS_ENABLED = false;
const BROWSER_BINARY_PAYLOADS_SUPPORTED = typeof Blob === "function"
&& typeof globalThis.URL?.createObjectURL === "function";
const BROWSER_CANVAS_FRAMES_SUPPORTED = typeof globalThis.createImageBitmap === "function";
function makeViewerToken() {
return globalThis.crypto?.randomUUID?.()
@ -114,6 +118,43 @@ function loadFrameDimensions(src) {
});
}
function frameImageSource(data = {}) {
const image = data?.image;
if (!image) return null;
const mime = data.mime || "image/jpeg";
const isArrayBuffer = typeof ArrayBuffer !== "undefined" && image instanceof ArrayBuffer;
const isView = typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView?.(image);
const isBlob = typeof Blob !== "undefined" && image instanceof Blob;
if (isArrayBuffer || isView || isBlob) {
if (!BROWSER_BINARY_PAYLOADS_SUPPORTED) return null;
const blob = isBlob ? image : new Blob([image], { type: mime });
const src = globalThis.URL.createObjectURL(blob);
return {
src,
blob,
objectUrl: src,
cleanup: () => globalThis.URL.revokeObjectURL(src),
};
}
if (data.encoding === "binary") return null;
if (typeof image !== "string") return null;
return {
src: `data:${mime};base64,${image}`,
objectUrl: "",
cleanup: null,
};
}
async function loadFrameBitmap(src, options = {}) {
if (!BROWSER_CANVAS_FRAMES_SUPPORTED || !src) return null;
try {
const blob = options.blob || await fetch(src).then((response) => response.blob());
return await globalThis.createImageBitmap(blob);
} catch {
return null;
}
}
const model = {
loading: true,
error: "",
@ -124,8 +165,10 @@ const model = {
activeBrowserContextId: "",
address: "",
frameSrc: "",
frameCanvasReady: false,
frameState: null,
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
tabScope: "per_context",
liveScreencastEnabled: true,
annotating: false,
annotationComments: [],
@ -146,9 +189,11 @@ const model = {
_lastFrameDimensions: null,
_pendingFrameSrc: "",
_pendingFrameOptions: null,
_frameObjectUrl: "",
_frameRenderHandle: null,
_frameRenderCancel: null,
_frameRenderSequence: 0,
_frameCanvas: null,
_floatingCleanup: null,
_stageElement: null,
_stageResizeObserver: null,
@ -295,8 +340,10 @@ const model = {
{ timeoutMs: 10000 },
);
const data = firstOk(response);
this.applyTabScope(data);
this.applyBrowserListing(data.browsers || [], data.context_id || "", {
replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers,
});
})();
try {
@ -442,7 +489,8 @@ const model = {
this.setActiveBrowserId(null);
this.address = "";
this.frameState = null;
this.frameSrc = "";
this.clearFrameSrc();
this.clearFrameCanvas();
if (this.contextId) {
await this.connectViewer();
}
@ -754,11 +802,13 @@ const model = {
},
releaseSurfaceBindings() {
this.freezeCanvasFrameToImage();
this._floatingCleanup?.();
this._floatingCleanup = null;
this._stageResizeObserver?.disconnect?.();
this._stageResizeObserver = null;
this._stageElement = null;
this._frameCanvas = null;
},
isCanvasSurfaceVisible(element = null) {
@ -813,7 +863,7 @@ const model = {
this._surfaceMounted = true;
this._surfaceOpenedAt = Date.now();
this._lastViewportKey = "";
if (this.frameSrc && !targetChanged) {
if (this.hasFrame() && !targetChanged) {
this._surfaceSwitching = false;
this.switchingBrowserId = null;
return;
@ -833,13 +883,14 @@ const model = {
resetRenderedFrame() {
this.cancelFrameRender();
this.frameSrc = "";
this.clearFrameSrc();
this.clearFrameCanvas();
this._lastFrameDimensions = null;
this._lastFrameAt = 0;
},
resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null, requestedContextId = "") {
if (!viewport || !this.frameSrc || !this._lastViewport) return;
if (!viewport || !this.hasFrame() || !this._lastViewport) return;
const targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
const targetContextId = this.normalizeContextId(requestedContextId || this.contextIdForBrowserId(targetBrowserId) || this.activeBrowserContextId);
if (!this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, targetBrowserId, targetContextId)) return;
@ -911,10 +962,41 @@ const model = {
return BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
},
normalizeTabScope(value = "") {
return String(value || "").trim().toLowerCase().replace("-", "_") === "shared"
? "shared"
: "per_context";
},
applyTabScope(data = {}) {
if (!data || typeof data !== "object") return;
if (!Object.prototype.hasOwnProperty.call(data, "tab_scope")) return;
this.tabScope = this.normalizeTabScope(data.tab_scope);
},
usesScreencastTransport() {
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
},
supportsBinaryFrames() {
return BROWSER_BINARY_FRAME_REQUESTS_ENABLED && BROWSER_BINARY_PAYLOADS_SUPPORTED;
},
captureDevicePixelRatio() {
const value = Number(globalThis.devicePixelRatio || 1);
if (!Number.isFinite(value) || value <= 1) return 1;
return Math.min(2, value);
},
frameDimensionsFromData(data = null) {
const width = Number(data?.width || 0);
const height = Number(data?.height || 0);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return { width, height };
}
return this.frameDimensionsFromMetadata(data?.metadata);
},
frameDimensionsFromMetadata(metadata = null) {
if (!metadata || typeof metadata !== "object") return null;
const width = Number(metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth || 0);
@ -986,6 +1068,9 @@ const model = {
viewer_id: viewerToken,
create_browser: Boolean(options.createBrowser || options.create_browser),
viewer_transport: this.requestedViewerTransport(),
binary_frames: this.supportsBinaryFrames(),
slim_frames: true,
device_pixel_ratio: this.captureDevicePixelRatio(),
viewport_width: initialViewport?.width,
viewport_height: initialViewport?.height,
},
@ -1007,16 +1092,20 @@ const model = {
return;
}
const data = firstOk(response);
this.applyBrowserListing(data.browsers || [], contextId, { replaceAll: Boolean(data.all_browsers) });
this.applyTabScope(data);
this.applyBrowserListing(data.browsers || [], contextId, {
replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers,
});
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
this.setActiveBrowserId(
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
data.active_browser_context_id || contextId,
);
this.applySnapshot(data.snapshot);
this.connected = true;
this.browserInstallExpected = false;
},
this.connected = true;
this.browserInstallExpected = false;
},
async _bindSocketEvents() {
if (!this._frameOff) {
@ -1026,9 +1115,15 @@ const model = {
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
this.applyTabScope(data);
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
this.applyBrowserListing(data.browsers || [], incomingContextId, { replaceContext: true });
if (Array.isArray(data.browsers)) {
this.applyBrowserListing(data.browsers, incomingContextId, {
replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers,
});
}
if (incomingBrowserId && !this.activeBrowserId) {
this.setActiveBrowserId(incomingBrowserId, incomingContextId);
}
@ -1046,11 +1141,17 @@ const model = {
this.address = data.state.currentUrl;
}
if (data.image) {
const frameImage = frameImageSource(data);
if (!frameImage?.src) return;
const frameBrowserId = incomingBrowserId || this.activeBrowserId;
this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`, {
this.queueFrameRender(frameImage.src, {
browserId: frameBrowserId,
contextId: incomingContextId,
dimensions: this.frameDimensionsFromMetadata(data.metadata),
dimensions: this.frameDimensionsFromData(data),
blob: frameImage.blob,
objectUrl: frameImage.objectUrl,
useCanvas: true,
cleanup: frameImage.cleanup,
onAccepted: () => {
if (
this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
@ -1063,13 +1164,15 @@ const model = {
});
} else if (!data.state) {
this.cancelFrameRender();
this.frameSrc = "";
this.clearFrameSrc();
this.clearFrameCanvas();
}
if (!data.image && !data.state) {
if (!this.activeBrowserId) {
this.setActiveBrowserId(null, "");
this.frameState = null;
this.frameSrc = "";
this.clearFrameSrc();
this.clearFrameCanvas();
}
}
this._lastFrameAt = Date.now();
@ -1077,15 +1180,21 @@ const model = {
await websocket.on("browser_viewer_frame", frameHandler);
this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
}
if (!this._stateOff) {
const stateHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
if (!this._stateOff) {
const stateHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
this.applyTabScope(data);
const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
this.applyBrowserListing(data.browsers || [], commandContextId, { replaceAll: Boolean(data.all_browsers) });
if (Array.isArray(data.browsers)) {
this.applyBrowserListing(data.browsers, commandContextId, {
replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers,
});
}
const command = String(data.command || "").toLowerCase();
const commandBrowserId = this.normalizeBrowserId(data.browser_id);
const result = data.result || {};
@ -1102,23 +1211,40 @@ const model = {
|| this.activeBrowserId
|| this.firstBrowserId(resultContextId)
);
if (
!this.activeBrowserId
|| command === "open"
|| command === "close"
|| this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
) {
this.setActiveBrowserId(preferredBrowserId, resultContextId);
}
this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId, this.activeBrowserContextId));
this.applySnapshot(data.snapshot);
};
const stateBrowserId = this.normalizeBrowserId(data.active_browser_id || data.browser_id || data.state?.id);
if (
stateBrowserId
&& (
!this.activeBrowserId
|| this.sameBrowserTab(stateBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
)
) {
this.setActiveBrowserId(stateBrowserId, commandContextId);
}
if (
!this.activeBrowserId
|| command === "open"
|| command === "close"
|| this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
) {
this.setActiveBrowserId(preferredBrowserId, resultContextId);
}
this.applyActiveFrameState(
resultState
|| data.state
|| this.browserById(this.activeBrowserId, this.activeBrowserContextId)
);
this.applySnapshot(data.snapshot);
};
await websocket.on("browser_viewer_state", stateHandler);
this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
}
},
queueFrameRender(frameSrc, options = {}) {
if (this._pendingFrameSrc) {
this.releasePendingFrame();
}
this._pendingFrameSrc = frameSrc;
this._pendingFrameOptions = options || null;
if (this._frameRenderHandle) return;
@ -1148,22 +1274,44 @@ const model = {
async renderDecodedFrame(frameSrc, options = {}, sequence = 0, surfaceSequence = this._surfaceOpenSequence) {
if (!frameSrc) {
if (sequence === this._frameRenderSequence) {
this.frameSrc = "";
this.clearFrameSrc();
this.clearFrameCanvas();
}
return;
}
const dimensions = options?.dimensions || await loadFrameDimensions(frameSrc);
let bitmap = null;
let dimensions = options?.dimensions || null;
if (options?.useCanvas && this.canUseCanvasFrames()) {
bitmap = await loadFrameBitmap(frameSrc, options);
if (bitmap) {
dimensions ||= { width: bitmap.width || 0, height: bitmap.height || 0 };
}
}
dimensions ||= await loadFrameDimensions(frameSrc);
if (sequence !== this._frameRenderSequence || surfaceSequence !== this._surfaceOpenSequence) {
bitmap?.close?.();
options?.cleanup?.();
return;
}
const viewport = this.currentViewportSize() || this._lastViewport;
if (!this.frameMatchesViewport(dimensions, viewport)) {
this.requestViewportSyncAfterRejectedFrame();
if (!this.shouldAcceptMismatchedFrame(dimensions)) {
bitmap?.close?.();
options?.cleanup?.();
return;
}
}
this.frameSrc = frameSrc;
if (bitmap && this.paintFrameBitmap(bitmap)) {
this.clearFrameSrc();
options?.cleanup?.();
} else {
this.clearFrameCanvas();
this.releaseRenderedFrameUrl(frameSrc);
this.frameSrc = frameSrc;
this._frameObjectUrl = options?.objectUrl || "";
}
bitmap?.close?.();
this._lastFrameDimensions = dimensions;
this._lastFrameAt = Date.now();
options?.onAccepted?.();
@ -1175,7 +1323,7 @@ const model = {
return Boolean(
dimensions?.width
&& dimensions?.height
&& (!this.frameSrc || this._surfaceSwitching || this.isSwitchingBrowser())
&& (!this.hasFrame() || this._surfaceSwitching || this.isSwitchingBrowser())
);
},
@ -1246,7 +1394,7 @@ const model = {
clearRenderedFrameIfViewportChanged() {
const viewport = this.currentViewportSize();
if (!this.frameSrc || !this._lastFrameDimensions || !viewport) return;
if (!this.hasFrame() || !this._lastFrameDimensions || !viewport) return;
if (this.frameMatchesViewport(this._lastFrameDimensions, viewport)) return;
this.cancelFrameRender();
this.resetViewportTracking();
@ -1262,9 +1410,84 @@ const model = {
}
this._frameRenderHandle = null;
this._frameRenderCancel = null;
this.releasePendingFrame();
this._frameRenderSequence += 1;
},
releasePendingFrame() {
this._pendingFrameOptions?.cleanup?.();
this._pendingFrameSrc = "";
this._pendingFrameOptions = null;
this._frameRenderSequence += 1;
},
releaseRenderedFrameUrl(nextSrc = "") {
if (this._frameObjectUrl && this._frameObjectUrl !== nextSrc) {
globalThis.URL?.revokeObjectURL?.(this._frameObjectUrl);
this._frameObjectUrl = "";
}
},
clearFrameSrc() {
this.releaseRenderedFrameUrl("");
this.frameSrc = "";
},
attachFrameCanvas(canvas = null) {
this._frameCanvas = canvas || null;
},
currentFrameCanvas() {
const stageCanvas = this._stageElement?.querySelector?.(".browser-frame-canvas");
if (stageCanvas?.isConnected) return stageCanvas;
if (this._frameCanvas?.isConnected) return this._frameCanvas;
return null;
},
canUseCanvasFrames() {
return Boolean(BROWSER_CANVAS_FRAMES_SUPPORTED && this.currentFrameCanvas()?.getContext);
},
hasFrame() {
return Boolean(this.frameSrc || this.frameCanvasReady);
},
paintFrameBitmap(bitmap) {
const canvas = this.currentFrameCanvas();
if (!canvas || !bitmap?.width || !bitmap?.height) return false;
if (canvas.width !== bitmap.width) canvas.width = bitmap.width;
if (canvas.height !== bitmap.height) canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) return false;
context.drawImage(bitmap, 0, 0);
this.frameCanvasReady = true;
return true;
},
clearFrameCanvas() {
const canvas = this.currentFrameCanvas();
if (canvas?.width && canvas?.height) {
canvas.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
}
this.frameCanvasReady = false;
},
freezeCanvasFrameToImage() {
const canvas = this.currentFrameCanvas();
if (!this.frameCanvasReady || !canvas) return;
try {
this.frameSrc = canvas.toDataURL("image/jpeg", 0.86);
} catch {
this.frameSrc = "";
}
this.clearFrameCanvas();
},
frameElement() {
if (this.frameCanvasReady) {
const canvas = this.currentFrameCanvas();
if (canvas) return canvas;
}
return this._stageElement?.querySelector?.(".browser-frame-image") || null;
},
beginCommand() {
@ -1303,7 +1526,11 @@ const model = {
{ timeoutMs: 20000 },
);
const data = firstOk(response);
this.applyBrowserListing(data.browsers || [], targetContextId, { replaceAll: Boolean(data.all_browsers) });
this.applyTabScope(data);
this.applyBrowserListing(data.browsers || [], targetContextId, {
replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers,
});
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
const result = data.result || {};
const resultContextId = this.normalizeContextId(
@ -1329,7 +1556,8 @@ const model = {
);
if (!this.activeBrowserId) {
this.frameState = null;
this.frameSrc = "";
this.clearFrameSrc();
this.clearFrameCanvas();
}
if (result.state?.currentUrl || result.currentUrl) {
this.address = result.state?.currentUrl || result.currentUrl;
@ -1438,7 +1666,8 @@ const model = {
this.error = "";
this.switchingBrowserId = targetId;
this.cancelFrameRender();
this.frameSrc = "";
this.clearFrameSrc();
this.clearFrameCanvas();
this.frameState = browser || null;
if (!this.addressFocused && browser?.currentUrl) {
this.address = browser.currentUrl;
@ -1546,6 +1775,15 @@ const model = {
return browsers[0] || null;
},
visibleBrowsers() {
const browsers = Array.isArray(this.browsers) ? this.browsers : [];
if (this.tabScope === "shared") return browsers;
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId || this.resolveContextId());
return contextId
? browsers.filter((browser) => this.normalizeContextId(browser?.context_id) === contextId)
: browsers;
},
firstBrowserInContext(contextId = "") {
const normalizedContextId = this.normalizeContextId(contextId);
if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
@ -1643,35 +1881,35 @@ const model = {
}
},
applySnapshot(snapshot = null) {
if (!snapshot?.image) return;
const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
applySnapshot(snapshot = null) {
if (!snapshot?.image) return;
const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
const snapshotContextId = this.normalizeContextId(snapshot.context_id || snapshot.state?.context_id || this.activeBrowserContextId);
if (
if (
snapshotId
&& this.activeBrowserId
&& !this.sameBrowserTab(snapshotId, snapshotContextId, this.activeBrowserId, this.activeBrowserContextId)
) {
return;
}
if (snapshot.state) {
this.applyActiveFrameState(snapshot.state);
}
const frameBrowserId = snapshotId || this.activeBrowserId;
this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
browserId: frameBrowserId,
return;
}
if (snapshot.state) {
this.applyActiveFrameState(snapshot.state);
}
const frameBrowserId = snapshotId || this.activeBrowserId;
this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
browserId: frameBrowserId,
contextId: snapshotContextId,
onAccepted: () => {
if (
onAccepted: () => {
if (
this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
&& this.normalizeContextId(this.activeBrowserContextId) === snapshotContextId
) {
this.switchingBrowserId = null;
}
this._surfaceSwitching = false;
},
});
},
this.switchingBrowserId = null;
}
this._surfaceSwitching = false;
},
});
},
isSwitchingBrowser() {
return Boolean(
@ -1710,8 +1948,8 @@ const model = {
const target = element || event?.currentTarget;
if (!target) return null;
const rect = target.getBoundingClientRect();
const naturalWidth = target.naturalWidth || rect.width;
const naturalHeight = target.naturalHeight || rect.height;
const naturalWidth = target.naturalWidth || target.width || rect.width;
const naturalHeight = target.naturalHeight || target.height || rect.height;
let contentLeft = rect.left;
let contentTop = rect.top;
let contentWidth = rect.width;
@ -1748,6 +1986,7 @@ const model = {
},
handleKeydown(event) {
if (isLocalEditableTarget(event?.target)) return;
const annotateShortcut = event?.key === "." && (event.metaKey || event.ctrlKey) && !event.altKey;
if (annotateShortcut && this._surfaceMounted) {
event.preventDefault();
@ -1862,7 +2101,7 @@ const model = {
},
canAnnotate() {
return Boolean(this.activeBrowserId && this.frameSrc && !this.isBusy());
return Boolean(this.activeBrowserId && this.hasFrame() && !this.isBusy());
},
activeAnnotationUrl() {
@ -2023,8 +2262,7 @@ const model = {
},
stagePointForEvent(event) {
const image = this._stageElement?.querySelector?.(".browser-frame") || null;
return this.pointerCoordinatesFor(event, image);
return this.pointerCoordinatesFor(event, this.frameElement());
},
normalizeAnnotationRect(start = {}, end = {}) {
@ -2425,7 +2663,9 @@ const model = {
const response = await websocket.request("browser_viewer_input", payload, { timeoutMs: 10000 });
const data = firstOk(response);
this.applyActiveFrameState(data.state);
this.applySnapshot(data.snapshot);
if (!this.frameCanvasReady || !this.usesScreencastTransport()) {
this.applySnapshot(data.snapshot);
}
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
}
@ -2437,8 +2677,7 @@ const model = {
async sendWheel(event) {
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
if (!contextId || !this.activeBrowserId || !event) return;
const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
const pointer = this.pointerCoordinatesFor(event, image);
const pointer = this.pointerCoordinatesFor(event, this.frameElement());
if (!pointer) return;
const payload = {
context_id: contextId,
@ -2462,8 +2701,7 @@ const model = {
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
if (!contextId || !this.activeBrowserId) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target?.tagName);
if (editable) return;
if (isLocalEditableTarget(event?.target)) return;
event.preventDefault();
const printable = event.key && event.key.length === 1;
await websocket.emit("browser_viewer_input", {
@ -2565,6 +2803,7 @@ const model = {
this._viewerToken = "";
this.switchingBrowserId = null;
this.viewerTransport = this.requestedViewerTransport();
this.tabScope = "per_context";
this._surfaceMounted = false;
this._surfaceSwitching = false;
this.commandInFlight = false;
@ -2846,8 +3085,21 @@ const model = {
export const store = createStore("browserPage", model);
const WEB_INTENT_SCHEMES = new Set(["http", "https", "file", "about"]);
function isWebUrlIntent(url = "") {
const value = String(url || "").trim();
if (!value) return true;
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
if (!scheme) return true;
return WEB_INTENT_SCHEMES.has(scheme[1].toLowerCase());
}
registerUrlHandler(async (intent = {}) => {
const url = String(intent.url || "").trim();
// Custom schemes such as a0-editor: belong to other surfaces; claiming them
// here would navigate the browser to an unloadable URL.
if (!isWebUrlIntent(url)) return false;
const payload = { url, source: intent.source || "surface-url-intent" };
await openLatestSurface("browser", payload);
return await store.openUrlIntent(url, { source: payload.source });

View file

@ -37,7 +37,7 @@
class="browser-config-field-help"
x-show="$store.browserConfig.config.runtime_backend === 'host_required'"
>
Uses Chrome, Edge, or Chromium on the A0 CLI host. Keep A0 CLI connected; the browser opens when the agent first needs it. For an already-open browser, use chrome://inspect/#remote-debugging and enable "Allow remote debugging for this browser instance".
Uses Chrome, Edge, Brave, Opera, Vivaldi, or Chromium on the A0 CLI host. Keep A0 CLI connected; the browser opens when the agent first needs it. For an already-open browser, use its inspect page, such as chrome://inspect/#remote-debugging or opera://inspect/#remote-debugging, and enable "Allow remote debugging for this browser instance".
</span>
</label>
@ -66,6 +66,50 @@
</span>
</label>
<label
class="browser-config-field"
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
>
<span class="browser-config-field-label">Host browser</span>
<select
:value="$store.browserConfig.hostBrowserSelectValue()"
@focus="$store.browserConfig.loadHostBrowserStatus()"
@click="$store.browserConfig.loadHostBrowserStatus()"
@change="$store.browserConfig.setHostBrowserSelection($event.target.value)"
>
<template x-for="option in $store.browserConfig.hostBrowserOptions()" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<span class="browser-config-field-help">Choose an allowed debug endpoint, or leave Automatic so A0 CLI picks.</span>
</label>
<label
class="browser-config-field"
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent' && $store.browserConfig.showCustomHostBrowserEndpoint()"
>
<span class="browser-config-field-label">Custom endpoint</span>
<input
type="text"
:value="$store.browserConfig.hostBrowserCustomEndpoint"
@focus="$store.browserConfig.loadHostBrowserStatus()"
@input="$store.browserConfig.setCustomHostBrowserEndpoint($event.target.value)"
placeholder="ws://127.0.0.1:9222/devtools/browser/..."
autocomplete="off"
/>
<span class="browser-config-field-help" x-text="$store.browserConfig.customHostBrowserEndpointDiagnostic()"></span>
</label>
<div
class="browser-config-note"
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
>
<span class="material-symbols-outlined">info</span>
<span>
For an already-open browser, Chrome, Edge, Brave, Vivaldi, and Chromium use chrome://inspect/#remote-debugging; Opera uses opera://inspect/#remote-debugging. Enable "Allow remote debugging for this browser instance", then restart or reconnect A0 CLI if the browser does not appear. If needed, launch the browser with --remote-debugging-port=9222 and --user-data-dir=&lt;profile-dir&gt;, or set A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS to the full ws://.../devtools/browser/... endpoint.
</span>
</div>
<div
class="browser-config-warning"
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
@ -112,6 +156,44 @@
/>
</label>
<label class="browser-config-field">
<span class="browser-config-field-label">Browser tabs</span>
<select
x-model="$store.browserConfig.config.browser_tab_scope"
@change="$store.browserConfig.setBrowserTabScope($event.target.value)"
>
<option value="per_context">Separate per chat</option>
<option value="shared">Shared across chats</option>
</select>
<span
class="browser-config-field-help"
x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'"
>
Each chat shows only its own Browser tabs.
</span>
<span
class="browser-config-field-help"
x-show="$store.browserConfig.config.browser_tab_scope === 'shared'"
>
The Browser tab strip shows tabs from every active chat.
</span>
</label>
<label class="browser-config-field">
<span class="browser-config-field-label">Maximum tabs per chat</span>
<input
type="number"
min="1"
max="50"
step="1"
x-model.number="$store.browserConfig.config.max_open_tabs"
@change="$store.browserConfig.normalizeMaxOpenTabs()"
/>
<span class="browser-config-field-help">
New Browser tabs stop opening in a chat after this limit.
</span>
</label>
<label class="browser-config-switch-row">
<span class="browser-config-switch-copy">
<span class="browser-config-field-label">Autofocus active page</span>
@ -242,6 +324,7 @@
}
.browser-config-field input[type="text"],
.browser-config-field input[type="number"],
.browser-config-field select {
width: 100%;
min-height: 36px;

View file

@ -19,6 +19,7 @@
- Backup JSON and transcript artifacts must remain UTF-8 writable when chat content contains malformed Unicode such as lone surrogates.
- Keep generated summaries bounded by configured model and token limits.
- Preserve loaded skill names from `skill_instructions` metadata without copying full skill bodies into compacted summaries.
- After replacing local history, clear the active Responses provider continuation while preserving stored response IDs for cleanup.
- Do not discard original context data unless the compaction flow explicitly owns that behavior.
## Work Guidance

View file

@ -5,7 +5,7 @@ from collections import deque
import models as models_module
from agent import Agent
from helpers import files, tokens
from helpers.history import History, output_text
from helpers.history import History, clear_responses_provider_state, output_text
from helpers.persist_chat import (
export_json_chat,
get_chat_folder_path,
@ -145,6 +145,7 @@ async def run_compaction(
agent.history = History(agent=agent)
agent.history.add_message(ai=True, content=compacted_content)
clear_responses_provider_state(agent)
# Clear subordinate chain
agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)

View file

@ -14,6 +14,7 @@
## Local Contracts
- Keep session concurrency, timeout, streaming, and reset behavior predictable.
- Terminal reset/close must not hang on foreground commands or shells that ignore SIGTERM.
- Explicitly target local versus SSH execution runtimes.
- Do not hardcode secrets, SSH credentials, or local user paths.

View file

@ -1,10 +1,12 @@
import asyncio, os, sys, platform, errno
import asyncio, os, sys, platform, errno, signal
_IS_WIN = platform.system() == "Windows"
if _IS_WIN:
import winpty # pip install pywinpty # type: ignore
import msvcrt
_CLOSE_TIMEOUT_SECONDS = 2
def _reconfigure_stream_errors(stream) -> None:
reconfigure = getattr(stream, "reconfigure", None)
@ -77,15 +79,16 @@ class TTYSession:
# Terminate the process if it exists
if self._proc:
if getattr(self._proc, "returncode", None) is None:
self._signal_process(signal.SIGTERM)
try:
if getattr(self._proc, "returncode", None) is None:
self._proc.terminate()
except ProcessLookupError:
pass
except Exception:
pass
try:
await self._proc.wait()
await asyncio.wait_for(self._proc.wait(), _CLOSE_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
self._signal_process(signal.SIGKILL)
try:
await asyncio.wait_for(self._proc.wait(), _CLOSE_TIMEOUT_SECONDS)
except Exception:
pass
except Exception:
pass
@ -93,6 +96,28 @@ class TTYSession:
self._proc = None
self._pump_task = None
def _signal_process(self, sig):
if self._proc is None:
return
try:
if _IS_WIN:
if sig == signal.SIGKILL:
self._proc.kill()
else:
self._proc.terminate()
return
os.killpg(self._proc.pid, sig)
except ProcessLookupError:
pass
except Exception:
try:
if sig == signal.SIGKILL:
self._proc.kill()
else:
self._proc.terminate()
except Exception:
pass
def _release_pty_master(self):
"""Release the POSIX PTY master exactly once.
@ -166,11 +191,7 @@ class TTYSession:
# Only attempt to kill if the process is still running
if getattr(self._proc, "returncode", None) is None:
try:
self._proc.kill()
except ProcessLookupError:
# Child already gone treat as successfully killed
pass
self._signal_process(signal.SIGKILL)
self._release_pty_master()
async def read(self, timeout=None):
@ -241,6 +262,7 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
cwd=cwd,
env=env,
close_fds=True,
start_new_session=True,
)
os.close(slave)

View file

@ -0,0 +1,45 @@
# Commands Plugin DOX
## Purpose
- Own the built-in slash command manager and chat composer slash picker.
- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
## Ownership
- `plugin.yaml` owns the built-in `_commands` plugin metadata.
- `helpers/commands.py` owns command name sanitization, argument parsing, scope resolution, file persistence, plugin command discovery, and command invocation resolution.
- `api/commands.py` owns the Commands API actions used by the WebUI.
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`.
- `extensions/` owns the chat composer picker and sidebar quick-action entry.
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
## Local Contracts
- The plugin identity is `_commands`; user-created command files live under `usr/plugins/_commands/commands/` or `usr/projects/<project>/.a0proj/plugins/_commands/commands/`.
- Each command is one `.command.yaml` config plus one same-directory `.txt` text template or `.py` script hook.
- Project commands override global commands, global commands override bundled `_commands/commands/` defaults, and bundled defaults override other plugin-distributed commands with the same name.
- Bundled `_commands/commands/` definitions and commands contributed by other plugins are read-only from this manager.
- Bundled command files use canonical command names only; do not ship alias-only built-ins such as `/img` for `/attach`.
- Command configs may set `webui_hidden: true` to stay resolvable but be omitted from the chat composer picker.
- Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
- On startup, `_commands` copies legacy `usr/plugins/commands` command and skill files into `usr/plugins/_commands` without overwriting existing files, copies scoped legacy command folders to `_commands`, and disables the legacy `commands` plugin roots to prevent duplicate WebUI popovers.
- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
## Work Guidance
- Keep the command storage and route namespace aligned with `_commands`.
- Preserve unknown command config keys when editing commands.
- Keep WebUI paths pointed at `/plugins/_commands/...`.
## Verification
- Run `conda run -n a0 pytest plugins/_commands/tests` after backend or command contract changes.
## Child DOX Index
No child DOX files.

21
plugins/_commands/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Commands 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.

146
plugins/_commands/README.md Normal file
View file

@ -0,0 +1,146 @@
# Commands
YAML-configured slash commands for Agent Zero.
This plugin lets you define reusable `/commands` as `.command.yaml` files with either:
- a `.txt` template body
- a `.py` script hook
Commands are managed from the plugin modal and can be inserted directly from the chat composer when the first token starts with `/`.
## Features
- `.command.yaml` config files with command metadata
- Text template commands with `{}` placeholders and parsed args
- Python hook commands with parsed args and optional chat history payload
- Unified parser for positional args, free-form tail, and flags
- Scope-aware command resolution across project and global scopes
- Built-in A0 CLI connector command pack for common session, queue, model, project, browser, and connector status commands
- Slash picker in the chat composer with keyboard navigation and create-on-empty flow
## Command File Model
Each command is defined by one config file plus one content file in the same scope directory.
Set `webui_hidden: true` to keep a command resolvable while omitting it from the chat composer picker.
Example text command:
`scan.command.yaml`
```yaml
name: scan
description: Scan a Git repository.
argument_hint: /scan --git-url https://github.com/org/repo
type: text
template_path: scan.txt
```
`scan.txt`
```txt
Please scan repository: {args.flags.git_url}
Raw input:
{raw}
```
Example python hook command:
`optimize.command.yaml`
```yaml
name: optimize
description: Optimize the current request.
argument_hint: /optimize 30%
type: script
script_path: optimize.py
include_history: true
```
`optimize.py`
```python
def run(payload):
args = payload["arguments"]
pct = args["positional"][0] if args["positional"] else "10%"
return {
"text": f"Optimize this response by {pct}.",
"effects": [],
}
```
## Argument Parsing
The parser supports:
- Positional input: `/scan https://github.com/org/repo`
- Long flags: `/scan --git-url https://github.com/org/repo`
- Long flags with equals: `/scan --git-url=https://github.com/org/repo`
- Short flags and bundles: `/scan -v -q` or `/scan -vq`
Parsed data is available to:
- Text templates via `{}` placeholders:
- `{raw}`
- `{args.positional.0}`
- `{args.flags.git_url}`
- Python scripts via `payload["arguments"]`
## Script Hook Contract
Python hook file must expose:
```python
def run(payload): ...
```
It can return:
- `str` (used as replacement text)
- `dict` with:
- `text: str` (replacement text)
- `effects: list[dict]`
Supported frontend effects:
- `{"type": "replace_input", "text": "..."}`
- `{"type": "append_input", "text": "..."}`
- `{"type": "toast", "level": "info|error|success", "message": "..."}`
- Built-in UI effects for existing WebUI actions such as chat switching, modals, attachments, compaction, queue actions, transcript copy, and toast output
## Scope Resolution
Commands are discovered from these scope folders:
- Project: `usr/projects/<project>/.a0proj/plugins/_commands/commands/`
- Global fallback: `usr/plugins/_commands/commands/`
- Built-in defaults: `plugins/_commands/commands/`
- Other enabled plugins: `plugins/<plugin>/commands/` or `usr/plugins/<plugin>/commands/`
Precedence in the chat picker:
1. Project
2. Global
3. Built-in `_commands`
4. Other plugin-distributed commands
## Legacy Community Plugin Migration
When the built-in `_commands` plugin starts, it migrates files from the older community `commands` plugin namespace:
- Copies `usr/plugins/commands/commands/` into `usr/plugins/_commands/commands/`
- Copies `usr/plugins/commands/skills/` into `usr/plugins/_commands/skills/`
- Copies project and agent scoped `plugins/commands/commands/` folders to matching `plugins/_commands/commands/` folders
- Skips existing destination files
- Disables the legacy `commands` plugin roots so the WebUI does not load two slash-command popovers
## UI Surfaces
- Plugin modal: open the Commands manager from the Plugins dialog
- Sidebar quick action: terminal icon next to the Plugins button
- Chat composer: type `/` at the start of the inline input to browse commands
## Agent Skill
The plugin ships with `commands-create-slash-command`, a plugin-scoped skill that helps Agent Zero create or update command files.

View file

@ -0,0 +1 @@
"""Commands plugin package."""

View file

@ -0,0 +1,165 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._commands.helpers import commands as commands_helper
class Commands(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
action = str(input.get("action", "") or "").strip()
if action == "list_effective":
return self._list_effective(input)
if action == "list_scope":
return self._list_scope(input)
if action == "get":
return self._get(input)
if action == "save":
return self._save(input)
if action == "delete":
return self._delete(input)
if action == "duplicate":
return self._duplicate(input)
if action == "scope_info":
return self._scope_info(input)
if action == "resolve":
return await self._resolve(input)
return Response(status=400, response=f"Unknown action: {action}")
def _list_effective(self, input: dict) -> dict | Response:
context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or ""))
commands, scope = commands_helper.list_effective_commands(
project_name=context_scope["project_name"],
)
commands = [
command
for command in commands
if not command.get("frontmatter_extra", {}).get("webui_hidden")
]
return {
"ok": True,
"commands": commands,
"scope": scope,
}
def _list_scope(self, input: dict) -> dict | Response:
commands, scope = commands_helper.list_scope_commands(
project_name=str(input.get("project_name", "") or ""),
)
return {
"ok": True,
"commands": commands,
"scope": scope,
}
def _get(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
try:
command = commands_helper.get_command(
path,
project_name=str(input.get("project_name", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "command": command}
def _save(self, input: dict) -> dict | Response:
try:
command = commands_helper.save_command(
project_name=str(input.get("project_name", "") or ""),
existing_path=str(input.get("existing_path", "") or ""),
name=str(input.get("name", "") or ""),
description=str(input.get("description", "") or ""),
argument_hint=str(input.get("argument_hint", "") or ""),
command_type=str(input.get("command_type", "text") or "text"),
body=str(input.get("body", "") or ""),
include_history=bool(input.get("include_history", False)),
extra_frontmatter=input.get("extra_frontmatter", {}) or {},
)
except FileExistsError as error:
return Response(status=409, response=str(error))
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "command": command}
def _delete(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
try:
commands_helper.delete_command(
path,
project_name=str(input.get("project_name", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True}
def _duplicate(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
try:
command = commands_helper.duplicate_command(
path,
project_name=str(input.get("project_name", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "command": command}
def _scope_info(self, input: dict) -> dict | Response:
explicit_project = str(input.get("project_name", "") or "")
context_scope = commands_helper.get_context_scope(str(input.get("context_id", "") or ""))
project_name = explicit_project if "project_name" in input else context_scope["project_name"]
scope = commands_helper.get_scope_payload(
project_name=project_name,
ensure_directory=bool(input.get("ensure_directory", False)),
)
return {
"ok": True,
"scope": commands_helper.strip_private_scope(scope),
"context_scope": context_scope,
}
async def _resolve(self, input: dict) -> dict | Response:
path = str(input.get("path", "") or "")
if not path:
return Response(status=400, response="Missing path")
slash_text = str(input.get("slash_text", "") or "")
if not slash_text:
return Response(status=400, response="Missing slash_text")
try:
resolution = await commands_helper.resolve_command_invocation(
path=path,
slash_text=slash_text,
project_name=str(input.get("project_name", "") or ""),
context_id=str(input.get("context_id", "") or ""),
)
except FileNotFoundError:
return Response(status=404, response="Command not found")
except ValueError as error:
return Response(status=400, response=str(error))
return {"ok": True, "resolution": resolution}

View file

@ -0,0 +1,5 @@
name: attach
description: Attach local image file(s) to the next message.
argument_hint: Choose local file(s) from the WebUI picker.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: browser
description: Choose Browser host/container mode and manage host-browser control.
argument_hint: "[host|container|status]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: chat
description: Switch to a chat context by id.
argument_hint: "<context_id>"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,6 @@
name: chats
description: List previous chats (default sorted by last updated). Use --project to filter by active project.
argument_hint: "[--project|--all-projects] [--sort=updated|created|name]"
type: script
script_path: connector_commands.py
webui_hidden: true

View file

@ -0,0 +1,4 @@
name: clear
description: Clear the visible chat log.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: compact
description: Open the connector-backed compaction confirmation flow.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: computer-use
description: Turn local Computer Use on or off.
argument_hint: "[on|off|status]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,305 @@
from __future__ import annotations
from typing import Any
from agent import AgentContext
from helpers import message_queue as mq
from helpers import plugins, projects
from helpers.integration_commands import try_handle_command
from helpers.state_monitor_integration import mark_dirty_for_context
CLI_ONLY = {
"quit": "Quit is an A0 CLI shell command. Close this browser tab or stop the WebUI session when you are done.",
}
def run(payload: dict[str, Any]) -> dict[str, Any]:
invocation = payload.get("invocation") or {}
raw_name = str(invocation.get("command_name") or "").strip().lower()
command = raw_name
raw_args = str(invocation.get("raw_arguments") or "").strip()
arguments = invocation.get("arguments") if isinstance(invocation.get("arguments"), dict) else {}
context_id = str((payload.get("context") or {}).get("context_id") or "").strip()
context = _context(context_id)
if command == "new":
return _effects(_toast("Created a new chat."), {"type": "new_chat"})
if command == "chat":
return _handle_chat(arguments)
if command == "chats":
return _show_markdown("Chats", _chat_list(context, arguments))
if command == "clear":
return _effects(_toast("Visible transcript cleared."), {"type": "clear_transcript"})
if command == "project":
return _handle_project(context, raw_args)
if command == "profile":
return _handle_profile(context, raw_args)
if command == "plugins":
return _effects({"type": "open_modal", "path": "/components/plugins/list/plugin-list.html"})
if command == "compact":
return _effects({"type": "compact_chat"})
if command == "pause":
return _effects(_toast("Pause requested."), {"type": "pause_agent", "paused": True})
if command == "resume":
return _effects(_toast("Resume requested."), {"type": "pause_agent", "paused": False})
if command == "nudge":
return _effects(_toast("Nudge sent."), {"type": "nudge_agent"})
if command == "send":
return _handle_queue(context, ["send"])
if command == "queue":
return _handle_queue(context, list(arguments.get("tokens") or []))
if command == "presets":
return _effects({"type": "open_modal", "path": "/plugins/_model_config/webui/main.html"})
if command == "models":
return _handle_models(context, raw_args)
if command == "browser":
return _handle_browser(context, raw_args)
if command == "attach":
return _effects({"type": "attach_files"})
if command == "computer-use":
return _show_markdown("Computer Use", _computer_use_status(context_id, raw_args))
if command == "copy":
return _effects({"type": "copy_transcript"})
if command == "status":
return _show_markdown("Status", _status(context))
if command in CLI_ONLY:
return _effects(_toast(CLI_ONLY[command], level="info"))
return _effects(_toast(f"Unknown command: /{raw_name or command}", level="error"))
def _context(context_id: str) -> AgentContext | None:
if context_id:
return AgentContext.get(context_id)
return AgentContext.current() or AgentContext.first()
def _require_context(context: AgentContext | None) -> str | None:
if context:
return None
return "Open or create a chat context first."
def _effects(*effects: dict[str, Any]) -> dict[str, Any]:
return {"text": "", "effects": [effect for effect in effects if effect]}
def _toast(message: str, *, level: str = "success") -> dict[str, Any]:
return {"type": "toast", "message": message, "level": level}
def _show_markdown(title: str, content: str) -> dict[str, Any]:
return _effects({"type": "show_markdown", "title": title, "content": content})
def _handle_chat(arguments: dict[str, Any]) -> dict[str, Any]:
selector = str((arguments.get("positional") or [""])[0] or "").strip()
if not selector:
return _effects(_toast("Usage: /chat <context_id>", level="error"))
if not AgentContext.get(selector):
return _effects(_toast(f"Chat context '{selector}' was not found.", level="error"))
return _effects(_toast(f"Switched to {selector}."), {"type": "select_chat", "context_id": selector})
def _handle_project(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
if not raw_args:
return _effects({"type": "open_modal", "path": "/components/projects/project-list.html"})
error = _require_context(context)
if error:
return _effects(_toast(error, level="error"))
return _show_markdown("Project", try_handle_command(context, f"/project {raw_args}") or "")
def _handle_profile(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
if not raw_args:
return _effects({"type": "open_modal", "path": "/components/settings/settings.html"})
error = _require_context(context)
if error:
return _effects(_toast(error, level="error"))
return _show_markdown("Agent Profile", try_handle_command(context, f"/agent {raw_args}") or "")
def _handle_models(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
return _effects({"type": "open_plugin_config", "plugin": "_model_config"})
def _handle_browser(context: AgentContext | None, raw_args: str) -> dict[str, Any]:
args = raw_args.strip().lower().replace("-", "_").split()
action = args[0] if args else ""
if not action:
return _effects({"type": "open_modal", "path": "/plugins/_browser/webui/main.html"})
if action in {"status", "state"}:
return _show_markdown("Browser", _browser_status(context))
if action not in {"host", "container", "docker"}:
return _effects(_toast("Usage: /browser [host|container|status]", level="error"))
project_name = projects.get_context_project_name(context) if context else ""
settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {}
settings["runtime_backend"] = "host_required" if action == "host" else "container"
plugins.save_plugin_config("_browser", project_name or "", "", settings)
if context:
mark_dirty_for_context(context.id, reason="plugins._commands.browser_runtime")
label = "Host browser through A0 CLI" if settings["runtime_backend"] == "host_required" else "Internal Docker browser"
return _effects(_toast(f"Browser runtime set to {label}."))
def _browser_status(context: AgentContext | None) -> str:
project_name = projects.get_context_project_name(context) if context else ""
settings = plugins.get_plugin_config("_browser", project_name=project_name or "", agent_profile="") or {}
runtime = str(settings.get("runtime_backend") or "container")
label = "Host browser through A0 CLI" if runtime == "host_required" else "Internal Docker browser"
return f"Browser runtime: {label}\n\nUse `/browser host` or `/browser container` to switch."
def _handle_queue(context: AgentContext | None, tokens: list[str]) -> dict[str, Any]:
error = _require_context(context)
if error:
return _effects(_toast(error, level="error"))
queue = mq.get_queue(context)
if not tokens:
return _show_markdown("Queue", _queue_summary(queue))
action = str(tokens[0] or "").lower()
if action in {"send", "all", "flush"}:
if not queue:
return _effects(_toast("No queued messages."))
sent_count = mq.send_all_aggregated(context)
mark_dirty_for_context(context.id, reason="plugins._commands.queue_send")
noun = "message" if sent_count == 1 else "messages"
return _effects(_toast(f"Sent {sent_count} queued {noun}."))
if action in {"clear", "delete"} and len(tokens) == 1:
mq.remove(context)
mark_dirty_for_context(context.id, reason="plugins._commands.queue_clear")
return _effects(_toast("Queue cleared."))
if action in {"remove", "rm", "delete"}:
if len(tokens) < 2:
return _effects(_toast("Usage: /queue remove <number|id>", level="error"))
item_id = _queue_selector_to_id(queue, str(tokens[1]))
if not item_id:
return _effects(_toast(f"No queued message matches '{tokens[1]}'.", level="error"))
mq.remove(context, item_id)
mark_dirty_for_context(context.id, reason="plugins._commands.queue_remove")
return _effects(_toast("Queued message removed."))
return _effects(_toast("Usage: /queue [send|clear|remove <number|id>]", level="error"))
def _queue_summary(queue: list[dict[str, Any]]) -> str:
if not queue:
return "No queued messages."
lines = [f"Queued messages ({len(queue)}):"]
for index, item in enumerate(queue, start=1):
text = str(item.get("text") or "").strip() or "(attachment only)"
if len(text) > 100:
text = text[:97].rstrip() + "..."
attachments = item.get("attachments") or []
suffix = f" [{len(attachments)} files]" if attachments else ""
lines.append(f"{index}. {text}{suffix}")
return "\n".join(lines)
def _queue_selector_to_id(queue: list[dict[str, Any]], selector: str) -> str:
value = selector.strip()
if value.isdigit():
index = int(value) - 1
if 0 <= index < len(queue):
return str(queue[index].get("id") or "")
return ""
return value
def _chat_list(context: AgentContext | None, arguments: dict[str, Any]) -> str:
items = list(AgentContext.all())
flags = arguments.get("flags") or {}
active_project_only = bool(flags.get("project") or flags.get("active_project") or flags.get("p"))
sort_by = str(flags.get("sort") or "").lower()
positional = [str(item).lower() for item in (arguments.get("positional") or [])]
if not sort_by:
sort_by = next((item for item in positional if item in {"updated", "created", "name"}), "updated")
if sort_by not in {"updated", "created", "name"}:
return "Usage: /chats [--project|--all-projects] [--sort=updated|created|name]"
if active_project_only and context:
project_name = projects.get_context_project_name(context) or ""
items = [item for item in items if (projects.get_context_project_name(item) or "") == project_name]
def sort_key(item: AgentContext) -> Any:
output = item.output()
if sort_by == "name":
return (item.name or item.id).casefold()
if sort_by == "created":
return str(output.get("created_at") or "")
return str(output.get("last_message") or output.get("created_at") or "")
items = sorted(items, key=sort_key, reverse=sort_by != "name")
if not items:
return "No chats found."
lines = ["| Chat | Context | State |", "| --- | --- | --- |"]
for item in items[:30]:
marker = "current" if context and item.id == context.id else ("running" if item.is_running() else "idle")
lines.append(f"| {_escape_cell(item.name or item.id)} | `{item.id}` | {marker} |")
if len(items) > 30:
lines.append(f"\nShowing 30 of {len(items)} chats.")
return "\n".join(lines)
def _status(context: AgentContext | None) -> str:
error = _require_context(context)
if error:
return error
project_name = projects.get_context_project_name(context) or "none"
profile = getattr(context.agent0.config, "profile", "default") if context.agent0 else "default"
running = "running" if context.is_running() else "idle"
if getattr(context, "paused", False):
running = "paused"
return "\n".join(
[
f"Context: `{context.id}`",
f"State: {running}",
f"Project: {project_name}",
f"Agent profile: {profile}",
f"Queued messages: {len(mq.get_queue(context))}",
]
)
def _computer_use_status(context_id: str, raw_args: str) -> str:
from plugins._a0_connector.helpers import ws_runtime
action = "-".join(part.strip().lower().replace("_", "-") for part in raw_args.split()) or "status"
sids = ws_runtime.remote_tool_sids_for_context(context_id) if context_id else sorted(ws_runtime.connected_sids())
if not sids:
return (
"No A0 CLI is connected to this WebUI session.\n\n"
"Computer Use requires the CLI because the desktop permission prompt and native backend live on the CLI host. "
"Start A0 CLI, connect it to this Agent Zero instance, then run `/computer-use on` in the CLI."
)
if action in {"on", "off", "enable", "disable", "enabled", "disabled", "true", "false", "yes", "no", "1", "0"}:
return (
"Computer Use must be armed from the connected A0 CLI because it controls local desktop permissions.\n\n"
"Run `/computer-use on` or `/computer-use off` in the CLI terminal."
)
lines = ["Connected A0 CLI sessions:"]
for sid in sids:
metadata = ws_runtime.computer_use_metadata_for_sid(sid) or {}
if not metadata:
lines.append(f"- `{sid}`: connected, but not advertising Computer Use metadata.")
continue
state = "enabled" if metadata.get("enabled") else "disabled"
supported = "supported" if metadata.get("supported") else "unsupported"
status = str(metadata.get("status") or "unknown")
detail = str(metadata.get("last_error") or metadata.get("support_reason") or "").strip()
suffix = f" ({detail})" if detail else ""
lines.append(f"- `{sid}`: {state}, {supported}, status: {status}{suffix}")
lines.append("\nUse `/computer-use on|off|status` in the CLI to change local Computer Use.")
return "\n".join(lines)
def _escape_cell(value: str) -> str:
return str(value).replace("|", "\\|")

View file

@ -0,0 +1,4 @@
name: copy
description: Copy the currently visible transcript text to the clipboard.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: models
description: Open Main/Utility model runtime editor.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: new
description: Create a brand-new empty chat context.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: nudge
description: Nudge the current agent run.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: pause
description: Pause the active agent run.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: plugins
description: Open the installed-only Agent Zero plugin toggle view.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: presets
description: Open preset picker with Main/Utility model details.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: profile
description: Pick or set the active Agent Zero Core profile.
argument_hint: "[profile]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: project
description: Open the project menu, or switch directly with /project <name>.
argument_hint: "[name]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,5 @@
name: queue
description: Show, send, clear, or remove queued messages.
argument_hint: "[send|clear|remove <number|id>]"
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: quit
description: Disconnect and exit the CLI.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: resume
description: Resume a paused agent run.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: send
description: Send all queued messages now.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,4 @@
name: status
description: Show this chat's project, model, agent, and queue state.
type: script
script_path: connector_commands.py

View file

@ -0,0 +1,124 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from helpers import cache, files, plugins
from helpers.extension import Extension
from helpers.print_style import PrintStyle
LEGACY_PLUGIN_NAME = "commands"
PLUGIN_NAME = "_commands"
COMMANDS_DIR = "commands"
SKILLS_DIR = "skills"
class LegacyCommandsMigration(Extension):
def execute(self, **kwargs):
result = migrate_legacy_commands()
if result["copied_commands"] or result["copied_skills"] or result["disabled_roots"]:
PrintStyle.info("Migrated legacy commands plugin data:", result)
def migrate_legacy_commands(base_dir: str | Path | None = None) -> dict[str, Any]:
root = Path(base_dir or files.get_abs_path("")).resolve()
result: dict[str, Any] = {
"copied_commands": 0,
"copied_skills": 0,
"disabled_roots": 0,
}
legacy_plugin_dir = root / "usr" / "plugins" / LEGACY_PLUGIN_NAME
if not legacy_plugin_dir.exists() and not _legacy_scoped_plugin_dirs(root):
return result
for legacy_commands_dir in _legacy_command_dirs(root):
target = _replace_plugin_segment(legacy_commands_dir, PLUGIN_NAME)
result["copied_commands"] += _copy_tree_files(legacy_commands_dir, target)
result["copied_skills"] += _copy_tree_files(
legacy_plugin_dir / SKILLS_DIR,
root / "usr" / "plugins" / PLUGIN_NAME / SKILLS_DIR,
)
for legacy_plugin_root in _legacy_plugin_roots(root):
if _disable_legacy_plugin_root(legacy_plugin_root):
result["disabled_roots"] += 1
if result["disabled_roots"]:
_clear_runtime_caches()
return result
def _legacy_command_dirs(root: Path) -> list[Path]:
return [
plugin_root / COMMANDS_DIR
for plugin_root in _legacy_plugin_roots(root)
if (plugin_root / COMMANDS_DIR).is_dir()
]
def _legacy_plugin_roots(root: Path) -> list[Path]:
roots = []
for candidate in [
root / "usr" / "plugins" / LEGACY_PLUGIN_NAME,
*root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
]:
if candidate.exists() and candidate not in roots:
roots.append(candidate)
return roots
def _legacy_scoped_plugin_dirs(root: Path) -> list[Path]:
return [
*root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
*root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
]
def _replace_plugin_segment(path: Path, plugin_name: str) -> Path:
parts = list(path.parts)
for index in range(len(parts) - 1):
if parts[index] == "plugins" and parts[index + 1] == LEGACY_PLUGIN_NAME:
parts[index + 1] = plugin_name
return Path(*parts)
return path
def _copy_tree_files(source: Path, target: Path) -> int:
if not source.is_dir():
return 0
copied = 0
for source_file in source.rglob("*"):
if not source_file.is_file():
continue
relative_path = source_file.relative_to(source)
target_file = target / relative_path
if target_file.exists():
continue
target_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_file, target_file)
copied += 1
return copied
def _disable_legacy_plugin_root(plugin_root: Path) -> bool:
plugin_root.mkdir(parents=True, exist_ok=True)
enabled_file = plugin_root / plugins.ENABLED_FILE_NAME
disabled_file = plugin_root / plugins.DISABLED_FILE_NAME
changed = enabled_file.exists() or not disabled_file.exists()
enabled_file.unlink(missing_ok=True)
disabled_file.write_text("", encoding="utf-8")
return changed
def _clear_runtime_caches() -> None:
cache.clear("*(plugins)*")
cache.clear("*(extensions)*")

View file

@ -0,0 +1,192 @@
<html>
<head>
<script type="module">
import { store } from "/plugins/_commands/webui/commands-slash-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.commandsSlash">
<div class="commands-slash-menu-root" x-create="$store.commandsSlash.onMount()" x-destroy="$store.commandsSlash.cleanup()">
<div class="commands-slash-menu" x-show="$store.commandsSlash.menuVisible" x-transition.opacity.duration.120ms>
<template x-if="$store.commandsSlash.loading">
<div class="commands-slash-loading">
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Loading slash commands...</span>
</div>
</template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0">
<div class="commands-slash-results">
<template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path">
<button type="button"
class="commands-slash-item"
:class="{ active: index === $store.commandsSlash.selectedIndex }"
@mouseenter="$store.commandsSlash.selectedIndex = index"
@mousedown.prevent
@click.prevent="$store.commandsSlash.applySelection(command)">
<div class="commands-slash-item-header">
<div class="commands-slash-item-name">
<span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
</div>
<span class="commands-slash-scope" x-text="command.source_scope_label"></span>
</div>
<div class="commands-slash-item-description" x-text="command.description"></div>
<template x-if="command.argument_hint">
<div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
</template>
</button>
</template>
</div>
</template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0">
<div class="commands-slash-empty">
<div class="commands-slash-empty-copy">
No matching slash commands.
</div>
<button type="button"
class="commands-slash-create"
@mousedown.prevent
@click.prevent="$store.commandsSlash.openCreateCommand()">
<span class="material-symbols-outlined">add</span>
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
</button>
</div>
</template>
</div>
</div>
</template>
</div>
<style>
.commands-slash-menu-root {
position: relative;
z-index: 30;
}
.commands-slash-menu {
margin-bottom: 0.55rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: color-mix(in srgb, var(--color-panel) 96%, var(--color-background));
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.14);
overflow: hidden;
}
.commands-slash-results {
max-height: 18rem;
overflow-y: auto;
}
.commands-slash-item {
display: flex;
flex-direction: column;
gap: 0.32rem;
width: 100%;
padding: var(--spacing-sm) 0.8rem;
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.commands-slash-item:last-child {
border-bottom: 0;
}
.commands-slash-item.active,
.commands-slash-item:hover {
background: color-mix(in srgb, var(--color-highlight) 10%, transparent);
}
.commands-slash-item-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.commands-slash-item-name {
font-weight: 600;
}
.commands-slash-prefix {
color: var(--color-highlight);
}
.commands-slash-scope {
padding: 0.18rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
font-size: 0.72rem;
font-weight: 600;
white-space: nowrap;
}
.commands-slash-item-description {
color: var(--color-text-secondary);
font-size: 0.86rem;
line-height: 1.4;
}
.commands-slash-item-hint {
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.77rem;
opacity: 0.85;
}
.commands-slash-empty,
.commands-slash-loading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 1rem;
}
.commands-slash-empty-copy,
.commands-slash-loading {
color: var(--color-text-secondary);
}
.commands-slash-create {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: transparent;
color: var(--color-text);
cursor: pointer;
font-weight: 600;
white-space: nowrap;
}
.spinning {
animation: commands-slash-spin 1s linear infinite;
}
@keyframes commands-slash-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.commands-slash-empty,
.commands-slash-loading {
flex-direction: column;
align-items: stretch;
}
.commands-slash-create {
justify-content: center;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,9 @@
<div x-data>
<button x-move-after=".config-button#plugins"
class="config-button"
id="commands-plugin"
title="Commands"
@click="import('/plugins/_commands/webui/commands-store.js').then(({ store }) => store.openManager())">
<span class="material-symbols-outlined">terminal</span>
</button>
</div>

View file

@ -0,0 +1 @@
"""Helpers for the commands plugin."""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
name: _commands
title: Commands
description: YAML-configured slash commands with text templates or Python hooks.
version: 0.5.0
settings_sections: []
per_project_config: false
per_agent_config: false
always_enabled: false

View file

@ -0,0 +1,72 @@
---
name: commands-create-slash-command
description: Create or update Agent Zero slash commands for the built-in Commands plugin. Use when the user asks to add, edit, duplicate, or refine a reusable /command backed by YAML config plus text/python content files.
version: 1.0.0
tags: ["commands", "slash-commands", "plugin", "yaml", "python", "templates"]
triggers:
- create slash command
- add slash command
- update slash command
- edit slash command
- commands plugin
---
# Commands Plugin Slash Command Authoring
Use this skill when the user wants a reusable `/command` for Agent Zero's built-in `_commands` plugin.
## Source Of Truth
- Slash commands are file-backed, not database rows.
- Each command uses:
- one config file: `<slug>.command.yaml`
- one content file:
- text template: `<slug>.txt`, or
- python hook: `<slug>.py`
- Required config keys:
- `name`
- `description`
- `type` (`text` or `script`)
- Optional config keys:
- `argument_hint`
- `include_history` (script commands)
- Preserve unknown config keys when editing existing commands.
## Scope Resolution
Choose the target folder from the requested scope:
- Project: `usr/projects/<project>/.a0proj/plugins/_commands/commands/`
- Global fallback: `usr/plugins/_commands/commands/`
If the user does not specify a scope, prefer the active chat scope when it is clear. Otherwise use the global scope.
## File Rules
- Config file format: `<slug>.command.yaml`
- Slash command name should be lowercase and hyphenated, for example `explain-code`
- For text commands, keep the `.txt` template concise and directly reusable
- For script commands, implement `run(payload)` in the `.py` file
- If the command expects trailing input, use `{raw}`, `{args.positional.0}`, or `{args.flags.some_flag}`
Use the bundled templates in `template.command.yaml` and `template.command.txt` when creating a new text command from scratch.
## Editing Workflow
1. Determine scope and final slash command name.
2. Check whether a command file already exists in that scope.
3. If it exists, load the file first and preserve unknown frontmatter keys.
4. Update YAML config and template/script content.
5. Save the file in the correct scope folder.
6. Report:
- the saved config path
- the saved content path
- the slash command name in `/name` form
## Output Contract
After saving, explicitly state the final file path and the exact slash command invocation, for example:
- `Saved config: /a0/usr/plugins/_commands/commands/explain-code.command.yaml`
- `Saved content: /a0/usr/plugins/_commands/commands/explain-code.txt`
- `Invoke with: /explain-code`

View file

@ -0,0 +1,3 @@
Describe the work to perform here.
{raw}

View file

@ -0,0 +1,5 @@
name: example-command
description: Briefly describe what this slash command does.
argument_hint: Optional free-form text after /example-command
type: text
template_path: example-command.txt

View file

@ -0,0 +1,11 @@
# tests/conftest.py
import sys
from pathlib import Path
# Ensure A0 framework root is first in sys.path so that
# `from helpers import ...` resolves to A0's helpers/, not
# the Commands plugin's local helpers/ directory.
a0_root = str(Path(__file__).resolve().parents[3]) # tests/ → _commands → plugins → a0
while a0_root in sys.path:
sys.path.remove(a0_root)
sys.path.insert(0, a0_root)

View file

@ -0,0 +1,367 @@
from __future__ import annotations
import threading
import uuid
from dataclasses import dataclass, field
from pathlib import Path
import sys
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from agent import AgentContext
from helpers import files, projects, skills as skills_helper
from initialize import initialize_agent
from plugins._commands.api.commands import Commands
from plugins._commands.commands import connector_commands
from plugins._commands.helpers import commands as commands_helper
@dataclass
class ScopeFixture:
prefix: str
project_name: str
created_paths: list[str] = field(default_factory=list)
def _track_paths(scope: ScopeFixture, command: dict) -> dict:
for key in ("path", "config_path", "content_path"):
command_path = files.fix_dev_path(command.get(key, ""))
if command_path and command_path not in scope.created_paths:
scope.created_paths.append(command_path)
return command
def _save_command(
scope: ScopeFixture,
*,
project_name: str = "",
name: str,
description: str,
body: str = "",
argument_hint: str = "",
command_type: str = "text",
include_history: bool = False,
extra_frontmatter: dict | None = None,
) -> dict:
command = commands_helper.save_command(
project_name=project_name,
name=name,
description=description,
body=body,
argument_hint=argument_hint,
command_type=command_type,
include_history=include_history,
extra_frontmatter=extra_frontmatter or {},
)
return _track_paths(scope, command)
@pytest.fixture
def scope_fixture() -> ScopeFixture:
suffix = uuid.uuid4().hex[:8]
scope = ScopeFixture(
prefix=f"commands-test-{suffix}",
project_name=f"commands_project_{suffix}",
)
yield scope
for path in reversed(scope.created_paths):
files.delete_file(path)
files.delete_dir(files.get_abs_path("usr", "projects", scope.project_name))
def _new_handler() -> Commands:
app = Flask("commands_plugin_tests")
app.secret_key = "commands-plugin-tests"
return Commands(app, threading.RLock())
def test_command_config_and_template_files_round_trip(
scope_fixture: ScopeFixture,
) -> None:
command = _save_command(
scope_fixture,
name=f"Explain {scope_fixture.prefix}",
description="Explain a code sample clearly.",
body="Explain the sample.\n\n{raw}",
argument_hint="Paste code or describe the module.",
command_type="text",
extra_frontmatter={"category": "analysis", "audience": "team"},
)
config_path = Path(files.fix_dev_path(command["path"]))
content_path = Path(files.fix_dev_path(command["content_path"]))
assert config_path.name == f"explain-{scope_fixture.prefix}.command.yaml"
assert content_path.name == f"explain-{scope_fixture.prefix}.txt"
loaded = commands_helper.get_command(command["path"])
assert loaded["frontmatter_extra"] == {
"category": "analysis",
"audience": "team",
}
config_yaml = files.read_file(str(config_path))
assert "category: analysis" in config_yaml
assert "audience: team" in config_yaml
assert f"name: explain-{scope_fixture.prefix}" in config_yaml
assert "type: text" in config_yaml
template_text = files.read_file(str(content_path))
assert "Explain the sample." in template_text
def test_parse_arguments_and_render_template_support_flags() -> None:
parsed = commands_helper.parse_arguments(
'--git-url=https://github.com/acme/repo "quoted phrase" -v 30%'
)
assert parsed["flags"]["git_url"] == "https://github.com/acme/repo"
assert parsed["flags"]["v"] is True
assert parsed["positional"] == ["quoted phrase", "30%"]
invocation = commands_helper.parse_slash_invocation(
'/optimize 30% --mode fast --git-url=https://github.com/acme/repo'
)
rendered = commands_helper.render_text_template(
"Pct: {args.positional.0}\nMode: {args.flags.mode}\nURL: {args.flags.git_url}\nRaw: {raw}",
invocation,
)
assert rendered == (
"Pct: 30%\n"
"Mode: fast\n"
"URL: https://github.com/acme/repo\n"
"Raw: 30% --mode fast --git-url=https://github.com/acme/repo"
)
appended = commands_helper.render_text_template(
"Summarize this request.",
commands_helper.parse_slash_invocation("/summarize alpha beta"),
)
assert appended == "Summarize this request.\n\nArguments:\nalpha beta"
invalid_invocation = commands_helper.parse_slash_invocation("/?")
assert invalid_invocation["command_name"] == ""
def test_list_effective_commands_project_overrides_global(
scope_fixture: ScopeFixture,
) -> None:
shared_name = f"{scope_fixture.prefix}-shared"
_save_command(
scope_fixture,
name=shared_name,
description="global description",
body="global body",
command_type="text",
)
_save_command(
scope_fixture,
project_name=scope_fixture.project_name,
name=shared_name,
description="project description",
body="project body",
command_type="text",
)
project_commands, _ = commands_helper.list_effective_commands(
scope_fixture.project_name
)
global_commands, _ = commands_helper.list_effective_commands("")
assert {command["name"]: command for command in project_commands}[shared_name][
"description"
] == "project description"
assert {command["name"]: command for command in global_commands}[shared_name][
"description"
] == "global description"
scoped_commands, _ = commands_helper.list_scope_commands(scope_fixture.project_name)
scoped_command = next(
command for command in scoped_commands if command["name"] == shared_name
)
assert scoped_command["override_count"] == 1
assert scoped_command["override_scopes"] == ["Global"]
def test_models_command_always_opens_modal():
result = connector_commands.run(
{
"invocation": {
"command_name": "models",
"raw_arguments": "default",
},
"context": {"context_id": ""},
}
)
assert result == {
"text": "",
"effects": [{"type": "open_plugin_config", "plugin": "_model_config"}],
}
@pytest.mark.asyncio
async def test_commands_api_crud_and_resolve_text_and_script(
scope_fixture: ScopeFixture,
) -> None:
handler = _new_handler()
command_name = f"{scope_fixture.prefix}-context"
context = AgentContext(
config=initialize_agent({}),
set_current=True,
)
context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, scope_fixture.project_name)
try:
saved = await handler.process(
{
"action": "save",
"project_name": scope_fixture.project_name,
"name": command_name,
"description": "context override",
"command_type": "text",
"body": (
"Repo: {args.flags.git_url}\n"
"Mode: {args.flags.mode}\n"
"Raw: {raw}"
),
},
None,
)
assert isinstance(saved, dict)
assert saved["ok"] is True
saved_command = _track_paths(scope_fixture, saved["command"])
loaded = await handler.process(
{
"action": "get",
"project_name": scope_fixture.project_name,
"path": saved_command["path"],
},
None,
)
assert isinstance(loaded, dict)
assert loaded["command"]["description"] == "context override"
resolved_text = await handler.process(
{
"action": "resolve",
"project_name": scope_fixture.project_name,
"path": saved_command["path"],
"slash_text": f"/{command_name} --git-url=https://github.com/acme/repo --mode deep",
"context_id": context.id,
},
None,
)
assert isinstance(resolved_text, dict)
assert resolved_text["ok"] is True
rendered_text = resolved_text["resolution"]["result"]["text"]
assert "Repo: https://github.com/acme/repo" in rendered_text
assert "Mode: deep" in rendered_text
script_saved = await handler.process(
{
"action": "save",
"project_name": scope_fixture.project_name,
"name": f"{command_name}-script",
"description": "script command",
"command_type": "script",
"include_history": True,
"body": (
"def run(payload):\n"
" flags = payload['arguments'].get('flags', {})\n"
" return {\n"
" 'text': f\"Script mode: {flags.get('mode', 'none')}\",\n"
" 'effects': [\n"
" {'type': 'toast', 'level': 'success', 'message': 'Script executed'}\n"
" ],\n"
" }\n"
),
},
None,
)
assert isinstance(script_saved, dict)
assert script_saved["ok"] is True
script_command = _track_paths(scope_fixture, script_saved["command"])
resolved_script = await handler.process(
{
"action": "resolve",
"project_name": scope_fixture.project_name,
"path": script_command["path"],
"slash_text": f"/{command_name}-script --mode turbo",
"context_id": context.id,
},
None,
)
assert isinstance(resolved_script, dict)
assert resolved_script["ok"] is True
assert resolved_script["resolution"]["result"]["text"] == "Script mode: turbo"
assert resolved_script["resolution"]["result"]["effects"] == [
{
"type": "toast",
"level": "success",
"message": "Script executed",
}
]
duplicated = await handler.process(
{
"action": "duplicate",
"project_name": scope_fixture.project_name,
"path": saved_command["path"],
},
None,
)
assert isinstance(duplicated, dict)
assert duplicated["ok"] is True
assert duplicated["command"]["name"].startswith(f"{command_name}-copy")
duplicated_command = _track_paths(scope_fixture, duplicated["command"])
effective_list = await handler.process(
{"action": "list_effective", "context_id": context.id},
None,
)
assert isinstance(effective_list, dict)
effective_by_name = {
command["name"]: command for command in effective_list["commands"]
}
assert effective_by_name[command_name]["description"] == "context override"
assert effective_by_name[command_name]["source_scope_key"] == "project"
scope_info = await handler.process(
{"action": "scope_info", "context_id": context.id},
None,
)
assert isinstance(scope_info, dict)
assert scope_info["scope"]["project_name"] == scope_fixture.project_name
deleted = await handler.process(
{
"action": "delete",
"project_name": scope_fixture.project_name,
"path": duplicated_command["path"],
},
None,
)
assert isinstance(deleted, dict)
assert deleted["ok"] is True
finally:
AgentContext.remove(context.id)
AgentContext.set_current("")
def test_plugin_scoped_skill_is_discoverable() -> None:
skill = skills_helper.find_skill("commands-create-slash-command")
assert skill is not None
assert skill.skill_md_path.as_posix().endswith(
"plugins/_commands/skills/commands-create-slash-command/SKILL.md"
)

View file

@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers import plugins
from plugins._commands.extensions.python.startup_migration._20_migrate_legacy_commands import (
migrate_legacy_commands,
)
def test_migrate_legacy_commands_copies_user_data_and_disables_old_plugin(tmp_path: Path):
legacy_root = tmp_path / "usr" / "plugins" / "commands"
legacy_commands = legacy_root / "commands"
legacy_skills = legacy_root / "skills" / "custom-skill"
project_commands = (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "commands"
/ "commands"
)
new_commands = tmp_path / "usr" / "plugins" / "_commands" / "commands"
legacy_commands.mkdir(parents=True)
legacy_skills.mkdir(parents=True)
project_commands.mkdir(parents=True)
new_commands.mkdir(parents=True)
(legacy_root / "plugin.yaml").write_text("name: commands\n", encoding="utf-8")
(legacy_root / plugins.ENABLED_FILE_NAME).write_text("", encoding="utf-8")
(legacy_commands / "demo.command.yaml").write_text(
"name: demo\ndescription: Demo\ntype: text\ntemplate_path: demo.txt\n",
encoding="utf-8",
)
(legacy_commands / "demo.txt").write_text("legacy demo\n", encoding="utf-8")
(legacy_commands / "keep.command.yaml").write_text(
"name: keep\ndescription: Keep\ntype: text\ntemplate_path: keep.txt\n",
encoding="utf-8",
)
(new_commands / "keep.command.yaml").write_text("existing\n", encoding="utf-8")
(project_commands / "project.command.yaml").write_text(
"name: project\ndescription: Project\ntype: text\ntemplate_path: project.txt\n",
encoding="utf-8",
)
(legacy_skills / "SKILL.md").write_text("---\nname: custom-skill\n---\n", encoding="utf-8")
result = migrate_legacy_commands(tmp_path)
assert result["copied_commands"] == 3
assert result["copied_skills"] == 1
assert result["disabled_roots"] == 2
assert (new_commands / "demo.command.yaml").read_text(encoding="utf-8").startswith(
"name: demo"
)
assert (new_commands / "demo.txt").read_text(encoding="utf-8") == "legacy demo\n"
assert (new_commands / "keep.command.yaml").read_text(encoding="utf-8") == "existing\n"
assert (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "_commands"
/ "commands"
/ "project.command.yaml"
).exists()
assert (
tmp_path
/ "usr"
/ "plugins"
/ "_commands"
/ "skills"
/ "custom-skill"
/ "SKILL.md"
).exists()
assert not (legacy_root / plugins.ENABLED_FILE_NAME).exists()
assert (legacy_root / plugins.DISABLED_FILE_NAME).exists()
assert (
tmp_path
/ "usr"
/ "projects"
/ "demo"
/ ".a0proj"
/ "plugins"
/ "commands"
/ plugins.DISABLED_FILE_NAME
).exists()

View file

@ -0,0 +1,337 @@
from __future__ import annotations
import os
import shutil
import uuid
from pathlib import Path
import sys
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers import cache, files, plugins
from plugins._commands.api.commands import Commands
from plugins._commands.helpers import commands as commands_helper
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _clear_plugin_cache():
"""Ensure plugin list cache is fresh for every test."""
cache.clear("*(plugins)*")
yield
cache.clear("*(plugins)*")
@pytest.fixture()
def fake_plugin():
"""Create a temporary plugin in usr/plugins/ with a commands/ directory."""
suffix = uuid.uuid4().hex[:8]
plugin_name = f"_test_cmd_disc_{suffix}"
plugin_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name)
commands_dir = os.path.join(plugin_dir, "commands")
os.makedirs(commands_dir, exist_ok=True)
# Write plugin.yaml so the plugin is discoverable
files.write_file(
os.path.join(plugin_dir, "plugin.yaml"),
f"name: {plugin_name}\ntitle: Test\ndescription: Test\n",
)
yield {"name": plugin_name, "dir": plugin_dir, "commands_dir": commands_dir}
# Cleanup
shutil.rmtree(plugin_dir, ignore_errors=True)
cache.remove(plugins.PLUGINS_LIST_CACHE_AREA, "")
def _write_plugin_command(
fake_plugin: dict,
*,
name: str,
description: str,
body: str = "default body",
) -> str:
"""Write a .command.yaml + .txt into the fake plugin's commands/ dir.
Returns the config file path.
"""
slug = commands_helper.sanitize_command_name(name)
cdir = fake_plugin["commands_dir"]
config_path = os.path.join(cdir, f"{slug}.command.yaml")
content_path = os.path.join(cdir, f"{slug}.txt")
files.write_file(
config_path,
f"name: {slug}\ndescription: {description}\ntype: text\ntemplate_path: {slug}.txt\n",
)
files.write_file(content_path, body)
return config_path
# ── Tests ─────────────────────────────────────────────────────────────────────
def test_discover_plugin_commands_finds_plugin_commands(fake_plugin: dict):
"""Plugin commands must be discovered without relying on real installs."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-build",
description="build test",
)
discovered = commands_helper._discover_plugin_commands()
names = {c["name"] for c in discovered}
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-build")
assert expected in names, f"Expected {expected!r} in discovered names, got {names}"
for cmd in discovered:
if cmd["name"] == expected:
assert cmd["source_plugin"] == fake_plugin["name"]
assert cmd["scope_key"] == "plugin"
assert cmd["scope_label"] == f"Plugin: {fake_plugin['name']}"
assert cmd["source_scope_key"] == "plugin"
assert cmd["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
break
def test_discover_plugin_commands_skips_own_plugin():
"""The commands plugin itself must NOT appear in _discover_plugin_commands."""
discovered = commands_helper._discover_plugin_commands()
for cmd in discovered:
assert cmd.get("source_plugin") != "_commands"
def test_discover_plugin_commands_skips_disabled_plugins(fake_plugin: dict):
"""Disabled plugins must not contribute slash commands to the picker."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-disabled",
description="disabled command",
)
files.write_file(os.path.join(fake_plugin["dir"], plugins.DISABLED_FILE_NAME), "")
cache.clear("*(plugins)*")
discovered = commands_helper._discover_plugin_commands()
names = {command["name"] for command in discovered}
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-disabled")
assert expected not in names
def test_discover_builtin_commands_marks_own_commands_read_only():
"""Bundled _commands command files are discoverable as built-ins, not plugin commands."""
discovered = commands_helper._discover_builtin_commands()
command = next((cmd for cmd in discovered if cmd["name"] == "new"), None)
assert command is not None
assert command["source_plugin"] == "_commands"
assert command["scope_key"] == "builtin"
assert command["scope_label"] == "Built-in"
loaded = commands_helper.get_command(command["path"])
assert loaded["name"] == "new"
assert loaded["scope_key"] == "builtin"
with pytest.raises(ValueError, match="Built-in commands are read-only"):
commands_helper.save_command(
existing_path=command["path"],
name="new",
description="updated description",
body="updated body",
)
with pytest.raises(ValueError, match="Built-in commands are read-only"):
commands_helper.delete_command(command["path"])
def test_builtin_commands_use_canonical_names_only():
discovered = commands_helper._discover_builtin_commands()
names = {command["name"] for command in discovered}
assert {"attach", "computer-use", "models", "plugins", "project"} <= names
assert {
"computer",
"cu",
"disconnect",
"exit",
"help",
"image",
"img",
"keys",
"model",
"plugin",
"projects",
}.isdisjoint(names)
def test_webui_effective_list_hides_webui_hidden_commands():
effective, _ = commands_helper.list_effective_commands("")
chats = next(command for command in effective if command["name"] == "chats")
response = object.__new__(Commands)._list_effective({"context_id": ""})
names = {command["name"] for command in response["commands"]}
assert chats["frontmatter_extra"]["webui_hidden"] is True
assert "chats" not in names
def test_list_effective_includes_plugin_commands(fake_plugin: dict):
"""list_effective_commands must include commands from other plugins."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-effective",
description="effective test",
)
effective, _ = commands_helper.list_effective_commands("")
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-effective")
command = next((item for item in effective if item["name"] == expected), None)
assert command is not None
assert command["scope_key"] == "plugin"
assert command["scope_label"] == f"Plugin: {fake_plugin['name']}"
def test_plugin_commands_appear_in_effective_list(fake_plugin: dict):
"""Commands from a freshly-created plugin appear in effective list."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-hello",
description="A test command from a plugin",
body="Hello from plugin",
)
effective, _ = commands_helper.list_effective_commands("")
expected = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-hello")
command = next((item for item in effective if item["name"] == expected), None)
assert command is not None
assert command["source_plugin"] == fake_plugin["name"]
def test_precedence_global_overrides_plugin(fake_plugin: dict):
"""A global command with the same name takes precedence over a plugin command."""
shared_name = f"{fake_plugin['name']}-shared"
slug = commands_helper.sanitize_command_name(shared_name)
# 1. Plugin command (lowest precedence)
_write_plugin_command(
fake_plugin,
name=shared_name,
description="plugin version",
body="plugin body",
)
# 2. Global command (higher precedence)
try:
commands_helper.save_command(
name=shared_name,
description="global version",
body="global body",
)
effective, _ = commands_helper.list_effective_commands("")
by_name = {c["name"]: c for c in effective}
assert slug in by_name
assert by_name[slug]["description"] == "global version"
finally:
scope_dir = commands_helper.get_scope_directory("")
files.delete_file(os.path.join(scope_dir, f"{slug}.command.yaml"))
files.delete_file(os.path.join(scope_dir, f"{slug}.txt"))
def test_source_plugin_field_on_discovered_command(fake_plugin: dict):
"""Discovered commands must carry the source_plugin field."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-src-test",
description="source plugin test",
)
discovered = commands_helper._discover_plugin_commands()
slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-src-test")
match = next((c for c in discovered if c["name"] == slug), None)
assert match is not None
assert match["source_plugin"] == fake_plugin["name"]
assert match["scope_key"] == "plugin"
assert match["scope_label"] == f"Plugin: {fake_plugin['name']}"
assert match["source_scope_key"] == "plugin"
assert match["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
def test_is_plugin_commands_dir_recognises_plugin_path(fake_plugin: dict):
"""_is_plugin_commands_dir must return True for files inside plugin commands/ dirs."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-path-check",
description="path test",
)
slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-path-check")
config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml")
normalized = commands_helper._normalize_client_path(config_path)
assert commands_helper._is_plugin_commands_dir(normalized) is True
def test_is_plugin_commands_dir_rejects_non_plugin_path(tmp_path: Path):
"""_is_plugin_commands_dir must return False for arbitrary paths."""
non_plugin_path = tmp_path / "not-a-plugin" / "commands" / "foo.txt"
assert commands_helper._is_plugin_commands_dir(str(non_plugin_path)) is False
def test_get_command_can_load_plugin_command(fake_plugin: dict):
"""get_command must work for commands inside plugin directories."""
_write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-loadable",
description="loadable test",
body="load me",
)
slug = commands_helper.sanitize_command_name(f"{fake_plugin['name']}-loadable")
config_path = os.path.join(fake_plugin["commands_dir"], f"{slug}.command.yaml")
normalized = commands_helper._normalize_client_path(config_path)
command = commands_helper.get_command(normalized, project_name="demo-project")
assert command["name"] == slug
assert command["description"] == "loadable test"
assert command["body"] == "load me"
assert command["source_plugin"] == fake_plugin["name"]
assert command["scope_key"] == "plugin"
assert command["scope_label"] == f"Plugin: {fake_plugin['name']}"
assert command["source_scope_key"] == "plugin"
assert command["source_scope_label"] == f"Plugin: {fake_plugin['name']}"
def test_save_command_rejects_plugin_existing_path(fake_plugin: dict):
"""Editing a plugin command must fail because plugin commands are read-only."""
config_path = _write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-readonly-edit",
description="read-only test",
body="plugin body",
)
normalized = commands_helper._normalize_client_path(config_path)
with pytest.raises(ValueError, match="Plugin commands are read-only"):
commands_helper.save_command(
existing_path=normalized,
name=f"{fake_plugin['name']}-readonly-edit",
description="updated description",
body="updated body",
)
def test_delete_command_rejects_plugin_command(fake_plugin: dict):
"""Deleting a plugin command must fail because plugin commands are read-only."""
config_path = _write_plugin_command(
fake_plugin,
name=f"{fake_plugin['name']}-readonly-delete",
description="read-only delete",
)
normalized = commands_helper._normalize_client_path(config_path)
with pytest.raises(ValueError, match="Plugin commands are read-only"):
commands_helper.delete_command(normalized)
assert os.path.exists(config_path)

View file

@ -0,0 +1,526 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as chatInputStore } from "/components/chat/input/input-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import {
toastFrontendError,
toastFrontendInfo,
toastFrontendSuccess,
} from "/components/notifications/notification-store.js";
import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
const COMMANDS_API_PATH = "/plugins/_commands/commands";
function sanitizeCommandName(rawName) {
return (rawName || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function parseSlashInput(message) {
const text = String(message || "");
const match = text.match(/^\s*\/([^\s]*)(?:\s+([\s\S]*))?$/);
if (!match) {
return {
active: false,
query: "",
rawArguments: "",
rawMessage: text,
};
}
return {
active: true,
query: (match[1] || "").trim().toLowerCase(),
rawArguments: match[2] || "",
rawMessage: text,
};
}
function notifyError(message) {
void toastFrontendError(message, "Commands");
}
function notifySuccess(message) {
void toastFrontendSuccess(message, "Commands");
}
const HTML_ESCAPE = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value) {
return String(value || "").replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
}
function notifyInfo(title, message) {
const formatted = escapeHtml(message).replace(/\n/g, "<br>");
void toastFrontendInfo(formatted, title || "Commands", 3, "", undefined, true);
}
const model = {
loading: false,
applying: false,
commands: [],
contextScope: { project_name: "" },
lastContextId: "",
active: false,
dismissed: false,
query: "",
rawArguments: "",
rawMessage: "",
selectedIndex: 0,
boundInput: null,
keydownHandler: null,
inputHandler: null,
focusHandler: null,
commandsUpdatedHandler: null,
get menuVisible() {
return this.active && !this.dismissed;
},
get filteredCommands() {
const needle = (this.query || "").trim().toLowerCase();
const commands = Array.isArray(this.commands) ? this.commands : [];
if (!needle) return commands;
return commands.filter((command) => {
const haystack = `${command?.name || ""} ${command?.description || ""}`.toLowerCase();
return haystack.includes(needle);
});
},
get selectedCommand() {
const commands = this.filteredCommands;
if (!commands.length) return null;
return commands[this.selectedIndex] || commands[0] || null;
},
get emptyStateLabel() {
const name = sanitizeCommandName(this.query || "");
return name ? `Create /${name}` : "Create slash command";
},
onMount() {
this.ensureBindings();
this.keydownHandler = (event) => this.handleKeydown(event);
this.commandsUpdatedHandler = () => {
this.commands = [];
if (this.menuVisible) {
void this.loadCommands(true);
}
};
document.addEventListener("keydown", this.keydownHandler, true);
window.addEventListener("commands:updated", this.commandsUpdatedHandler);
this.handleInput();
},
cleanup() {
this.removeBindings();
if (this.keydownHandler) {
document.removeEventListener("keydown", this.keydownHandler, true);
}
if (this.commandsUpdatedHandler) {
window.removeEventListener("commands:updated", this.commandsUpdatedHandler);
}
this.keydownHandler = null;
this.commandsUpdatedHandler = null;
this.dismissed = false;
this.active = false;
this.query = "";
this.rawArguments = "";
this.rawMessage = "";
this.selectedIndex = 0;
this.applying = false;
},
ensureBindings() {
const input = this.getInputElement();
if (!input || input === this.boundInput) return;
this.removeBindings();
this.inputHandler = (event) => this.handleInput(event);
this.focusHandler = () => this.handleInput();
input.addEventListener("input", this.inputHandler);
input.addEventListener("focus", this.focusHandler);
this.boundInput = input;
},
removeBindings() {
if (this.boundInput && this.inputHandler) {
this.boundInput.removeEventListener("input", this.inputHandler);
}
if (this.boundInput && this.focusHandler) {
this.boundInput.removeEventListener("focus", this.focusHandler);
}
this.boundInput = null;
this.inputHandler = null;
this.focusHandler = null;
},
getInputElement() {
return document.getElementById("chat-input");
},
getInputMessage(event = null) {
const target = event?.target || null;
const targetEditor = target?.closest?.("#chat-input");
if (targetEditor?.isContentEditable || target?.isContentEditable) {
return (
chatInputStore?._editorToMarkdown?.() ||
targetEditor?.textContent ||
target?.textContent ||
""
);
}
if (typeof target?.value === "string") return target.value;
const input = this.getInputElement();
if (input?.isContentEditable) {
return chatInputStore?._editorToMarkdown?.() ?? input.textContent ?? "";
}
if (typeof input?.value === "string") return input.value;
return chatInputStore?.message ?? "";
},
getContextId() {
return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
},
async loadCommands(force = false) {
const contextId = this.getContextId();
if (!force && this.commands.length && contextId === this.lastContextId) {
this.ensureSelection();
return;
}
this.loading = true;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "list_effective",
context_id: contextId,
});
this.commands = Array.isArray(response?.commands) ? response.commands : [];
this.contextScope = response?.scope || {
project_name: "",
};
this.lastContextId = contextId;
this.ensureSelection();
} catch (error) {
console.error("Failed to load effective commands:", error);
this.commands = [];
this.contextScope = { project_name: "" };
} finally {
this.loading = false;
}
},
handleInput(event = null) {
this.ensureBindings();
this.dismissed = false;
const message = this.getInputMessage(event);
const parsed = parseSlashInput(message);
this.active = parsed.active;
this.query = parsed.query;
this.rawArguments = parsed.rawArguments;
this.rawMessage = parsed.rawMessage;
if (!this.active) {
this.selectedIndex = 0;
return;
}
this.ensureSelection();
void this.loadCommands();
},
handleKeydown(event) {
const input = this.getInputElement();
if (!this.menuVisible || !input || document.activeElement !== input) return;
if (event.isComposing || event.keyCode === 229) return;
if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
this.moveSelection(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
this.moveSelection(-1);
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.dismissed = true;
return;
}
if (event.key === "Enter" && this.selectedCommand) {
event.preventDefault();
event.stopPropagation();
void this.applySelection(this.selectedCommand);
}
},
ensureSelection() {
const commands = this.filteredCommands;
if (!commands.length) {
this.selectedIndex = 0;
return;
}
if (this.selectedIndex >= commands.length) {
this.selectedIndex = 0;
}
},
moveSelection(delta) {
const commands = this.filteredCommands;
if (!commands.length) return;
const nextIndex =
(this.selectedIndex + delta + commands.length) % commands.length;
this.selectedIndex = nextIndex;
this.scrollSelectedIntoView();
},
scrollSelectedIntoView() {
requestAnimationFrame(() => {
document
.querySelector(".commands-slash-results .commands-slash-item.active")
?.scrollIntoView({ block: "nearest" });
});
},
async applySelection(command) {
if (!command || this.applying) return;
const input = this.getInputElement();
if (!input) return;
this.applying = true;
try {
const contextId = this.getContextId();
const fallbackSlash = this.rawMessage?.trim()
? this.rawMessage
: this.rawArguments
? `/${command.name} ${this.rawArguments}`
: `/${command.name}`;
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "resolve",
path: command.path,
slash_text: fallbackSlash,
project_name: this.contextScope?.project_name || "",
context_id: contextId,
});
const applied = await this.applyResolution(response?.resolution, input);
if (!applied?.hadToast && !applied?.hadError) {
notifySuccess(`Applied /${command.name}`);
}
} catch (error) {
console.error("Failed to apply slash command:", error);
notifyError(error?.message || "Failed to apply slash command.");
} finally {
this.applying = false;
}
},
async applyResolution(resolution, input) {
const result = resolution?.result || {};
const hasText = typeof result.text === "string";
let nextText = hasText ? result.text : this.getInputMessage();
const effects = Array.isArray(result.effects) ? result.effects : [];
let hadToast = false;
let hadError = false;
let shouldSend = false;
for (const effect of effects) {
if (!effect || typeof effect !== "object") continue;
const type = String(effect.type || "").trim().toLowerCase();
if (type === "replace_input") {
nextText = String(effect.text || "");
continue;
}
if (type === "append_input") {
const chunk = String(effect.text || "");
nextText = nextText ? `${nextText}\n${chunk}` : chunk;
continue;
}
if (type === "send_message") {
nextText = String(effect.text || nextText || "");
shouldSend = true;
continue;
}
if (type === "toast") {
hadToast = true;
const level = String(effect.level || "info").toLowerCase();
const message = String(effect.message || "");
if (!message) continue;
if (level === "error") {
hadError = true;
notifyError(message);
} else {
notifySuccess(message);
}
continue;
}
if (type === "new_chat") {
await chatsStore?.newChat?.();
continue;
}
if (type === "select_chat") {
const contextId = String(effect.context_id || "").trim();
if (contextId) await chatsStore?.selectChat?.(contextId);
continue;
}
if (type === "reset_chat") {
await chatsStore?.resetChat?.(String(effect.context_id || "") || null);
continue;
}
if (type === "pause_agent") {
await chatInputStore?.pauseAgent?.(Boolean(effect.paused));
continue;
}
if (type === "nudge_agent") {
await chatInputStore?.nudge?.();
continue;
}
if (type === "open_modal") {
const path = String(effect.path || "").trim();
if (path) await window.openModal?.(path);
continue;
}
if (type === "show_markdown") {
hadToast = true;
notifyInfo(
String(effect.title || "Slash Command"),
String(effect.content || ""),
);
continue;
}
if (type === "goal_changed") {
window.dispatchEvent(new CustomEvent("goal:changed", { detail: effect }));
continue;
}
if (type === "open_plugin_config") {
const pluginName = String(effect.plugin || "").trim();
if (pluginName) {
const { store } = await import("/components/plugins/plugin-settings-store.js");
await store.openConfig(
pluginName,
String(effect.project_name || ""),
String(effect.agent_profile || ""),
);
}
continue;
}
if (type === "compact_chat") {
const { store } = await import("/plugins/_chat_compaction/webui/compact-store.js");
await store.fetchStats();
continue;
}
if (type === "attach_files") {
await this.openAttachmentPicker(effect);
continue;
}
if (type === "copy_transcript") {
await this.copyTranscript();
hadToast = true;
continue;
}
if (type === "clear_transcript") {
const history = document.getElementById("chat-history");
if (history) history.innerHTML = "";
continue;
}
}
if (typeof input.value === "string") input.value = nextText;
chatInputStore.message = nextText;
input.dispatchEvent(new Event("input", { bubbles: true }));
chatInputStore.adjustTextareaHeight();
input.focus();
if (typeof input.setSelectionRange === "function") {
input.setSelectionRange(nextText.length, nextText.length);
} else {
chatInputStore?._setEditorCaret?.(nextText.length);
}
this.active = false;
this.dismissed = false;
this.query = "";
this.rawArguments = "";
this.rawMessage = nextText;
this.selectedIndex = 0;
if (shouldSend && nextText.trim()) {
await chatInputStore?.sendMessage?.();
}
return { hadToast, hadError };
},
openAttachmentPicker(effect = {}) {
return new Promise((resolve) => {
const picker = document.createElement("input");
let settled = false;
const done = () => {
if (settled) return;
settled = true;
picker.remove();
resolve();
};
picker.type = "file";
picker.multiple = true;
picker.accept = String(effect.accept || "*");
picker.style.display = "none";
picker.addEventListener("change", () => {
attachmentsStore?.handleFiles?.(picker.files || []);
done();
}, { once: true });
window.addEventListener("focus", () => setTimeout(done, 500), { once: true });
document.body.appendChild(picker);
picker.click();
});
},
async copyTranscript() {
const text = document.getElementById("chat-history")?.innerText?.trim() || "";
if (!text) {
notifyError("No visible transcript to copy.");
return;
}
await navigator.clipboard.writeText(text);
notifySuccess("Transcript copied.");
},
openCreateCommand() {
commandsManagerStore.openManager({
projectName: this.contextScope?.project_name || "",
prefillName: sanitizeCommandName(this.query || ""),
openEditor: true,
});
this.dismissed = true;
},
};
export const store = createStore("commandsSlash", model);

View file

@ -0,0 +1,417 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import {
toastFrontendError,
toastFrontendSuccess,
} from "/components/notifications/notification-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
const COMMANDS_API_PATH = "/plugins/_commands/commands";
const MAIN_MODAL_PATH = "/plugins/_commands/webui/main.html";
const EDITOR_MODAL_PATH = "/plugins/_commands/webui/editor.html";
function createEmptyEditor() {
return {
mode: "create",
existingPath: "",
path: "",
name: "",
description: "",
argumentHint: "",
commandType: "text",
includeHistory: false,
body: "",
extraFrontmatter: {},
};
}
function safeStringify(value) {
try {
return JSON.stringify(value ?? {});
} catch {
return "";
}
}
function sanitizeCommandName(rawName) {
return (rawName || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function buildDefaultBody(commandType = "text") {
if (commandType === "script") {
return [
"def run(payload):",
" args = payload.get('arguments', {})",
" flags = args.get('flags', {})",
" positional = args.get('positional', [])",
" return {",
" 'text': f\"Script command received args: {positional} flags: {flags}\",",
" 'effects': [],",
" }",
"",
].join("\n");
}
return "Describe the work to perform here.\n\n{raw}";
}
function notifyError(message) {
void toastFrontendError(message, "Commands");
}
function notifySuccess(message) {
void toastFrontendSuccess(message, "Commands");
}
function emitCommandsUpdated() {
window.dispatchEvent(new CustomEvent("commands:updated"));
}
const model = {
loading: false,
saving: false,
projects: [],
projectName: "",
scope: null,
contextScope: { project_name: "" },
commands: [],
pendingScope: null,
pendingCreate: null,
editor: createEmptyEditor(),
editorSnapshot: "",
get selectedScopeLabel() {
return this.scope?.scope_label || "Global";
},
get selectedScopeDirectory() {
return this.scope?.directory_path || "";
},
get hasCommands() {
return (this.commands || []).length > 0;
},
get editorTitle() {
return this.editor.mode === "edit" ? "Edit Slash Command" : "Create Slash Command";
},
get editorDirty() {
return this._serializeEditor() !== this.editorSnapshot;
},
get editorBodyLabel() {
return this.editor.commandType === "script" ? "Python hook" : "Text template";
},
openManager(options = {}) {
const hasExplicitScope = Object.prototype.hasOwnProperty.call(options, "projectName");
this.pendingScope = hasExplicitScope
? {
projectName: options.projectName || "",
}
: null;
this.pendingCreate =
options.openEditor || options.prefillName
? {
name: options.prefillName || "",
}
: null;
return window.openModal?.(MAIN_MODAL_PATH);
},
async onOpen() {
await this.loadProjects();
try {
await this.resolveInitialScope();
await this.loadCommands();
} catch (error) {
console.error("Failed to initialize commands manager:", error);
this.scope = null;
this.commands = [];
notifyError(error?.message || "Failed to open the commands manager.");
}
if (this.pendingCreate) {
const pendingCreate = { ...this.pendingCreate };
this.pendingCreate = null;
await this.openCreateCommand({ name: pendingCreate.name });
}
},
cleanup() {
this.loading = false;
this.saving = false;
this.projects = [];
this.projectName = "";
this.scope = null;
this.contextScope = { project_name: "" };
this.commands = [];
this.pendingScope = null;
this.pendingCreate = null;
this.resetEditor();
},
async loadProjects() {
try {
const response = await callJsonApi("projects", { action: "list_options" });
this.projects = Array.isArray(response?.data) ? response.data : [];
} catch {
this.projects = [];
}
},
normalizeProject(projectName) {
if (!projectName) return "";
return (this.projects || []).some((project) => project?.key === projectName)
? projectName
: "";
},
async resolveInitialScope() {
const contextId =
chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
const scopeInfo = await callJsonApi(COMMANDS_API_PATH, {
action: "scope_info",
context_id: contextId,
});
this.contextScope = scopeInfo?.context_scope || {
project_name: "",
};
const preferredScope = this.pendingScope || scopeInfo?.scope || {};
this.projectName = this.normalizeProject(preferredScope.project_name || "");
this.pendingScope = null;
},
async loadCommands() {
this.loading = true;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "list_scope",
project_name: this.projectName || "",
});
this.commands = Array.isArray(response?.commands) ? response.commands : [];
this.scope = response?.scope || null;
} catch (error) {
console.error("Failed to load commands:", error);
this.commands = [];
this.scope = null;
notifyError(error?.message || "Failed to load commands.");
} finally {
this.loading = false;
}
},
async refresh() {
await this.loadCommands();
},
async onScopeChanged() {
this.projectName = this.normalizeProject(this.projectName);
await this.loadCommands();
},
overrideBadgeLabel(command) {
const count = Number(command?.override_count || 0);
if (!count) return "";
if (count === 1) {
return `Overrides ${command.override_scopes[0]}`;
}
return `Overrides ${count} lower scopes`;
},
async browseScopeFolder() {
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "scope_info",
project_name: this.projectName || "",
ensure_directory: true,
});
if (response?.scope?.directory_path) {
await fileBrowserStore.open(response.scope.directory_path);
}
} catch (error) {
console.error("Failed to open scope folder:", error);
notifyError(error?.message || "Failed to open scope folder.");
}
},
async openCreateCommand(options = {}) {
if (Object.prototype.hasOwnProperty.call(options, "projectName")) {
this.projectName = this.normalizeProject(options.projectName || "");
await this.loadCommands();
}
const suggestedName = sanitizeCommandName(options.name || "");
this.editor = {
...createEmptyEditor(),
mode: "create",
name: suggestedName,
commandType: "text",
body: buildDefaultBody("text"),
};
this.editorSnapshot = this._serializeEditor();
await this.openEditorModal();
},
async openEditCommand(command) {
if (!command?.path) return;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "get",
path: command.path,
project_name: this.projectName || "",
});
const loaded = response?.command || command;
this.editor = {
mode: "edit",
existingPath: loaded.path || "",
path: loaded.path || "",
name: loaded.name || "",
description: loaded.description || "",
argumentHint: loaded.argument_hint || "",
commandType: loaded.command_type || "text",
includeHistory: Boolean(loaded.include_history),
body: loaded.body || "",
extraFrontmatter: loaded.frontmatter_extra || {},
};
this.editorSnapshot = this._serializeEditor();
await this.openEditorModal();
} catch (error) {
console.error("Failed to load command:", error);
notifyError(error?.message || "Failed to load command.");
}
},
async duplicateCommand(command) {
if (!command?.path) return;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "duplicate",
path: command.path,
project_name: this.projectName || "",
});
await this.loadCommands();
emitCommandsUpdated();
notifySuccess(`Duplicated /${response?.command?.name || command.name}`);
if (response?.command) {
await this.openEditCommand(response.command);
}
} catch (error) {
console.error("Failed to duplicate command:", error);
notifyError(error?.message || "Failed to duplicate command.");
}
},
async deleteCommand(command) {
if (!command?.path) return;
try {
await callJsonApi(COMMANDS_API_PATH, {
action: "delete",
path: command.path,
project_name: this.projectName || "",
});
await this.loadCommands();
emitCommandsUpdated();
notifySuccess(`Deleted /${command.name}`);
} catch (error) {
console.error("Failed to delete command:", error);
notifyError(error?.message || "Failed to delete command.");
}
},
async openEditorModal() {
await window.openModal?.(EDITOR_MODAL_PATH, () => this.confirmCloseEditor());
this.resetEditor();
},
confirmCloseEditor() {
if (!this.editorDirty) return true;
return window.confirm("Discard unsaved slash command changes?");
},
async closeEditor() {
await window.closeModal?.(EDITOR_MODAL_PATH);
},
setEditorType(nextType) {
const normalizedType = nextType === "script" ? "script" : "text";
if (this.editor.commandType === normalizedType) return;
this.editor.commandType = normalizedType;
this.editor.includeHistory =
normalizedType === "script" ? this.editor.includeHistory : false;
this.editor.body = buildDefaultBody(normalizedType);
},
async saveEditor() {
this.saving = true;
try {
const response = await callJsonApi(COMMANDS_API_PATH, {
action: "save",
project_name: this.projectName || "",
existing_path: this.editor.existingPath || "",
name: this.editor.name || "",
description: this.editor.description || "",
argument_hint: this.editor.argumentHint || "",
command_type: this.editor.commandType || "text",
include_history:
this.editor.commandType === "script" ? Boolean(this.editor.includeHistory) : false,
body: this.editor.body || "",
extra_frontmatter: this.editor.extraFrontmatter || {},
});
this.editor.path = response?.command?.path || "";
this.editor.existingPath = response?.command?.path || "";
this.editorSnapshot = this._serializeEditor();
await this.loadCommands();
emitCommandsUpdated();
notifySuccess(
`${this.editor.mode === "edit" ? "Updated" : "Saved"} /${response?.command?.name || this.editor.name}`,
);
await window.closeModal?.(EDITOR_MODAL_PATH);
} catch (error) {
console.error("Failed to save command:", error);
notifyError(error?.message || "Failed to save command.");
} finally {
this.saving = false;
}
},
resetEditor() {
this.editor = createEmptyEditor();
this.editorSnapshot = this._serializeEditor();
},
_serializeEditor() {
return safeStringify({
existingPath: this.editor.existingPath || "",
name: this.editor.name || "",
description: this.editor.description || "",
argumentHint: this.editor.argumentHint || "",
commandType: this.editor.commandType || "text",
includeHistory: Boolean(this.editor.includeHistory),
body: this.editor.body || "",
extraFrontmatter: this.editor.extraFrontmatter || {},
});
},
};
export const store = createStore("commandsManager", model);

View file

@ -0,0 +1,240 @@
<html>
<head>
<title>Slash Command</title>
<script type="module">
import { store } from "/plugins/_commands/webui/commands-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.commandsManager">
<div class="commands-editor"
x-create="$el.closest('.modal')?.querySelector('.modal-title') && ($el.closest('.modal').querySelector('.modal-title').textContent = $store.commandsManager.editorTitle)">
<div class="commands-editor-header">
<div class="commands-editor-copy">
<div class="commands-editor-title" x-text="$store.commandsManager.editorTitle"></div>
<div class="commands-editor-subtitle">
Saving writes a <code>.command.yaml</code> config plus a template file in <span x-text="$store.commandsManager.selectedScopeLabel"></span>.
</div>
</div>
</div>
<div class="commands-editor-grid">
<label class="commands-field">
<span>Command name</span>
<input type="text"
x-model="$store.commandsManager.editor.name"
placeholder="explain-code"
autocomplete="off">
</label>
<label class="commands-field">
<span>Description</span>
<input type="text"
x-model="$store.commandsManager.editor.description"
placeholder="Explain code clearly with examples.">
</label>
</div>
<label class="commands-field">
<span>Argument hint or example</span>
<input type="text"
x-model="$store.commandsManager.editor.argumentHint"
placeholder="Optional free-form text after /command">
</label>
<div class="commands-editor-grid">
<label class="commands-field">
<span>Command type</span>
<select x-model="$store.commandsManager.editor.commandType"
@change="$store.commandsManager.setEditorType($store.commandsManager.editor.commandType)">
<option value="text">Text template (.txt)</option>
<option value="script">Python hook (.py)</option>
</select>
</label>
<template x-if="$store.commandsManager.editor.commandType === 'script'">
<label class="commands-field commands-field-checkbox">
<span>Script context</span>
<div class="commands-checkbox-inline">
<input type="checkbox" x-model="$store.commandsManager.editor.includeHistory">
<span>Include chat history payload</span>
</div>
</label>
</template>
</div>
<label class="commands-field commands-field-body">
<span x-text="$store.commandsManager.editorBodyLabel"></span>
<textarea x-model="$store.commandsManager.editor.body"
rows="14"
:placeholder="$store.commandsManager.editor.commandType === 'script'
? 'Implement run(payload) and return { text, effects }.'
: 'Write reusable text. Use {raw}, {args.positional.0}, {args.flags.some_flag}.'"></textarea>
</label>
<template x-if="$store.commandsManager.editor.commandType === 'text'">
<div class="commands-editor-help">
<span class="commands-help-chip">{raw}</span>
<span class="commands-help-chip">{args.positional.0}</span>
<span class="commands-help-chip">{args.flags.git_url}</span>
<span class="commands-help-copy">
Trailing command input is parsed once and exposed to template placeholders and python payloads.
</span>
</div>
</template>
<template x-if="$store.commandsManager.editor.commandType === 'script'">
<div class="commands-editor-help">
<span class="commands-help-chip">run(payload)</span>
<span class="commands-help-chip">payload.arguments</span>
<span class="commands-help-chip">result.effects</span>
<span class="commands-help-copy">
Return a string or <code>{ text, effects }</code>. Effects support <code>replace_input</code>, <code>append_input</code>, and <code>toast</code>.
</span>
</div>
</template>
<template x-if="$store.commandsManager.editor.path">
<div class="commands-editor-path">
<span class="material-symbols-outlined">description</span>
<span x-text="$store.commandsManager.editor.path"></span>
</div>
</template>
</div>
</template>
</div>
<div class="modal-footer" data-modal-footer>
<button class="btn btn-ok"
@click="$store.commandsManager.saveEditor()"
:disabled="$store.commandsManager.saving">
Save
</button>
<button class="btn btn-cancel" @click="$store.commandsManager.closeEditor()">
Cancel
</button>
</div>
<style>
.commands-editor {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 1.1rem 1.2rem;
}
.commands-editor-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.commands-editor-copy {
min-width: 0;
}
.commands-editor-title {
font-size: 1.02rem;
font-weight: 600;
}
.commands-editor-subtitle {
margin-top: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
line-height: 1.45;
}
.commands-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.commands-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.commands-field span {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.commands-field textarea,
.commands-field input {
width: 100%;
}
.commands-field select {
width: 100%;
}
.commands-field-checkbox {
justify-content: flex-end;
}
.commands-checkbox-inline {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--color-text-primary);
font-size: 0.86rem;
font-weight: 500;
}
.commands-checkbox-inline input {
width: auto;
}
.commands-field-body textarea {
min-height: 18rem;
resize: vertical;
font-family: "Roboto Mono", monospace;
}
.commands-editor-help {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.commands-help-chip {
display: inline-flex;
align-items: center;
padding: 0.22rem 0.55rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-highlight) 24%, transparent);
font-family: "Roboto Mono", monospace;
font-size: 0.8rem;
}
.commands-help-copy {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.commands-editor-path {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.78rem;
word-break: break-all;
}
@media (max-width: 760px) {
.commands-editor-grid {
grid-template-columns: 1fr;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,395 @@
<html>
<head>
<title>Commands</title>
<script type="module">
import { store } from "/plugins/_commands/webui/commands-store.js";
</script>
</head>
<body>
<div x-data>
<template x-if="$store.commandsManager">
<div class="commands-manager" x-create="$store.commandsManager.onOpen()" x-destroy="$store.commandsManager.cleanup()">
<div class="commands-toolbar">
<div class="commands-toolbar-copy">
<div class="commands-title">Slash Commands</div>
<div class="commands-subtitle">
YAML-configured commands for the inline chat composer.
</div>
</div>
<div class="commands-toolbar-controls">
<label class="commands-select">
<span>Project</span>
<select x-model="$store.commandsManager.projectName" @change="$store.commandsManager.onScopeChanged()">
<option value="">Global</option>
<template x-for="project in $store.commandsManager.projects" :key="project.key">
<option :value="project.key" x-text="project.label"></option>
</template>
</select>
</label>
<div class="commands-toolbar-actions">
<button type="button" class="button secondary" @click="$store.commandsManager.refresh()">
<span class="material-symbols-outlined">refresh</span>
<span>Refresh</span>
</button>
<button type="button" class="button secondary" @click="$store.commandsManager.browseScopeFolder()">
<span class="material-symbols-outlined">folder_open</span>
<span>Browse Scope Folder</span>
</button>
<button type="button" class="button primary" @click="$store.commandsManager.openCreateCommand()">
<span class="material-symbols-outlined">add</span>
<span>Create Command</span>
</button>
</div>
</div>
</div>
<div class="commands-scope-banner" x-show="$store.commandsManager.scope">
<div class="commands-scope-banner-label">
<span class="material-symbols-outlined">target</span>
<span x-text="$store.commandsManager.selectedScopeLabel"></span>
</div>
<div class="commands-scope-banner-path" x-text="$store.commandsManager.selectedScopeDirectory"></div>
</div>
<div class="commands-loading" x-show="$store.commandsManager.loading">
<span class="material-symbols-outlined spinning">progress_activity</span>
<span>Loading commands...</span>
</div>
<div class="commands-list" x-show="!$store.commandsManager.loading">
<template x-if="$store.commandsManager.hasCommands">
<div class="commands-grid">
<template x-for="command in $store.commandsManager.commands" :key="command.path">
<article class="commands-card">
<div class="commands-card-header">
<div class="commands-card-copy">
<div class="commands-card-title">
<span class="commands-slash">/</span><span x-text="command.name"></span>
</div>
<div class="commands-card-description" x-text="command.description"></div>
</div>
<div class="commands-card-actions">
<button type="button" class="button icon" title="Edit command" @click="$store.commandsManager.openEditCommand(command)">
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="button icon" title="Duplicate command" @click="$store.commandsManager.duplicateCommand(command)">
<span class="material-symbols-outlined">content_copy</span>
</button>
<button type="button" class="button icon danger" title="Delete command" @click.stop="$confirmClick($event, () => $store.commandsManager.deleteCommand(command))">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</div>
<div class="commands-card-badges">
<span class="commands-badge scope" x-text="command.scope_label"></span>
<span class="commands-badge type" x-text="command.command_type === 'script' ? 'Python Hook' : 'Text Template'"></span>
<template x-if="command.override_count">
<span class="commands-badge override" x-text="$store.commandsManager.overrideBadgeLabel(command)"></span>
</template>
</div>
<template x-if="command.argument_hint">
<div class="commands-card-hint">
<span class="material-symbols-outlined">subdirectory_arrow_right</span>
<span x-text="command.argument_hint"></span>
</div>
</template>
<div class="commands-card-path" x-text="command.path"></div>
</article>
</template>
</div>
</template>
<template x-if="!$store.commandsManager.hasCommands">
<div class="commands-empty-state">
<div class="commands-empty-title">No commands in this scope yet.</div>
<div class="commands-empty-copy">
Create YAML-configured slash commands here and they will appear from <code>/</code> in chat.
</div>
<button type="button" class="button primary" @click="$store.commandsManager.openCreateCommand()">
<span class="material-symbols-outlined">add</span>
<span>Create Slash Command</span>
</button>
</div>
</template>
</div>
</div>
</template>
</div>
<style>
.commands-manager {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.commands-toolbar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-panel) 88%, transparent);
}
.commands-toolbar-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.commands-title {
font-size: 1.1rem;
font-weight: 600;
}
.commands-subtitle {
color: var(--color-text-secondary);
font-size: 0.92rem;
}
.commands-toolbar-controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
.commands-select {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 14rem;
flex: 1 1 14rem;
}
.commands-select span {
color: var(--color-text-secondary);
font-size: 0.82rem;
font-weight: 600;
}
.commands-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-left: auto;
}
.commands-scope-banner {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
}
.commands-scope-banner-label {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
}
.commands-scope-banner-path {
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.82rem;
word-break: break-all;
}
.commands-loading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.9rem 1rem;
border-radius: 10px;
}
.commands-loading {
color: var(--color-text-secondary);
border: 1px dashed var(--color-border);
}
.commands-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 0.9rem;
}
.commands-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-panel) 90%, transparent);
}
.commands-card-header {
display: flex;
gap: 0.75rem;
justify-content: space-between;
align-items: flex-start;
}
.commands-card-copy {
min-width: 0;
}
.commands-card-title {
font-size: 1rem;
font-weight: 600;
word-break: break-word;
}
.commands-slash {
color: var(--color-highlight);
}
.commands-card-description {
margin-top: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.45;
}
.commands-card-actions {
display: inline-flex;
gap: 0.35rem;
flex-shrink: 0;
}
.commands-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.commands-badge {
display: inline-flex;
align-items: center;
padding: 0.22rem 0.5rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
border: 1px solid transparent;
}
.commands-badge.scope {
background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
border-color: color-mix(in srgb, var(--color-highlight) 24%, transparent);
}
.commands-badge.override {
background: color-mix(in srgb, #f39c12 12%, transparent);
border-color: color-mix(in srgb, #f39c12 28%, transparent);
}
.commands-badge.type {
background: color-mix(in srgb, #5b8def 12%, transparent);
border-color: color-mix(in srgb, #5b8def 30%, transparent);
}
.commands-card-hint {
display: inline-flex;
align-items: flex-start;
gap: 0.35rem;
color: var(--color-text-secondary);
font-size: 0.86rem;
}
.commands-card-hint .material-symbols-outlined {
font-size: 1rem;
margin-top: 0.05rem;
}
.commands-card-path {
color: var(--color-text-secondary);
font-family: "Roboto Mono", monospace;
font-size: 0.78rem;
word-break: break-all;
}
.commands-empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
padding: 1.4rem;
border: 1px dashed var(--color-border);
border-radius: 12px;
background: color-mix(in srgb, var(--color-background) 84%, var(--color-panel));
}
.commands-empty-title {
font-size: 1rem;
font-weight: 600;
}
.commands-empty-copy {
color: var(--color-text-secondary);
max-width: 42rem;
}
.button.primary,
.button.secondary,
.button.icon {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.button.icon {
padding: 0.45rem;
min-width: 2.25rem;
justify-content: center;
}
.button.icon.danger {
color: var(--color-accent);
}
.spinning {
animation: commands-spin 1s linear infinite;
}
@keyframes commands-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 760px) {
.commands-toolbar-actions {
margin-left: 0;
}
.commands-select {
min-width: 0;
flex-basis: 100%;
}
.commands-toolbar-actions {
width: 100%;
}
.commands-toolbar-actions .button {
flex: 1 1 100%;
justify-content: center;
}
}
</style>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -17,7 +17,7 @@
- Keep desktop state injected into prompts accurate and bounded.
- Do not expose desktop routes without the expected auth protections.
- Keep Desktop host visibility tied to an attached modal or canvas host; modal cleanup may preserve the iframe in keepalive, but must not leave stale modal mode behind.
- Keep Markdown and plain text file open-with handling routed to the Editor surface through the desktop intent bridge; Desktop owns the Xfce launcher/MIME setup, while Editor owns `.md` and `.txt` editing.
- Keep LibreOffice Writer as the default handler for Markdown and plain text files; keep Agent Zero Editor available as a secondary Open With target through the desktop intent bridge.
## Work Guidance

View file

@ -79,6 +79,7 @@ URL_INTENT_MAX_ITEMS = 50
URL_INTENT_MAX_LENGTH = 8192
URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop"
WRITER_HANDLER_DESKTOP_ID = "libreoffice-writer.desktop"
SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
SHUTDOWN_CONFIRM_SECONDS = 8
@ -2197,15 +2198,19 @@ def _editor_text_handler_mime_types() -> tuple[str, ...]:
def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None:
url_associations = ";".join([url_desktop_id, ""])
editor_associations = ";".join([editor_desktop_id, ""])
# LibreOffice Writer stays the default so double-clicks keep the user (and
# agents) inside the Desktop; the Agent Zero Editor remains an "Open With"
# option. When Writer's desktop entry is missing, xdg falls back through
# the added associations to the editor.
text_associations = ";".join([WRITER_HANDLER_DESKTOP_ID, editor_desktop_id, ""])
lines = [
"[Default Applications]",
*(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()),
*(f"{mime_type}={editor_desktop_id}" for mime_type in _editor_text_handler_mime_types()),
*(f"{mime_type}={WRITER_HANDLER_DESKTOP_ID}" for mime_type in _editor_text_handler_mime_types()),
"",
"[Added Associations]",
*(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()),
*(f"{mime_type}={editor_associations}" for mime_type in _editor_text_handler_mime_types()),
*(f"{mime_type}={text_associations}" for mime_type in _editor_text_handler_mime_types()),
"",
]
path.parent.mkdir(parents=True, exist_ok=True)

41
plugins/_goal/AGENTS.md Normal file
View file

@ -0,0 +1,41 @@
# Goal Plugin DOX
## Purpose
- Own the built-in chat goal strip, `/goal` slash command, goal state API, and agent-facing goal tools.
- Keep chat goals scoped to the active chat context and stored as user data outside tracked plugin code.
## Ownership
- `plugin.yaml` owns the always-enabled `_goal` plugin metadata.
- `helpers/goals.py` owns file-backed goal storage under `usr/plugins/_goal/goals/` and goal status normalization.
- `api/goal.py` owns the WebUI JSON API for reading, editing, pausing, resuming, and deleting goals.
- `commands/` owns the `/goal` slash command contributed to `_commands`.
- `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
- `tools/` and `prompts/` own agent-facing goal inspection, creation, and status update behavior.
- `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
## Local Contracts
- Goal status values are `active`, `paused`, `complete`, and `blocked`.
- Active goals are injected into agent extras; paused and blocked goals remain visible in the UI, while complete goals are hidden.
- Goal records track accumulated active time with `elapsed_seconds` and `active_since`; pausing freezes elapsed time until resume.
- User controls may pause, resume, edit, or delete a goal; destructive delete uses inline confirmation. Model tools may create goals and mark them complete or blocked.
- `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately.
- `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
- Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
## Work Guidance
- Keep goal state in `usr/plugins/_goal/`; do not store runtime goal data in tracked files.
- Keep the goal strip mounted through WebUI extension points instead of modifying core composer templates.
- Keep `_commands` compatibility in mind: `/goal` is a plugin-contributed command and should remain read-only in the command manager.
## Verification
- Run `conda run -n a0 pytest plugins/_goal/tests` after changing `_goal` backend behavior.
- Run `_commands` discovery tests when changing the `/goal` command contribution contract.
## Child DOX Index
No child DOX files.

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

55
plugins/_goal/api/goal.py Normal file
View file

@ -0,0 +1,55 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._goal.helpers import goals
class Goal(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
action = str(input.get("action", "") or "").strip().lower()
context_id = str(input.get("context_id", "") or "").strip()
try:
if action == "get":
return {"ok": True, "goal": goals.public_goal(goals.get_goal(context_id))}
if action in {"set", "create"}:
return self._set(context_id, input)
if action == "update":
return self._update(context_id, input)
if action == "pause":
return self._status(context_id, "paused")
if action == "resume":
return self._status(context_id, "active")
if action == "delete":
goals.delete_goal(context_id)
return {"ok": True, "goal": None}
except FileNotFoundError:
return Response(status=404, response="Goal not found")
except ValueError as error:
return Response(status=400, response=str(error))
return Response(status=400, response=f"Unknown action: {action}")
def _set(self, context_id: str, input: dict) -> dict:
goal = goals.create_goal(
context_id,
str(input.get("objective") or ""),
created_by=str(input.get("created_by") or "user"),
token_budget=input.get("token_budget"),
)
return {"ok": True, "goal": goals.public_goal(goal)}
def _update(self, context_id: str, input: dict) -> dict:
goal = goals.update_goal(
context_id,
objective=input.get("objective") if "objective" in input else None,
status=input.get("status") if "status" in input else None,
note=input.get("note") if "note" in input else None,
token_budget=input.get("token_budget") if "token_budget" in input else None,
)
return {"ok": True, "goal": goals.public_goal(goal)}
def _status(self, context_id: str, status: str) -> dict:
goal = goals.update_goal(context_id, status=status)
return {"ok": True, "goal": goals.public_goal(goal)}

View file

@ -0,0 +1,5 @@
name: goal
description: Create, inspect, pause, resume, edit, or delete this chat goal.
argument_hint: "[objective|status|pause|resume|edit <goal>|delete|auto]"
type: script
script_path: goal_command.py

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