mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-10 01:18:29 +00:00
Compare commits
44 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bb40576af | ||
|
|
c3f44b2c88 | ||
|
|
fa05710da7 | ||
|
|
8d79f556c6 | ||
|
|
1a6d0eb614 | ||
|
|
1bd741ff7f | ||
|
|
84c13dab01 | ||
|
|
9c9a4e00ca | ||
|
|
3d87998821 | ||
|
|
bc96386260 | ||
|
|
f64b6490c5 | ||
|
|
f1787b65f5 | ||
|
|
5f14435093 | ||
|
|
a7255d9773 | ||
|
|
4f80ded531 | ||
|
|
6dfe33c493 | ||
|
|
eb1326ccd3 | ||
|
|
bcf2634000 | ||
|
|
f8b06e0b12 | ||
|
|
0de0fcec0e | ||
|
|
dea64ddad0 | ||
|
|
5ac4f75a47 | ||
|
|
7aba42d46e | ||
|
|
ad148f24cc | ||
|
|
67895f7b44 | ||
|
|
96fd5fc2b2 | ||
|
|
9bb3cb9469 | ||
|
|
101d84551d | ||
|
|
8871bf5001 | ||
|
|
6998789ed3 | ||
|
|
afa5231a5a | ||
|
|
c719134537 | ||
|
|
7168f0984e | ||
|
|
7298a88fda | ||
|
|
d018177927 | ||
|
|
9bfa3b5c81 | ||
|
|
153ffcc221 | ||
|
|
69fb0fca2a | ||
|
|
277bcd0783 | ||
|
|
727a840e10 | ||
|
|
91b3bc9613 | ||
|
|
686e039d1b | ||
|
|
306012eb00 | ||
|
|
05481c222a |
214 changed files with 12510 additions and 705 deletions
131
README.md
131
README.md
|
|
@ -3,56 +3,58 @@
|
||||||
<img src="docs/res/a0-vector-graphics/horizontal_banner.svg" alt="Agent Zero Banner" width="100%"/>
|
<img src="docs/res/a0-vector-graphics/horizontal_banner.svg" alt="Agent Zero Banner" width="100%"/>
|
||||||
|
|
||||||
# Agent Zero
|
# Agent Zero
|
||||||
### A full Linux system for your AI agent.
|
### Give your agent a full Linux computer.
|
||||||
|
|
||||||
Agent Zero is an open, dynamic, organic agentic framework. One Docker container ships a full Linux system with a desktop and a plugin hub that the agent can extend using Skills.
|
Agent Zero is an open agent framework for work that needs more than chat: a Dockerized Linux desktop, a browser with DOM annotation, live document cowork, projects, skills, plugins, and a bridge back to your host machine.
|
||||||
|
|
||||||
[](https://agent-zero.ai)
|
[](https://agent-zero.ai)
|
||||||
[](./docs/)
|
[](./docs/)
|
||||||
[](https://discord.gg/B8KZKNsPpj)
|
[](https://discord.gg/B8KZKNsPpj)
|
||||||
[](https://github.com/sponsors/agent0ai)
|
[](https://github.com/sponsors/agent0ai)
|
||||||
|
|
||||||
[Install](#how-to-install) |
|
|
||||||
[Launcher](#a0-launcher) |
|
|
||||||
[What's Different](#what-makes-agent-zero-different) |
|
|
||||||
[A0 CLI](#a0-cli-connector-extend-onto-your-host-machine) |
|
|
||||||
[Docs](#documentation)
|
|
||||||
|
|
||||||
[](https://deepwiki.com/agent0ai/agent-zero)
|
[](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>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://www.youtube.com/watch?v=k78HX_RA9Q0&t=19s">
|
<img alt="Agent Zero driving Blender in its built-in XFCE desktop" src="docs/res/usage/webui/agentzero-xfce-computer.gif" width="100%" />
|
||||||
<img src="docs/res/thumbnail-install.webp" alt="Agent Zero Installation Guide" width="100%"/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</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 |
|
# Quick Start
|
||||||
| --- | --- | --- |
|
|
||||||
| **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. |
|
|
||||||
|
|
||||||
## 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 |
|
| 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) |
|
| 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.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) |
|
| 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
|
## 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).
|
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
|
## 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.
|
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.
|
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.
|
- **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.
|
- **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.
|
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.
|
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."
|
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.
|
||||||
- **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).
|
|
||||||
|
|
||||||
<img alt="Time Travel" src="docs/res/time-travel.png" />
|
<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.
|
- **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.
|
- **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
|
## Documentation
|
||||||
|
|
||||||
| I want to... | Start here |
|
| 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.
|
- [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.
|
- [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.
|
- [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.
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ chat:
|
||||||
endpoint_url: "/v1/models"
|
endpoint_url: "/v1/models"
|
||||||
default_base: "http://host.docker.internal:1234"
|
default_base: "http://host.docker.internal:1234"
|
||||||
kwargs:
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
api_base: "http://host.docker.internal:1234/v1"
|
api_base: "http://host.docker.internal:1234/v1"
|
||||||
api_key: "lm-studio"
|
api_key: "lm-studio"
|
||||||
llama_cpp:
|
llama_cpp:
|
||||||
|
|
@ -93,6 +94,7 @@ chat:
|
||||||
endpoint_url: "/v1/models"
|
endpoint_url: "/v1/models"
|
||||||
default_base: "http://host.docker.internal:8080"
|
default_base: "http://host.docker.internal:8080"
|
||||||
kwargs:
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
api_base: "http://host.docker.internal:8080/v1"
|
api_base: "http://host.docker.internal:8080/v1"
|
||||||
api_key: "llama-cpp"
|
api_key: "llama-cpp"
|
||||||
mistral:
|
mistral:
|
||||||
|
|
@ -120,6 +122,7 @@ chat:
|
||||||
format: "ollama"
|
format: "ollama"
|
||||||
default_base: "http://host.docker.internal:11434"
|
default_base: "http://host.docker.internal:11434"
|
||||||
kwargs:
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
api_base: "http://host.docker.internal:11434"
|
api_base: "http://host.docker.internal:11434"
|
||||||
omlx:
|
omlx:
|
||||||
name: oMLX
|
name: oMLX
|
||||||
|
|
@ -128,6 +131,7 @@ chat:
|
||||||
endpoint_url: "/v1/models"
|
endpoint_url: "/v1/models"
|
||||||
default_base: "http://host.docker.internal:8000"
|
default_base: "http://host.docker.internal:8000"
|
||||||
kwargs:
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
api_base: "http://host.docker.internal:8000/v1"
|
api_base: "http://host.docker.internal:8000/v1"
|
||||||
api_key: "omlx"
|
api_key: "omlx"
|
||||||
ollama_cloud:
|
ollama_cloud:
|
||||||
|
|
@ -136,6 +140,7 @@ chat:
|
||||||
models_list:
|
models_list:
|
||||||
endpoint_url: "/models"
|
endpoint_url: "/models"
|
||||||
kwargs:
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
api_base: https://ollama.com/v1
|
api_base: https://ollama.com/v1
|
||||||
openai:
|
openai:
|
||||||
name: OpenAI
|
name: OpenAI
|
||||||
|
|
@ -173,6 +178,7 @@ chat:
|
||||||
models_list:
|
models_list:
|
||||||
endpoint_url: "https://api.venice.ai/api/v1/models"
|
endpoint_url: "https://api.venice.ai/api/v1/models"
|
||||||
kwargs:
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
api_base: https://api.venice.ai/api/v1
|
api_base: https://api.venice.ai/api/v1
|
||||||
venice_parameters:
|
venice_parameters:
|
||||||
include_venice_system_prompt: false
|
include_venice_system_prompt: false
|
||||||
|
|
@ -183,6 +189,7 @@ chat:
|
||||||
endpoint_url: "/v1/models"
|
endpoint_url: "/v1/models"
|
||||||
default_base: "http://host.docker.internal:8000"
|
default_base: "http://host.docker.internal:8000"
|
||||||
kwargs:
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
api_base: "http://host.docker.internal:8000/v1"
|
api_base: "http://host.docker.internal:8000/v1"
|
||||||
api_key: "vllm"
|
api_key: "vllm"
|
||||||
xai:
|
xai:
|
||||||
|
|
@ -207,6 +214,8 @@ chat:
|
||||||
other:
|
other:
|
||||||
name: Other OpenAI compatible
|
name: Other OpenAI compatible
|
||||||
litellm_provider: openai
|
litellm_provider: openai
|
||||||
|
kwargs:
|
||||||
|
a0_api_mode: chat
|
||||||
|
|
||||||
embedding:
|
embedding:
|
||||||
huggingface:
|
huggingface:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
- Do not bake secrets, local `.env` values, or user data into the image.
|
- 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 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.
|
- 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
|
## Work Guidance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,11 @@ def should_exclude_from_usr_backup(
|
||||||
logger: AttemptLogger,
|
logger: AttemptLogger,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
parts = relative_dir.parts
|
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 (
|
if (
|
||||||
len(parts) >= 6
|
len(parts) >= 6
|
||||||
and parts[0] == "plugins"
|
and parts[0] == "plugins"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
- `README.md`, `quickstart.md`, `guides/`, and `setup/` cover user-facing setup and workflows.
|
- `README.md`, `quickstart.md`, `guides/`, and `setup/` cover user-facing setup and workflows.
|
||||||
- `developer/` covers compact developer references and source handoffs.
|
- `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.
|
- `res/` contains documentation images and other documentation assets.
|
||||||
|
|
||||||
## Local Contracts
|
## Local Contracts
|
||||||
|
|
|
||||||
|
|
@ -132,15 +132,22 @@ machine.
|
||||||
- [ ] Keep A0 CLI connected to the Agent Zero chat.
|
- [ ] Keep A0 CLI connected to the Agent Zero chat.
|
||||||
- [ ] In Agent Zero Web UI, open Browser plugin settings and choose **Bring Your
|
- [ ] In Agent Zero Web UI, open Browser plugin settings and choose **Bring Your
|
||||||
Own Browser**.
|
Own Browser**.
|
||||||
- [ ] If you want Agent Zero to use an already-open personal Chrome window, open
|
- [ ] If you want Agent Zero to use an already-open personal browser window,
|
||||||
that browser first.
|
open that browser first.
|
||||||
- [ ] In that browser, go to `chrome://inspect/#remote-debugging`.
|
- [ ] In that browser, open its remote debugging page.
|
||||||
- [ ] Enable **Allow remote debugging for this browser instance**.
|
- [ ] Enable **Allow remote debugging for this browser instance**.
|
||||||
|
|
||||||
|
Remote debugging pages:
|
||||||
|
|
||||||
|
| Browser | Page |
|
||||||
|
| --- | --- |
|
||||||
|
| Chrome, Edge, Brave, Vivaldi, Chromium | `chrome://inspect/#remote-debugging` |
|
||||||
|
| Opera | `opera://inspect/#remote-debugging` |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
When Agent Zero performs its first Browser action against that host browser,
|
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.
|
instance and A0 CLI connection.
|
||||||
|
|
||||||

|

|
||||||
|
|
@ -149,11 +156,30 @@ A0 CLI does not take over the browser while it is only checking status. Browser
|
||||||
control starts when Agent Zero actually needs to use the browser.
|
control starts when Agent Zero actually needs to use the browser.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!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
|
> 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
|
> with trusted Agent Zero instances and browser windows you intend the agent to
|
||||||
> control.
|
> 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
|
### Browser Profiles
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -137,8 +137,8 @@ development, Agent Zero can install it the first time it is needed.
|
||||||
|
|
||||||
## Bring Your Own Browser
|
## Bring Your Own Browser
|
||||||
|
|
||||||
Bring Your Own Browser lets Agent Zero use Chrome, Edge, or Chromium on your own
|
Bring Your Own Browser lets Agent Zero use Chrome, Edge, Brave, Opera, Vivaldi,
|
||||||
computer through A0 CLI.
|
or Chromium on your own computer through A0 CLI.
|
||||||
|
|
||||||
Use it when the page, login, or browser profile should stay on your machine.
|
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.
|
- [ ] Keep A0 CLI connected to the Agent Zero chat.
|
||||||
- [ ] Choose **Bring Your Own Browser** in Browser settings.
|
- [ ] Choose **Bring Your Own Browser** in Browser settings.
|
||||||
- [ ] Use Chrome, Edge, or Chromium on the host.
|
- [ ] Use a Chromium-family browser on the host: Chrome, Edge, Brave, Opera, Vivaldi, or Chromium.
|
||||||
- [ ] 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**.
|
- [ ] 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/..."
|
||||||
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium
|
||||||
If **Bring Your Own Browser** mode fails:
|
If **Bring Your Own Browser** mode fails:
|
||||||
|
|
||||||
- keep A0 CLI connected to the chat;
|
- 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;
|
- run `/browser status` in A0 CLI;
|
||||||
- check that Browser settings still say **Bring Your Own Browser**;
|
- check that Browser settings still say **Bring Your Own Browser**;
|
||||||
- check **Page content access** if page text or screenshots are blocked.
|
- check **Page content access** if page text or screenshots are blocked.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
- Preserve history consistency before saving chats.
|
- Preserve history consistency before saving chats.
|
||||||
- Do not skip persistence for successful loops unless the hook contract explicitly permits it.
|
- 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
|
## Work Guidance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
from helpers.extension import Extension
|
from helpers.extension import Extension
|
||||||
from agent import LoopData
|
from agent import LoopData
|
||||||
from helpers.defer import DeferredTask, THREAD_BACKGROUND
|
from helpers.defer import DeferredTask, THREAD_BACKGROUND
|
||||||
|
from helpers.history import clear_responses_provider_state
|
||||||
|
|
||||||
DATA_NAME_TASK = "_organize_history_task"
|
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):
|
class OrganizeHistory(Extension):
|
||||||
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
|
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
|
||||||
if not self.agent:
|
if not self.agent:
|
||||||
|
|
@ -17,6 +25,6 @@ class OrganizeHistory(Extension):
|
||||||
|
|
||||||
# start task
|
# start task
|
||||||
task = DeferredTask(thread_name=THREAD_BACKGROUND)
|
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
|
# set to agent to be able to wait for it
|
||||||
self.agent.set_data(DATA_NAME_TASK, task)
|
self.agent.set_data(DATA_NAME_TASK, task)
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,21 @@
|
||||||
|
|
||||||
## Purpose
|
## 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
|
## Ownership
|
||||||
|
|
||||||
- Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
|
- Ordered Python files own current datetime, relevant-skill hints, loaded-skill history reattachment, agent info, parallel job status, and workdir extras injection.
|
||||||
- Active skill instructions belong in prompt protocol.
|
|
||||||
- Explicitly loaded skill bodies belong in tool-result history with metadata so they can survive persistence and be reattached after compaction.
|
- 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.
|
- 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
|
## Local Contracts
|
||||||
|
|
||||||
- Keep injected content bounded and clearly attributed.
|
- Keep injected content bounded and clearly attributed.
|
||||||
- Preserve ordering where later prompt extras depend on earlier recall or load results.
|
- Preserve ordering where later prompt extras depend on earlier recall or load results.
|
||||||
- Do not expose secrets or private files from workdir extras.
|
- 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
|
## Work Guidance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@ class RecallRelevantSkills(Extension):
|
||||||
if not self.agent or loop_data.iteration != 0:
|
if not self.agent or loop_data.iteration != 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
user_instruction = (
|
content = loop_data.user_message.content if loop_data.user_message else ""
|
||||||
loop_data.user_message.output_text() if loop_data.user_message else ""
|
if isinstance(content, dict):
|
||||||
).strip()
|
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:
|
if len(user_instruction) < 8:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ class IncludeLoadedSkills(Extension):
|
||||||
if not self.agent:
|
if not self.agent:
|
||||||
return
|
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)
|
skill_names = skills.get_loaded_skill_names(self.agent)
|
||||||
if not skill_names:
|
if not skill_names:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
from helpers.extension import Extension
|
from helpers.extension import Extension
|
||||||
from agent import LoopData
|
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
|
from helpers.defer import DeferredTask, THREAD_BACKGROUND
|
||||||
|
|
||||||
MAX_SYNC_COMPRESSION_PASSES = 64
|
MAX_SYNC_COMPRESSION_PASSES = 64
|
||||||
|
|
@ -33,7 +36,7 @@ class OrganizeHistoryWait(Extension):
|
||||||
else:
|
else:
|
||||||
# no task was running, start and wait
|
# no task was running, start and wait
|
||||||
self.agent.context.log.set_progress("Compressing history...")
|
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()
|
after_tokens = self.agent.history.get_tokens()
|
||||||
if not compressed or after_tokens >= before_tokens:
|
if not compressed or after_tokens >= before_tokens:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
- Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections.
|
- 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
|
## Local Contracts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
result = agent.read_prompt("agent.system.projects.main.md")
|
||||||
project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
|
project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
|
||||||
if loop_data:
|
if loop_data:
|
||||||
|
loop_data.protocol_persistent.pop("agents_md_instructions", None)
|
||||||
loop_data.protocol_persistent.pop("project_instructions", None)
|
loop_data.protocol_persistent.pop("project_instructions", None)
|
||||||
if project_name:
|
if project_name:
|
||||||
project_vars = projects.build_system_prompt_vars(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"):
|
if loop_data and project_vars.get("project_instructions"):
|
||||||
loop_data.protocol_persistent["project_instructions"] = agent.read_prompt(
|
loop_data.protocol_persistent["project_instructions"] = agent.read_prompt(
|
||||||
"agent.protocol.projects.instructions.md",
|
"agent.protocol.projects.instructions.md",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
- Preserve public helper APIs used by core code and plugins unless all callers, docs, and tests are updated.
|
- 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.
|
- 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.
|
- 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.
|
- Do not hardcode secrets, provider keys, local absolute paths, or environment-specific values.
|
||||||
- Use `RepairableException` for errors an agent may be able to fix.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ class BackupService:
|
||||||
return f"""# User data
|
return f"""# User data
|
||||||
# All persistent user data is now centralized in /usr for easier backup and restore
|
# All persistent user data is now centralized in /usr for easier backup and restore
|
||||||
{agent_root}/usr/**
|
{agent_root}/usr/**
|
||||||
|
!{agent_root}/usr/.time_travel/**
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _get_agent_zero_version(self) -> str:
|
def _get_agent_zero_version(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, settings/state persistence, secret handling.
|
- 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`.
|
- 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.
|
- `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
|
## Key Concepts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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:
|
def _merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent:
|
||||||
if isinstance(a, str) and isinstance(b, str):
|
if isinstance(a, str) and isinstance(b, str):
|
||||||
return a + "\n" + b
|
return a + "\n" + b
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@
|
||||||
- `group_messages_abab(messages: list[BaseMessage]) -> list[BaseMessage]`
|
- `group_messages_abab(messages: list[BaseMessage]) -> list[BaseMessage]`
|
||||||
- `output_langchain(messages: list[OutputMessage])`
|
- `output_langchain(messages: list[OutputMessage])`
|
||||||
- `output_text(messages: list[OutputMessage], ai_label=..., human_label=...)`
|
- `output_text(messages: list[OutputMessage], ai_label=..., human_label=...)`
|
||||||
|
- `clear_responses_provider_state(agent) -> None`
|
||||||
- `_merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent`
|
- `_merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent`
|
||||||
- `_merge_properties(a: Dict[str, MessageContent], b: Dict[str, MessageContent]) -> Dict[str, MessageContent]`
|
- `_merge_properties(a: Dict[str, MessageContent], b: Dict[str, MessageContent]) -> Dict[str, MessageContent]`
|
||||||
- `_is_raw_message(obj: object) -> bool`
|
- `_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.
|
- 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.
|
- 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.
|
- 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`.
|
- Imported dependency areas include: `abc`, `asyncio`, `collections`, `collections.abc`, `enum`, `helpers`, `json`, `langchain_core.messages`, `math`, `plugins._model_config.helpers.model_config`, `typing`, `uuid`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from contextlib import AsyncExitStack
|
||||||
from shutil import which
|
from shutil import which
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import json
|
import json
|
||||||
|
import shlex
|
||||||
import uuid
|
import uuid
|
||||||
from helpers import errors
|
from helpers import errors
|
||||||
from helpers import settings
|
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()]
|
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):
|
def initialize_mcp(mcp_servers_config: str):
|
||||||
if not MCPConfig.get_instance().is_initialized():
|
if not MCPConfig.get_instance().is_initialized():
|
||||||
try:
|
try:
|
||||||
|
|
@ -649,6 +688,15 @@ class MCPServerLocal(BaseModel):
|
||||||
|
|
||||||
def update(self, config: dict[str, Any]) -> "MCPServerLocal":
|
def update(self, config: dict[str, Any]) -> "MCPServerLocal":
|
||||||
with self.__lock:
|
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():
|
for key, value in config.items():
|
||||||
if key in [
|
if key in [
|
||||||
"name",
|
"name",
|
||||||
|
|
@ -665,11 +713,15 @@ class MCPServerLocal(BaseModel):
|
||||||
"disabled_tools",
|
"disabled_tools",
|
||||||
"scope",
|
"scope",
|
||||||
]:
|
]:
|
||||||
|
if key in ["command", "args"]:
|
||||||
|
continue
|
||||||
if key == "name":
|
if key == "name":
|
||||||
value = normalize_name(value)
|
value = normalize_name(value)
|
||||||
if key == "disabled_tools":
|
if key == "disabled_tools":
|
||||||
value = _normalize_disabled_tools(value)
|
value = _normalize_disabled_tools(value)
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
self.command = command
|
||||||
|
self.args = args
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def initialize(self) -> "MCPServerLocal":
|
async def initialize(self) -> "MCPServerLocal":
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@
|
||||||
- `_is_streaming_http_type(server_type: str) -> bool`: Check if the server type is a streaming HTTP variant.
|
- `_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.
|
- `_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.
|
- `_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)`
|
- `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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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 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 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.
|
- 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.
|
- 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.
|
- 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
|
## Key Concepts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -487,37 +487,36 @@ async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
|
||||||
|
|
||||||
worker_agent = worker_context.agent0
|
worker_agent = worker_context.agent0
|
||||||
worker_agent.loop_data = LoopData()
|
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:
|
finally:
|
||||||
if worker_context:
|
if worker_context:
|
||||||
await _remove_context(worker_context.id)
|
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":
|
if tool_name == "parallel":
|
||||||
raise ValueError("`parallel` cannot be nested inside a parallel worker.")
|
raise ValueError("`parallel` cannot be nested inside a parallel worker.")
|
||||||
|
|
||||||
tool = None
|
tool = _resolve_parallel_tool(agent, tool_name, tool_args, strict=True)
|
||||||
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,
|
|
||||||
)
|
|
||||||
if not tool:
|
if not tool:
|
||||||
raise ValueError(f"Tool '{tool_name}' not found or could not be initialized.")
|
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
|
agent.loop_data.current_tool = tool
|
||||||
try:
|
try:
|
||||||
await agent.handle_intervention()
|
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()
|
await agent.handle_intervention()
|
||||||
return response.message
|
return response.message
|
||||||
finally:
|
finally:
|
||||||
|
if original_get_log_object is not None:
|
||||||
|
tool.get_log_object = original_get_log_object
|
||||||
agent.loop_data.current_tool = None
|
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(
|
async def _cancel_job(
|
||||||
job: ParallelJob,
|
job: ParallelJob,
|
||||||
*,
|
*,
|
||||||
|
|
@ -600,6 +653,17 @@ def _log_parallel_child_started(agent: "Agent", job: ParallelJob) -> None:
|
||||||
)
|
)
|
||||||
return
|
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}'"
|
heading = f"icon://construction {agent.agent_name}: Using tool '{job.tool_name}'"
|
||||||
job.log_item = agent.context.log.log(
|
job.log_item = agent.context.log.log(
|
||||||
type="tool",
|
type="tool",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@
|
||||||
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Prompt extras must stay bounded and expose only job IDs, tool names, status, and compact result/error summaries.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import os
|
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 import files, dirty_json, persist_chat, file_tree, extension
|
||||||
from helpers.print_style import PrintStyle
|
from helpers.print_style import PrintStyle
|
||||||
|
|
@ -15,7 +15,13 @@ PROJECT_KNOWLEDGE_DIR = "knowledge"
|
||||||
PROJECT_SKILLS_DIR = "skills"
|
PROJECT_SKILLS_DIR = "skills"
|
||||||
PROJECT_HEADER_FILE = "project.json"
|
PROJECT_HEADER_FILE = "project.json"
|
||||||
PROJECT_MCP_SERVERS_FILE = "mcp_servers.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}'
|
DEFAULT_MCP_SERVERS_CONFIG = '{\n "mcpServers": {}\n}'
|
||||||
|
|
||||||
CONTEXT_DATA_KEY_PROJECT = "project"
|
CONTEXT_DATA_KEY_PROJECT = "project"
|
||||||
|
|
@ -466,9 +472,10 @@ def deactivate_project_in_chats(name: str):
|
||||||
def build_system_prompt_vars(name: str):
|
def build_system_prompt_vars(name: str):
|
||||||
project_data = load_basic_project_data(name)
|
project_data = load_basic_project_data(name)
|
||||||
main_instructions = project_data.get("instructions", "") or ""
|
main_instructions = project_data.get("instructions", "") or ""
|
||||||
|
include_agents_md = project_data.get("include_agents_md", True)
|
||||||
instruction_files = get_project_instruction_files(
|
instruction_files = get_project_instruction_files(
|
||||||
name,
|
name,
|
||||||
include_agents_md=project_data.get("include_agents_md", True),
|
include_agents_md=include_agents_md,
|
||||||
)
|
)
|
||||||
instruction_parts = [
|
instruction_parts = [
|
||||||
main_instructions,
|
main_instructions,
|
||||||
|
|
@ -481,11 +488,75 @@ def build_system_prompt_vars(name: str):
|
||||||
"project_name": project_data.get("title", ""),
|
"project_name": project_data.get("title", ""),
|
||||||
"project_description": project_data.get("description", ""),
|
"project_description": project_data.get("description", ""),
|
||||||
"project_instructions": complete_instructions or "",
|
"project_instructions": complete_instructions or "",
|
||||||
|
"include_agents_md": include_agents_md,
|
||||||
"project_path": files.normalize_a0_path(get_project_folder(name)),
|
"project_path": files.normalize_a0_path(get_project_folder(name)),
|
||||||
"project_git_url": project_data.get("git_url", ""),
|
"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):
|
def get_additional_instructions_files(name: str):
|
||||||
instructions_folder = files.get_abs_path(
|
instructions_folder = files.get_abs_path(
|
||||||
get_project_folder(name), PROJECT_META_DIR, PROJECT_INSTRUCTIONS_DIR
|
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:
|
def get_project_agents_md_instruction_file(name: str) -> tuple[str, str] | None:
|
||||||
project_folder = get_project_folder(name)
|
project_folder = get_project_folder(name)
|
||||||
for filename in PROJECT_AGENTS_MD_FILES:
|
for path, content in get_agents_md_chain(project_folder, project_folder):
|
||||||
matches = files.read_text_files_in_dir(project_folder, pattern=filename)
|
return (files.normalize_a0_path(path), content)
|
||||||
if filename in matches:
|
|
||||||
path = files.get_abs_path(project_folder, filename)
|
|
||||||
return (files.normalize_a0_path(path), matches[filename])
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@
|
||||||
- `reactivate_project_in_chats(name: str)`
|
- `reactivate_project_in_chats(name: str)`
|
||||||
- `deactivate_project_in_chats(name: str)`
|
- `deactivate_project_in_chats(name: str)`
|
||||||
- `build_system_prompt_vars(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_additional_instructions_files(name: str)`
|
||||||
- `get_project_instruction_files(name: str, include_agents_md: bool=...) -> list[tuple[str, 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`
|
- `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 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 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.
|
- 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 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`.
|
- 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.
|
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
|
||||||
|
|
|
||||||
|
|
@ -543,11 +543,15 @@ def search_skills(
|
||||||
if not q:
|
if not q:
|
||||||
return []
|
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 = [
|
terms = [
|
||||||
t for t in raw_terms
|
t for t in raw_terms
|
||||||
if len(t) >= 3 or any(ch.isdigit() for ch in t)
|
if len(t) >= 4 or any(ch.isdigit() for ch in t)
|
||||||
] or raw_terms
|
]
|
||||||
|
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)
|
candidates = list_skills(agent, include_hidden=include_hidden)
|
||||||
|
|
||||||
scored: List[Tuple[int, Skill]] = []
|
scored: List[Tuple[int, Skill]] = []
|
||||||
|
|
@ -568,14 +572,13 @@ def search_skills(
|
||||||
score += 4
|
score += 4
|
||||||
if any(q in tag for tag in tags):
|
if any(q in tag for tag in tags):
|
||||||
score += 3
|
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
|
score += 8
|
||||||
|
|
||||||
for term in terms:
|
for term in terms:
|
||||||
if term in name:
|
if term in name:
|
||||||
score += 3
|
score += 3
|
||||||
if term in desc:
|
for term in long_terms:
|
||||||
score += 2
|
|
||||||
if any(term in tag for tag in tags):
|
if any(term in tag for tag in tags):
|
||||||
score += 1
|
score += 1
|
||||||
if any(term in trigger for trigger in triggers):
|
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,
|
CONTEXT_DATA_NAME_CHAT_VISIBLE_SKILLS,
|
||||||
visible_entries,
|
visible_entries,
|
||||||
)
|
)
|
||||||
unload_agent_skill(agent, normalized)
|
|
||||||
return get_hidden_skills(agent)
|
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:
|
def build_active_skills_prompt(agent: Agent | None) -> str:
|
||||||
items = _resolve_active_skill_entries(agent, get_active_skills(agent))
|
return ""
|
||||||
return "\n\n".join(item["content"] for item in items if item.get("content")).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _format_skill_prompt(skill: Skill) -> str:
|
def _format_skill_prompt(skill: Skill) -> str:
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@
|
||||||
- Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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`.
|
- Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ Direct child DOX files:
|
||||||
| [_browser/AGENTS.md](_browser/AGENTS.md) | Playwright browser tool, helpers, viewer, and browser panel UI. |
|
| [_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_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_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. |
|
| [_office/AGENTS.md](_office/AGENTS.md) | LibreOffice office artifacts and office canvas sessions. |
|
||||||
| [_onboarding/AGENTS.md](_onboarding/AGENTS.md) | First-time model onboarding wizard. |
|
| [_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_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_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. |
|
| [_plugin_validator/AGENTS.md](_plugin_validator/AGENTS.md) | Plugin manifest, structure, convention, and security validator. |
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
- File operation results may arrive as chunked JSON/base64
|
- File operation results may arrive as chunked JSON/base64
|
||||||
`connector_file_op_result` frames; resolve the pending file operation only
|
`connector_file_op_result` frames; resolve the pending file operation only
|
||||||
after all chunks for the `op_id` are assembled.
|
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
|
## Work Guidance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ def _normalize_requested_backend(value: object) -> str:
|
||||||
return ""
|
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:
|
def _normalize_profile_mode(value: object) -> str:
|
||||||
normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
|
normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
|
||||||
if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}:
|
if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}:
|
||||||
|
|
@ -68,6 +73,10 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
|
||||||
mimetype="application/json",
|
mimetype="application/json",
|
||||||
)
|
)
|
||||||
settings["runtime_backend"] = runtime_backend
|
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:
|
if "host_browser_profile_mode" in input or "profile_mode" in input:
|
||||||
profile_mode = _normalize_profile_mode(
|
profile_mode = _normalize_profile_mode(
|
||||||
input.get("host_browser_profile_mode", input.get("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"
|
runtime_backend = settings.get("runtime_backend") or "container"
|
||||||
profile_mode = _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"runtime_backend": runtime_backend,
|
"runtime_backend": runtime_backend,
|
||||||
"host_browser_profile_mode": profile_mode,
|
"host_browser_profile_mode": profile_mode,
|
||||||
|
"host_browser_selection": browser_selection,
|
||||||
"label": _runtime_label(runtime_backend),
|
"label": _runtime_label(runtime_backend),
|
||||||
"project_name": project_name,
|
"project_name": project_name,
|
||||||
"agent_profile": "",
|
"agent_profile": "",
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,9 @@ class HostBrowserMetadata:
|
||||||
profile_label: str
|
profile_label: str
|
||||||
profile_path: str
|
profile_path: str
|
||||||
cdp_endpoint: str
|
cdp_endpoint: str
|
||||||
|
browser_id: str
|
||||||
|
browser_label: str
|
||||||
|
available_browsers: tuple[dict[str, Any], ...]
|
||||||
content_helper_sha256: str
|
content_helper_sha256: str
|
||||||
features: tuple[str, ...]
|
features: tuple[str, ...]
|
||||||
support_reason: 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_label=str(payload.get("profile_label", "") or "").strip(),
|
||||||
profile_path=str(payload.get("profile_path", "") or "").strip(),
|
profile_path=str(payload.get("profile_path", "") or "").strip(),
|
||||||
cdp_endpoint=str(payload.get("cdp_endpoint", "") 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(),
|
content_helper_sha256=str(payload.get("content_helper_sha256", "") or "").strip().lower(),
|
||||||
features=features,
|
features=features,
|
||||||
support_reason=support_reason,
|
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:
|
def clear_sid_host_browser_metadata(sid: str) -> None:
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
_sid_host_browser_metadata.pop(sid, None)
|
_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_label": metadata.profile_label,
|
||||||
"profile_path": metadata.profile_path,
|
"profile_path": metadata.profile_path,
|
||||||
"cdp_endpoint": metadata.cdp_endpoint,
|
"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,
|
"content_helper_sha256": metadata.content_helper_sha256,
|
||||||
"features": list(metadata.features),
|
"features": list(metadata.features),
|
||||||
"support_reason": metadata.support_reason,
|
"support_reason": metadata.support_reason,
|
||||||
|
|
@ -529,6 +564,10 @@ def all_host_browser_metadata() -> list[dict[str, Any]]:
|
||||||
"browser_family": metadata.browser_family,
|
"browser_family": metadata.browser_family,
|
||||||
"profile_label": metadata.profile_label,
|
"profile_label": metadata.profile_label,
|
||||||
"profile_path": metadata.profile_path,
|
"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,
|
"content_helper_sha256": metadata.content_helper_sha256,
|
||||||
"features": list(metadata.features),
|
"features": list(metadata.features),
|
||||||
"support_reason": metadata.support_reason,
|
"support_reason": metadata.support_reason,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,15 @@
|
||||||
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Do not hardcode user-specific browser paths or secrets.
|
||||||
|
|
||||||
|
|
@ -34,6 +42,7 @@
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes.
|
- 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.
|
- Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills.
|
||||||
|
|
||||||
## Child DOX Index
|
## Child DOX Index
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
import time
|
import time
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
@ -8,14 +9,20 @@ from typing import Any, ClassVar
|
||||||
from agent import AgentContext
|
from agent import AgentContext
|
||||||
from helpers.ws import WsHandler
|
from helpers.ws import WsHandler
|
||||||
from helpers.ws_manager import WsResult
|
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
|
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_RETRY_DELAY_SECONDS = 0.5
|
||||||
FRAME_STATE_REFRESH_SECONDS = 0.75
|
FRAME_STATE_REFRESH_SECONDS = 0.75
|
||||||
SNAPSHOT_STATE_POLL_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_SCREENCAST = "screencast"
|
||||||
VIEWER_TRANSPORT_SNAPSHOT = "snapshot"
|
VIEWER_TRANSPORT_SNAPSHOT = "snapshot"
|
||||||
VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT}
|
VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT}
|
||||||
|
|
@ -97,24 +104,40 @@ class WsBrowser(WsHandler):
|
||||||
existing.cancel()
|
existing.cancel()
|
||||||
viewer_id = str(data.get("viewer_id") or "")
|
viewer_id = str(data.get("viewer_id") or "")
|
||||||
viewer_transport = self._viewer_transport(data)
|
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
|
snapshot = None
|
||||||
if runtime:
|
if runtime:
|
||||||
if viewer_transport == VIEWER_TRANSPORT_SCREENCAST:
|
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:
|
else:
|
||||||
stream_task = self._stream_state(sid, context_id, active_id, viewer_id)
|
stream_task = self._stream_state(sid, context_id, active_id, viewer_id)
|
||||||
self._streams[stream_key] = asyncio.create_task(stream_task)
|
self._streams[stream_key] = asyncio.create_task(stream_task)
|
||||||
snapshot = await self._snapshot_for_browser(runtime, active_id)
|
snapshot = await self._snapshot_for_browser(runtime, active_id)
|
||||||
|
|
||||||
|
browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"context_id": context_id,
|
"context_id": context_id,
|
||||||
"active_browser_context_id": context_id,
|
"active_browser_context_id": context_id,
|
||||||
"active_browser_id": active_id,
|
"active_browser_id": active_id,
|
||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
"browsers": await self._all_browser_tabs(),
|
"browsers": browsers,
|
||||||
"all_browsers": True,
|
"all_browsers": all_browsers,
|
||||||
|
"tab_scope": tab_scope,
|
||||||
"viewer_id": viewer_id,
|
"viewer_id": viewer_id,
|
||||||
"viewer_transport": viewer_transport,
|
"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:
|
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}
|
return {"context_id": context_id, "unsubscribed": True}
|
||||||
|
|
||||||
async def _sessions(self, data: dict[str, Any]) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"context_id": self._context_id(data),
|
"context_id": context_id,
|
||||||
"browsers": await self._all_browser_tabs(),
|
"browsers": listing.get("browsers") or [],
|
||||||
"all_browsers": True,
|
"all_browsers": False,
|
||||||
|
"tab_scope": tab_scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _snapshot(self, data: dict[str, Any]) -> dict[str, Any] | WsResult:
|
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)
|
runtime = await get_runtime(context_id, create=False)
|
||||||
if not runtime:
|
if not runtime:
|
||||||
|
browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, [])
|
||||||
return {
|
return {
|
||||||
"context_id": context_id,
|
"context_id": context_id,
|
||||||
"active_browser_context_id": context_id,
|
"active_browser_context_id": context_id,
|
||||||
"active_browser_id": None,
|
"active_browser_id": None,
|
||||||
"snapshot": None,
|
"snapshot": None,
|
||||||
"browsers": await self._all_browser_tabs(),
|
"browsers": browsers,
|
||||||
"all_browsers": True,
|
"all_browsers": all_browsers,
|
||||||
|
"tab_scope": tab_scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
listing = await runtime.call("list")
|
listing = await runtime.call("list")
|
||||||
|
|
@ -157,9 +195,9 @@ class WsBrowser(WsHandler):
|
||||||
snapshot = None
|
snapshot = None
|
||||||
if active_id:
|
if active_id:
|
||||||
try:
|
try:
|
||||||
quality = int(data.get("quality") or SCREENCAST_QUALITY)
|
quality = int(data.get("quality") or SCREENSHOT_QUALITY)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
quality = SCREENCAST_QUALITY
|
quality = SCREENSHOT_QUALITY
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
snapshot = await runtime.call(
|
snapshot = await runtime.call(
|
||||||
"screenshot",
|
"screenshot",
|
||||||
|
|
@ -167,13 +205,16 @@ class WsBrowser(WsHandler):
|
||||||
quality=quality,
|
quality=quality,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"context_id": context_id,
|
"context_id": context_id,
|
||||||
"active_browser_context_id": context_id,
|
"active_browser_context_id": context_id,
|
||||||
"active_browser_id": active_id,
|
"active_browser_id": active_id,
|
||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
"browsers": await self._all_browser_tabs(),
|
"browsers": browsers,
|
||||||
"all_browsers": True,
|
"all_browsers": all_browsers,
|
||||||
|
"tab_scope": tab_scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _command(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
|
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")
|
listing = await runtime.call("list")
|
||||||
last_interacted_browser_id = listing.get("last_interacted_browser_id")
|
last_interacted_browser_id = listing.get("last_interacted_browser_id")
|
||||||
snapshot = await self._snapshot_for_result(runtime, result)
|
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(
|
await self.emit_to(
|
||||||
sid,
|
sid,
|
||||||
"browser_viewer_state",
|
"browser_viewer_state",
|
||||||
|
|
@ -220,8 +264,9 @@ class WsBrowser(WsHandler):
|
||||||
"browser_id": browser_id,
|
"browser_id": browser_id,
|
||||||
"result": result,
|
"result": result,
|
||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
"browsers": all_browsers,
|
"browsers": browsers,
|
||||||
"all_browsers": True,
|
"all_browsers": all_browsers,
|
||||||
|
"tab_scope": tab_scope,
|
||||||
"last_interacted_browser_id": last_interacted_browser_id,
|
"last_interacted_browser_id": last_interacted_browser_id,
|
||||||
"viewer_transport": self._viewer_transport(data),
|
"viewer_transport": self._viewer_transport(data),
|
||||||
},
|
},
|
||||||
|
|
@ -230,8 +275,9 @@ class WsBrowser(WsHandler):
|
||||||
return {
|
return {
|
||||||
"result": result,
|
"result": result,
|
||||||
"snapshot": snapshot,
|
"snapshot": snapshot,
|
||||||
"browsers": all_browsers,
|
"browsers": browsers,
|
||||||
"all_browsers": True,
|
"all_browsers": all_browsers,
|
||||||
|
"tab_scope": tab_scope,
|
||||||
"active_browser_context_id": context_id,
|
"active_browser_context_id": context_id,
|
||||||
"last_interacted_browser_id": last_interacted_browser_id,
|
"last_interacted_browser_id": last_interacted_browser_id,
|
||||||
"command": command,
|
"command": command,
|
||||||
|
|
@ -347,7 +393,7 @@ class WsBrowser(WsHandler):
|
||||||
if not browser_id:
|
if not browser_id:
|
||||||
return None
|
return None
|
||||||
with contextlib.suppress(Exception):
|
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
|
return None
|
||||||
|
|
||||||
async def _snapshot_for_browser(
|
async def _snapshot_for_browser(
|
||||||
|
|
@ -358,9 +404,27 @@ class WsBrowser(WsHandler):
|
||||||
if not browser_id:
|
if not browser_id:
|
||||||
return None
|
return None
|
||||||
with contextlib.suppress(Exception):
|
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
|
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]]:
|
async def _all_browser_tabs(self) -> list[dict[str, Any]]:
|
||||||
browsers: list[dict[str, Any]] = []
|
browsers: list[dict[str, Any]] = []
|
||||||
for session in await list_runtime_sessions():
|
for session in await list_runtime_sessions():
|
||||||
|
|
@ -377,6 +441,10 @@ class WsBrowser(WsHandler):
|
||||||
context_id: str,
|
context_id: str,
|
||||||
browser_id: int | str | None,
|
browser_id: int | str | None,
|
||||||
viewer_id: str = "",
|
viewer_id: str = "",
|
||||||
|
*,
|
||||||
|
binary_frames: bool = False,
|
||||||
|
slim_frames: bool = False,
|
||||||
|
capture_scale: float = 1.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
runtime = None
|
runtime = None
|
||||||
stream_id = None
|
stream_id = None
|
||||||
|
|
@ -397,12 +465,14 @@ class WsBrowser(WsHandler):
|
||||||
browsers = listing.get("browsers") or []
|
browsers = listing.get("browsers") or []
|
||||||
active_id = self._active_browser_id(listing, browser_id)
|
active_id = self._active_browser_id(listing, browser_id)
|
||||||
if not active_id:
|
if not active_id:
|
||||||
await self._emit_empty_frame(
|
await self._emit_viewer_state(
|
||||||
sid,
|
sid,
|
||||||
context_id,
|
context_id,
|
||||||
|
active_id,
|
||||||
browsers=browsers,
|
browsers=browsers,
|
||||||
viewer_id=viewer_id,
|
viewer_id=viewer_id,
|
||||||
frame_source=VIEWER_TRANSPORT_SCREENCAST,
|
state=None,
|
||||||
|
viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
|
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
|
||||||
continue
|
continue
|
||||||
|
|
@ -410,30 +480,60 @@ class WsBrowser(WsHandler):
|
||||||
screencast = await runtime.call(
|
screencast = await runtime.call(
|
||||||
"start_screencast",
|
"start_screencast",
|
||||||
active_id,
|
active_id,
|
||||||
quality=SCREENCAST_QUALITY,
|
quality=SCREENCAST_STREAM_QUALITY,
|
||||||
every_nth_frame=1,
|
every_nth_frame=1,
|
||||||
|
capture_scale=capture_scale,
|
||||||
)
|
)
|
||||||
stream_id = screencast["stream_id"]
|
stream_id = screencast["stream_id"]
|
||||||
active_id = screencast["browser_id"]
|
active_id = screencast["browser_id"]
|
||||||
state = screencast.get("state")
|
state = screencast.get("state")
|
||||||
await self.emit_to(
|
await self._emit_viewer_state(
|
||||||
sid,
|
sid,
|
||||||
"browser_viewer_frame",
|
context_id,
|
||||||
{
|
active_id,
|
||||||
"context_id": context_id,
|
browsers=browsers,
|
||||||
"viewer_id": viewer_id,
|
viewer_id=viewer_id,
|
||||||
"browser_id": active_id,
|
state=state,
|
||||||
"browsers": browsers,
|
viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
|
||||||
"image": "",
|
|
||||||
"mime": "",
|
|
||||||
"state": state,
|
|
||||||
"frame_source": "state",
|
|
||||||
"viewer_transport": VIEWER_TRANSPORT_SCREENCAST,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
last_state_refresh = 0.0
|
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:
|
while True:
|
||||||
|
if stop_event.is_set():
|
||||||
|
break
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
|
if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
|
||||||
listing = await runtime.call("list")
|
listing = await runtime.call("list")
|
||||||
|
|
@ -442,24 +542,25 @@ class WsBrowser(WsHandler):
|
||||||
if str(active_id) not in browser_ids:
|
if str(active_id) not in browser_ids:
|
||||||
break
|
break
|
||||||
state = self._state_for_browser(browsers, active_id, state)
|
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
|
last_state_refresh = now
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frame = await runtime.call("pop_screencast_frame", stream_id)
|
await asyncio.wait_for(stop_event.wait(), timeout=FRAME_READ_TIMEOUT_SECONDS)
|
||||||
except KeyError:
|
|
||||||
break
|
break
|
||||||
if frame is None:
|
except TimeoutError:
|
||||||
await asyncio.sleep(FRAME_IDLE_POLL_SECONDS)
|
|
||||||
continue
|
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:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -512,20 +613,14 @@ class WsBrowser(WsHandler):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if signature != last_signature:
|
if signature != last_signature:
|
||||||
await self.emit_to(
|
await self._emit_viewer_state(
|
||||||
sid,
|
sid,
|
||||||
"browser_viewer_frame",
|
context_id,
|
||||||
{
|
active_id,
|
||||||
"context_id": context_id,
|
browsers=browsers,
|
||||||
"viewer_id": viewer_id,
|
viewer_id=viewer_id,
|
||||||
"browser_id": active_id,
|
state=state,
|
||||||
"browsers": browsers,
|
viewer_transport=VIEWER_TRANSPORT_SNAPSHOT,
|
||||||
"image": "",
|
|
||||||
"mime": "",
|
|
||||||
"state": state,
|
|
||||||
"frame_source": VIEWER_TRANSPORT_SNAPSHOT,
|
|
||||||
"viewer_transport": VIEWER_TRANSPORT_SNAPSHOT,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
last_signature = signature
|
last_signature = signature
|
||||||
await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
|
await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
|
||||||
|
|
@ -564,6 +659,107 @@ class WsBrowser(WsHandler):
|
||||||
return browser
|
return browser
|
||||||
return current_state
|
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(
|
async def _emit_empty_frame(
|
||||||
self,
|
self,
|
||||||
sid: str,
|
sid: str,
|
||||||
|
|
@ -573,7 +769,8 @@ class WsBrowser(WsHandler):
|
||||||
viewer_id: str = "",
|
viewer_id: str = "",
|
||||||
frame_source: str = "",
|
frame_source: str = "",
|
||||||
) -> None:
|
) -> 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,
|
sid,
|
||||||
"browser_viewer_frame",
|
"browser_viewer_frame",
|
||||||
{
|
{
|
||||||
|
|
@ -581,6 +778,8 @@ class WsBrowser(WsHandler):
|
||||||
"viewer_id": viewer_id,
|
"viewer_id": viewer_id,
|
||||||
"browser_id": None,
|
"browser_id": None,
|
||||||
"browsers": browsers or [],
|
"browsers": browsers or [],
|
||||||
|
"all_browsers": all_browsers,
|
||||||
|
"tab_scope": tab_scope,
|
||||||
"image": "",
|
"image": "",
|
||||||
"mime": "",
|
"mime": "",
|
||||||
"state": None,
|
"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
|
@staticmethod
|
||||||
def _viewer_transport(data: dict[str, Any]) -> str:
|
def _viewer_transport(data: dict[str, Any]) -> str:
|
||||||
raw = (
|
raw = (
|
||||||
|
|
@ -620,6 +833,20 @@ class WsBrowser(WsHandler):
|
||||||
"height": max(200, min(4096, height)),
|
"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
|
@staticmethod
|
||||||
def _context_id(data: dict[str, Any]) -> str:
|
def _context_id(data: dict[str, Any]) -> str:
|
||||||
return str(data.get("context_id") or data.get("context") or "").strip()
|
return str(data.get("context_id") or data.get("context") or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ default_homepage: "about:blank"
|
||||||
# When the Browser surface is already open, keep it synced to agent Browser tool results.
|
# When the Browser surface is already open, keep it synced to agent Browser tool results.
|
||||||
autofocus_active_page: true
|
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.
|
# Maximum number of Browser tabs/pages a single chat context may keep open.
|
||||||
# Raise this only for deliberate parallel browsing workflows.
|
# Raise this only for deliberate parallel browsing workflows.
|
||||||
max_open_tabs: 32
|
max_open_tabs: 32
|
||||||
|
|
@ -28,6 +33,11 @@ host_browser_privacy_policy: "allow"
|
||||||
# - agent: use a clean A0-controlled browser profile on the host.
|
# - agent: use a clean A0-controlled browser profile on the host.
|
||||||
host_browser_profile_mode: "existing"
|
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.
|
# Optional _model_config preset used by Browser-owned model helpers.
|
||||||
# Empty uses the effective Main Model.
|
# Empty uses the effective Main Model.
|
||||||
model_preset: ""
|
model_preset: ""
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,17 @@ PLUGIN_NAME = "_browser"
|
||||||
MODEL_PRESET_KEY = "model_preset"
|
MODEL_PRESET_KEY = "model_preset"
|
||||||
DEFAULT_HOMEPAGE_KEY = "default_homepage"
|
DEFAULT_HOMEPAGE_KEY = "default_homepage"
|
||||||
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
|
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
|
||||||
|
TAB_SCOPE_KEY = "browser_tab_scope"
|
||||||
MAX_OPEN_TABS_KEY = "max_open_tabs"
|
MAX_OPEN_TABS_KEY = "max_open_tabs"
|
||||||
RUNTIME_BACKEND_KEY = "runtime_backend"
|
RUNTIME_BACKEND_KEY = "runtime_backend"
|
||||||
HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
|
HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
|
||||||
HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
|
HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
|
||||||
|
HOST_BROWSER_SELECTION_KEY = "host_browser_selection"
|
||||||
RUNTIME_BACKENDS = {"container", "host_required"}
|
RUNTIME_BACKENDS = {"container", "host_required"}
|
||||||
|
BROWSER_TAB_SCOPES = {"per_context", "shared"}
|
||||||
HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
|
HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
|
||||||
HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
|
HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
|
||||||
|
DEFAULT_BROWSER_TAB_SCOPE = "per_context"
|
||||||
DEFAULT_MAX_OPEN_TABS = 32
|
DEFAULT_MAX_OPEN_TABS = 32
|
||||||
MIN_MAX_OPEN_TABS = 1
|
MIN_MAX_OPEN_TABS = 1
|
||||||
HARD_MAX_OPEN_TABS = 50
|
HARD_MAX_OPEN_TABS = 50
|
||||||
|
|
@ -55,6 +59,15 @@ def _normalize_model_preset(value: Any) -> str:
|
||||||
return str(value or "").strip()
|
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:
|
def _normalize_default_homepage(value: Any) -> str:
|
||||||
homepage = str(value or "").strip()
|
homepage = str(value or "").strip()
|
||||||
return homepage or "about:blank"
|
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),
|
raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
|
||||||
default=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(
|
MAX_OPEN_TABS_KEY: _normalize_int(
|
||||||
raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS),
|
raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS),
|
||||||
default=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,
|
allowed=HOST_BROWSER_PROFILE_MODES,
|
||||||
default="existing",
|
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, "")),
|
MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@ HOST_BROWSER_PROFILE_MODE_KEY = getattr(
|
||||||
"HOST_BROWSER_PROFILE_MODE_KEY",
|
"HOST_BROWSER_PROFILE_MODE_KEY",
|
||||||
"host_browser_profile_mode",
|
"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
|
get_browser_config = browser_config.get_browser_config
|
||||||
_LOCAL_PROVIDERS = {"ollama", "lm_studio", "llama_cpp", "omlx", "vllm"}
|
_LOCAL_PROVIDERS = {"ollama", "lm_studio", "llama_cpp", "omlx", "vllm"}
|
||||||
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
|
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
|
||||||
|
|
@ -78,7 +83,8 @@ _REQUIRED_API_NAMES_RE = re.compile(
|
||||||
re.S,
|
re.S,
|
||||||
)
|
)
|
||||||
_HOST_BROWSER_REMOTE_DEBUGGING_HELP = (
|
_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`, '
|
'enable "Allow remote debugging for this browser instance", run `/browser host on`, '
|
||||||
"and retry."
|
"and retry."
|
||||||
)
|
)
|
||||||
|
|
@ -123,6 +129,7 @@ class ConnectorBrowserRuntime:
|
||||||
"context_id": self.context_id,
|
"context_id": self.context_id,
|
||||||
"action": action,
|
"action": action,
|
||||||
"profile_mode": self._host_browser_profile_mode(),
|
"profile_mode": self._host_browser_profile_mode(),
|
||||||
|
"browser_selection": self._host_browser_selection(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if action == "open":
|
if action == "open":
|
||||||
|
|
@ -283,6 +290,7 @@ class ConnectorBrowserRuntime:
|
||||||
"context_id": self.context_id,
|
"context_id": self.context_id,
|
||||||
"action": "ensure",
|
"action": "ensure",
|
||||||
"profile_mode": self._host_browser_profile_mode(),
|
"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()
|
mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower()
|
||||||
return "agent" if mode == "agent" else "existing"
|
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]:
|
def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
return self._with_browser_helpers(sid, payload)
|
return self._with_browser_helpers(sid, payload)
|
||||||
|
|
||||||
|
|
@ -508,7 +520,10 @@ class ConnectorBrowserRuntime:
|
||||||
if not message:
|
if not message:
|
||||||
message = "Host browser operation failed"
|
message = "Host browser operation failed"
|
||||||
normalized = message.lower()
|
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)
|
return _append_docker_browser_recovery(message)
|
||||||
if any(token in normalized for token in _REMOTE_DEBUGGING_ERROR_TOKENS):
|
if any(token in normalized for token in _REMOTE_DEBUGGING_ERROR_TOKENS):
|
||||||
return _append_docker_browser_recovery(
|
return _append_docker_browser_recovery(
|
||||||
|
|
|
||||||
|
|
@ -317,8 +317,11 @@ class _BrowserScreencast:
|
||||||
self.browser_id = browser_id
|
self.browser_id = browser_id
|
||||||
self.session = session
|
self.session = session
|
||||||
self.mime = mime
|
self.mime = mime
|
||||||
|
self.frame_consumer: Any | None = None
|
||||||
|
self.stop_callback: Any | None = None
|
||||||
self.queue = asyncio.Queue(maxsize=1)
|
self.queue = asyncio.Queue(maxsize=1)
|
||||||
self.stopped = False
|
self.stopped = False
|
||||||
|
self._closed = False
|
||||||
self._ack_tasks: set[asyncio.Task] = set()
|
self._ack_tasks: set[asyncio.Task] = set()
|
||||||
self._expected_width = 0
|
self._expected_width = 0
|
||||||
self._expected_height = 0
|
self._expected_height = 0
|
||||||
|
|
@ -329,10 +332,14 @@ class _BrowserScreencast:
|
||||||
quality: int,
|
quality: int,
|
||||||
every_nth_frame: int,
|
every_nth_frame: int,
|
||||||
viewport: dict[str, int],
|
viewport: dict[str, int],
|
||||||
|
capture_scale: float = 1.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session.on("Page.screencastFrame", self._on_frame)
|
self.session.on("Page.screencastFrame", self._on_frame)
|
||||||
width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"])))
|
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"])))
|
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_width = width
|
||||||
self._expected_height = height
|
self._expected_height = height
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
|
|
@ -343,8 +350,8 @@ class _BrowserScreencast:
|
||||||
{
|
{
|
||||||
"format": "jpeg",
|
"format": "jpeg",
|
||||||
"quality": max(20, min(95, int(quality))),
|
"quality": max(20, min(95, int(quality))),
|
||||||
"maxWidth": SCREENCAST_MAX_WIDTH,
|
"maxWidth": max_width,
|
||||||
"maxHeight": SCREENCAST_MAX_HEIGHT,
|
"maxHeight": max_height,
|
||||||
"everyNthFrame": max(1, int(every_nth_frame)),
|
"everyNthFrame": max(1, int(every_nth_frame)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -394,10 +401,21 @@ class _BrowserScreencast:
|
||||||
raise RuntimeError("Browser screencast stopped.")
|
raise RuntimeError("Browser screencast stopped.")
|
||||||
return frame
|
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:
|
async def stop(self) -> None:
|
||||||
if self.stopped:
|
if self._closed:
|
||||||
return
|
return
|
||||||
|
was_stopped = self.stopped
|
||||||
|
self._closed = True
|
||||||
self.stopped = True
|
self.stopped = True
|
||||||
|
if not was_stopped:
|
||||||
|
self._notify_stopped()
|
||||||
self._drop_queued_frames()
|
self._drop_queued_frames()
|
||||||
with contextlib.suppress(asyncio.QueueFull):
|
with contextlib.suppress(asyncio.QueueFull):
|
||||||
self.queue.put_nowait(None)
|
self.queue.put_nowait(None)
|
||||||
|
|
@ -419,6 +437,8 @@ class _BrowserScreencast:
|
||||||
task.add_done_callback(self._ack_tasks.discard)
|
task.add_done_callback(self._ack_tasks.discard)
|
||||||
|
|
||||||
async def _handle_frame(self, params: dict[str, Any]) -> None:
|
async def _handle_frame(self, params: dict[str, Any]) -> None:
|
||||||
|
stop_after_ack = False
|
||||||
|
notify_stop = False
|
||||||
try:
|
try:
|
||||||
data = params.get("data") or ""
|
data = params.get("data") or ""
|
||||||
if data:
|
if data:
|
||||||
|
|
@ -428,7 +448,7 @@ class _BrowserScreencast:
|
||||||
metadata["jpegWidth"], metadata["jpegHeight"] = size
|
metadata["jpegWidth"], metadata["jpegHeight"] = size
|
||||||
metadata["expectedWidth"] = self._expected_width
|
metadata["expectedWidth"] = self._expected_width
|
||||||
metadata["expectedHeight"] = self._expected_height
|
metadata["expectedHeight"] = self._expected_height
|
||||||
self._queue_latest(
|
await self._deliver_frame(
|
||||||
{
|
{
|
||||||
"browser_id": self.browser_id,
|
"browser_id": self.browser_id,
|
||||||
"mime": self.mime,
|
"mime": self.mime,
|
||||||
|
|
@ -436,6 +456,14 @@ class _BrowserScreencast:
|
||||||
"metadata": metadata,
|
"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:
|
finally:
|
||||||
session_id = params.get("sessionId")
|
session_id = params.get("sessionId")
|
||||||
if session_id is not None and not self.stopped:
|
if session_id is not None and not self.stopped:
|
||||||
|
|
@ -444,6 +472,24 @@ class _BrowserScreencast:
|
||||||
"Page.screencastFrameAck",
|
"Page.screencastFrameAck",
|
||||||
{"sessionId": int(session_id)},
|
{"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:
|
def _queue_latest(self, frame: dict[str, Any]) -> None:
|
||||||
self._drop_queued_frames()
|
self._drop_queued_frames()
|
||||||
|
|
@ -1616,6 +1662,7 @@ class _BrowserRuntimeCore:
|
||||||
*,
|
*,
|
||||||
quality: int = 78,
|
quality: int = 78,
|
||||||
every_nth_frame: int = 1,
|
every_nth_frame: int = 1,
|
||||||
|
capture_scale: float = 1.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
await self.ensure_started()
|
await self.ensure_started()
|
||||||
resolved_id = self._resolve_browser_id(browser_id)
|
resolved_id = self._resolve_browser_id(browser_id)
|
||||||
|
|
@ -1634,6 +1681,7 @@ class _BrowserRuntimeCore:
|
||||||
quality=quality,
|
quality=quality,
|
||||||
every_nth_frame=every_nth_frame,
|
every_nth_frame=every_nth_frame,
|
||||||
viewport=page.viewport_size or DEFAULT_VIEWPORT,
|
viewport=page.viewport_size or DEFAULT_VIEWPORT,
|
||||||
|
capture_scale=capture_scale,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.screencasts.pop(stream_id, None)
|
self.screencasts.pop(stream_id, None)
|
||||||
|
|
@ -1663,6 +1711,17 @@ class _BrowserRuntimeCore:
|
||||||
raise KeyError("Browser screencast is not active.")
|
raise KeyError("Browser screencast is not active.")
|
||||||
return await screencast.pop_frame()
|
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:
|
async def stop_screencast(self, stream_id: str) -> None:
|
||||||
screencast = self.screencasts.pop(str(stream_id or ""), None)
|
screencast = self.screencasts.pop(str(stream_id or ""), None)
|
||||||
if screencast:
|
if screencast:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,14 @@ import { callJsonApi } from "/js/api.js";
|
||||||
const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
|
const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
|
||||||
const BROWSER_STATUS_API = "/plugins/_browser/status";
|
const BROWSER_STATUS_API = "/plugins/_browser/status";
|
||||||
const RUNTIME_BACKENDS = new Set(["container", "host_required"]);
|
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_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]);
|
||||||
const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
|
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) {
|
function normalizePathList(value) {
|
||||||
const source = Array.isArray(value)
|
const source = Array.isArray(value)
|
||||||
|
|
@ -27,6 +33,8 @@ function ensureConfig(config) {
|
||||||
config.extension_paths = normalizePathList(config.extension_paths);
|
config.extension_paths = normalizePathList(config.extension_paths);
|
||||||
config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
|
config.default_homepage = String(config.default_homepage || "about:blank").trim() || "about:blank";
|
||||||
config.autofocus_active_page = normalizeBoolean(config.autofocus_active_page, true);
|
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.runtime_backend = normalizeRuntimeBackend(config.runtime_backend);
|
||||||
config.host_browser_privacy_policy = normalizeChoice(
|
config.host_browser_privacy_policy = normalizeChoice(
|
||||||
config.host_browser_privacy_policy,
|
config.host_browser_privacy_policy,
|
||||||
|
|
@ -38,6 +46,7 @@ function ensureConfig(config) {
|
||||||
HOST_PROFILE_MODES,
|
HOST_PROFILE_MODES,
|
||||||
"existing",
|
"existing",
|
||||||
);
|
);
|
||||||
|
config.host_browser_selection = normalizeHostBrowserSelection(config.host_browser_selection);
|
||||||
config.model_preset = String(config.model_preset || "").trim();
|
config.model_preset = String(config.model_preset || "").trim();
|
||||||
delete config.model;
|
delete config.model;
|
||||||
return config;
|
return config;
|
||||||
|
|
@ -48,12 +57,55 @@ function normalizeChoice(value, allowed, fallback) {
|
||||||
return allowed.has(normalized) ? normalized : 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) {
|
function normalizeRuntimeBackend(value) {
|
||||||
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
|
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
|
||||||
if (normalized === "host_when_available") return "host_required";
|
if (normalized === "host_when_available") return "host_required";
|
||||||
return RUNTIME_BACKENDS.has(normalized) ? normalized : "container";
|
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) {
|
function normalizeBoolean(value, fallback = true) {
|
||||||
if (value === undefined || value === null || value === "") return fallback;
|
if (value === undefined || value === null || value === "") return fallback;
|
||||||
if (typeof value === "boolean") return value;
|
if (typeof value === "boolean") return value;
|
||||||
|
|
@ -74,6 +126,9 @@ function hostBrowserFamilyLabel(value) {
|
||||||
chromium: "Chromium",
|
chromium: "Chromium",
|
||||||
edge: "Edge",
|
edge: "Edge",
|
||||||
"edge-dev": "Edge Dev",
|
"edge-dev": "Edge Dev",
|
||||||
|
brave: "Brave",
|
||||||
|
opera: "Opera",
|
||||||
|
vivaldi: "Vivaldi",
|
||||||
};
|
};
|
||||||
const label = labels[base] || "Host browser";
|
const label = labels[base] || "Host browser";
|
||||||
if (remoteDebugging) return `${label} (allowed)`;
|
if (remoteDebugging) return `${label} (allowed)`;
|
||||||
|
|
@ -99,13 +154,18 @@ export const store = createStore("browserConfig", {
|
||||||
extensionDeleteLoadingPath: "",
|
extensionDeleteLoadingPath: "",
|
||||||
hostBrowserStatus: null,
|
hostBrowserStatus: null,
|
||||||
hostBrowserStatusLoading: false,
|
hostBrowserStatusLoading: false,
|
||||||
|
hostBrowserStatusRefreshTimer: null,
|
||||||
|
hostBrowserCustomEndpoint: "",
|
||||||
|
hostBrowserCustomMode: false,
|
||||||
|
|
||||||
async init(config) {
|
async init(config) {
|
||||||
this.bindConfig(config);
|
this.bindConfig(config);
|
||||||
await Promise.all([this.loadExtensionsList(), this.loadHostBrowserStatus()]);
|
await Promise.all([this.loadExtensionsList(), this.loadHostBrowserStatus()]);
|
||||||
|
this.startHostBrowserStatusRefresh();
|
||||||
},
|
},
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
this.stopHostBrowserStatusRefresh();
|
||||||
this.config = null;
|
this.config = null;
|
||||||
this.extensionsList = [];
|
this.extensionsList = [];
|
||||||
this.extensionsError = "";
|
this.extensionsError = "";
|
||||||
|
|
@ -113,6 +173,22 @@ export const store = createStore("browserConfig", {
|
||||||
this.extensionDeleteLoadingPath = "";
|
this.extensionDeleteLoadingPath = "";
|
||||||
this.hostBrowserStatus = null;
|
this.hostBrowserStatus = null;
|
||||||
this.hostBrowserStatusLoading = false;
|
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) {
|
bindConfig(config) {
|
||||||
|
|
@ -120,6 +196,9 @@ export const store = createStore("browserConfig", {
|
||||||
if (!safeConfig) return;
|
if (!safeConfig) return;
|
||||||
if (this.config === safeConfig) return;
|
if (this.config === safeConfig) return;
|
||||||
this.config = safeConfig;
|
this.config = safeConfig;
|
||||||
|
if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) {
|
||||||
|
this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setAutofocusActivePage(enabled) {
|
setAutofocusActivePage(enabled) {
|
||||||
|
|
@ -132,6 +211,27 @@ export const store = createStore("browserConfig", {
|
||||||
return this.config?.autofocus_active_page === false ? "Off" : "On";
|
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() {
|
runtimeBackendLabel() {
|
||||||
const value = this.config?.runtime_backend || "container";
|
const value = this.config?.runtime_backend || "container";
|
||||||
if (value === "host_required") return "Bring Your Own Browser";
|
if (value === "host_required") return "Bring Your Own Browser";
|
||||||
|
|
@ -145,6 +245,93 @@ export const store = createStore("browserConfig", {
|
||||||
return "Local Models Only";
|
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() {
|
hostBrowserProfileModeLabel() {
|
||||||
const value = this.config?.host_browser_profile_mode || "existing";
|
const value = this.config?.host_browser_profile_mode || "existing";
|
||||||
if (value === "agent") return "Clean Agent Profile";
|
if (value === "agent") return "Clean Agent Profile";
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<div class="browser-meta">
|
<div class="browser-meta">
|
||||||
<div class="browser-meta-top">
|
<div class="browser-meta-top">
|
||||||
<div class="browser-session-tabs" role="tablist" aria-label="Browser sessions">
|
<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) }">
|
<div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
|
||||||
<button type="button" class="browser-tab" role="tab"
|
<button type="button" class="browser-tab" role="tab"
|
||||||
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
|
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
|
||||||
|
|
@ -187,12 +187,17 @@
|
||||||
<div class="browser-stage" tabindex="0" @click="$el.focus()"
|
<div class="browser-stage" tabindex="0" @click="$el.focus()"
|
||||||
:class="{ 'is-annotating': $store.browserPage.annotating }"
|
:class="{ 'is-annotating': $store.browserPage.annotating }"
|
||||||
@wheel.prevent="$store.browserPage.handleStageWheel($event)">
|
@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">
|
<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)"
|
@click="$store.browserPage.sendMouse('click', $event)"
|
||||||
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
|
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
|
||||||
</template>
|
</template>
|
||||||
<template x-if="$store.browserPage.annotating && $store.browserPage.frameSrc">
|
<template x-if="$store.browserPage.annotating && $store.browserPage.hasFrame()">
|
||||||
<div class="browser-annotation-layer"
|
<div class="browser-annotation-layer"
|
||||||
:class="{ 'is-busy': $store.browserPage.annotationBusy }"
|
:class="{ 'is-busy': $store.browserPage.annotationBusy }"
|
||||||
@pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
|
@pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
|
||||||
|
|
@ -267,7 +272,7 @@
|
||||||
@click="$store.browserPage.sendAnnotationsToChat()">Send now</button>
|
@click="$store.browserPage.sendAnnotationsToChat()">Send now</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template x-if="!$store.browserPage.frameSrc && !$store.browserPage.isBusy()">
|
<template x-if="!$store.browserPage.hasFrame() && !$store.browserPage.isBusy()">
|
||||||
<div class="browser-empty">
|
<div class="browser-empty">
|
||||||
<span class="material-symbols-outlined">captive_portal</span>
|
<span class="material-symbols-outlined">captive_portal</span>
|
||||||
<button class="btn btn-field" @click="$store.browserPage.command('open')">Open
|
<button class="btn btn-field" @click="$store.browserPage.command('open')">Open
|
||||||
|
|
@ -999,7 +1004,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
object-fit: fill;
|
object-fit: contain;
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ const ANNOTATION_DOM_LIMIT = 1200;
|
||||||
const ANNOTATION_TRAY_MARGIN = 10;
|
const ANNOTATION_TRAY_MARGIN = 10;
|
||||||
const BROWSER_VISUAL_SHORTCUT_KEYS = new Set(["a", "c", "insert", "v", "x", "y", "z"]);
|
const BROWSER_VISUAL_SHORTCUT_KEYS = new Set(["a", "c", "insert", "v", "x", "y", "z"]);
|
||||||
const LOCAL_EDITABLE_SELECTOR = "input, textarea, select, [contenteditable]";
|
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() {
|
function makeViewerToken() {
|
||||||
return globalThis.crypto?.randomUUID?.()
|
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 = {
|
const model = {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: "",
|
error: "",
|
||||||
|
|
@ -124,8 +165,10 @@ const model = {
|
||||||
activeBrowserContextId: "",
|
activeBrowserContextId: "",
|
||||||
address: "",
|
address: "",
|
||||||
frameSrc: "",
|
frameSrc: "",
|
||||||
|
frameCanvasReady: false,
|
||||||
frameState: null,
|
frameState: null,
|
||||||
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
|
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
|
||||||
|
tabScope: "per_context",
|
||||||
liveScreencastEnabled: true,
|
liveScreencastEnabled: true,
|
||||||
annotating: false,
|
annotating: false,
|
||||||
annotationComments: [],
|
annotationComments: [],
|
||||||
|
|
@ -146,9 +189,11 @@ const model = {
|
||||||
_lastFrameDimensions: null,
|
_lastFrameDimensions: null,
|
||||||
_pendingFrameSrc: "",
|
_pendingFrameSrc: "",
|
||||||
_pendingFrameOptions: null,
|
_pendingFrameOptions: null,
|
||||||
|
_frameObjectUrl: "",
|
||||||
_frameRenderHandle: null,
|
_frameRenderHandle: null,
|
||||||
_frameRenderCancel: null,
|
_frameRenderCancel: null,
|
||||||
_frameRenderSequence: 0,
|
_frameRenderSequence: 0,
|
||||||
|
_frameCanvas: null,
|
||||||
_floatingCleanup: null,
|
_floatingCleanup: null,
|
||||||
_stageElement: null,
|
_stageElement: null,
|
||||||
_stageResizeObserver: null,
|
_stageResizeObserver: null,
|
||||||
|
|
@ -295,8 +340,10 @@ const model = {
|
||||||
{ timeoutMs: 10000 },
|
{ timeoutMs: 10000 },
|
||||||
);
|
);
|
||||||
const data = firstOk(response);
|
const data = firstOk(response);
|
||||||
|
this.applyTabScope(data);
|
||||||
this.applyBrowserListing(data.browsers || [], data.context_id || "", {
|
this.applyBrowserListing(data.browsers || [], data.context_id || "", {
|
||||||
replaceAll: Boolean(data.all_browsers),
|
replaceAll: Boolean(data.all_browsers),
|
||||||
|
replaceContext: !data.all_browsers,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
try {
|
try {
|
||||||
|
|
@ -442,7 +489,8 @@ const model = {
|
||||||
this.setActiveBrowserId(null);
|
this.setActiveBrowserId(null);
|
||||||
this.address = "";
|
this.address = "";
|
||||||
this.frameState = null;
|
this.frameState = null;
|
||||||
this.frameSrc = "";
|
this.clearFrameSrc();
|
||||||
|
this.clearFrameCanvas();
|
||||||
if (this.contextId) {
|
if (this.contextId) {
|
||||||
await this.connectViewer();
|
await this.connectViewer();
|
||||||
}
|
}
|
||||||
|
|
@ -754,11 +802,13 @@ const model = {
|
||||||
},
|
},
|
||||||
|
|
||||||
releaseSurfaceBindings() {
|
releaseSurfaceBindings() {
|
||||||
|
this.freezeCanvasFrameToImage();
|
||||||
this._floatingCleanup?.();
|
this._floatingCleanup?.();
|
||||||
this._floatingCleanup = null;
|
this._floatingCleanup = null;
|
||||||
this._stageResizeObserver?.disconnect?.();
|
this._stageResizeObserver?.disconnect?.();
|
||||||
this._stageResizeObserver = null;
|
this._stageResizeObserver = null;
|
||||||
this._stageElement = null;
|
this._stageElement = null;
|
||||||
|
this._frameCanvas = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
isCanvasSurfaceVisible(element = null) {
|
isCanvasSurfaceVisible(element = null) {
|
||||||
|
|
@ -813,7 +863,7 @@ const model = {
|
||||||
this._surfaceMounted = true;
|
this._surfaceMounted = true;
|
||||||
this._surfaceOpenedAt = Date.now();
|
this._surfaceOpenedAt = Date.now();
|
||||||
this._lastViewportKey = "";
|
this._lastViewportKey = "";
|
||||||
if (this.frameSrc && !targetChanged) {
|
if (this.hasFrame() && !targetChanged) {
|
||||||
this._surfaceSwitching = false;
|
this._surfaceSwitching = false;
|
||||||
this.switchingBrowserId = null;
|
this.switchingBrowserId = null;
|
||||||
return;
|
return;
|
||||||
|
|
@ -833,13 +883,14 @@ const model = {
|
||||||
|
|
||||||
resetRenderedFrame() {
|
resetRenderedFrame() {
|
||||||
this.cancelFrameRender();
|
this.cancelFrameRender();
|
||||||
this.frameSrc = "";
|
this.clearFrameSrc();
|
||||||
|
this.clearFrameCanvas();
|
||||||
this._lastFrameDimensions = null;
|
this._lastFrameDimensions = null;
|
||||||
this._lastFrameAt = 0;
|
this._lastFrameAt = 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
resetRenderedFrameIfViewportChanged(viewport = null, requestedBrowserId = null, requestedContextId = "") {
|
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 targetBrowserId = requestedBrowserId || this.activeBrowserId || this.firstBrowserId();
|
||||||
const targetContextId = this.normalizeContextId(requestedContextId || this.contextIdForBrowserId(targetBrowserId) || this.activeBrowserContextId);
|
const targetContextId = this.normalizeContextId(requestedContextId || this.contextIdForBrowserId(targetBrowserId) || this.activeBrowserContextId);
|
||||||
if (!this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, targetBrowserId, targetContextId)) return;
|
if (!this.sameBrowserTab(this._lastViewport.browserId, this._lastViewport.contextId, targetBrowserId, targetContextId)) return;
|
||||||
|
|
@ -911,10 +962,41 @@ const model = {
|
||||||
return BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
|
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() {
|
usesScreencastTransport() {
|
||||||
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
|
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) {
|
frameDimensionsFromMetadata(metadata = null) {
|
||||||
if (!metadata || typeof metadata !== "object") return null;
|
if (!metadata || typeof metadata !== "object") return null;
|
||||||
const width = Number(metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth || 0);
|
const width = Number(metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth || 0);
|
||||||
|
|
@ -986,6 +1068,9 @@ const model = {
|
||||||
viewer_id: viewerToken,
|
viewer_id: viewerToken,
|
||||||
create_browser: Boolean(options.createBrowser || options.create_browser),
|
create_browser: Boolean(options.createBrowser || options.create_browser),
|
||||||
viewer_transport: this.requestedViewerTransport(),
|
viewer_transport: this.requestedViewerTransport(),
|
||||||
|
binary_frames: this.supportsBinaryFrames(),
|
||||||
|
slim_frames: true,
|
||||||
|
device_pixel_ratio: this.captureDevicePixelRatio(),
|
||||||
viewport_width: initialViewport?.width,
|
viewport_width: initialViewport?.width,
|
||||||
viewport_height: initialViewport?.height,
|
viewport_height: initialViewport?.height,
|
||||||
},
|
},
|
||||||
|
|
@ -1007,16 +1092,20 @@ const model = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = firstOk(response);
|
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.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||||
this.setActiveBrowserId(
|
this.setActiveBrowserId(
|
||||||
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
|
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
|
||||||
data.active_browser_context_id || contextId,
|
data.active_browser_context_id || contextId,
|
||||||
);
|
);
|
||||||
this.applySnapshot(data.snapshot);
|
this.applySnapshot(data.snapshot);
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.browserInstallExpected = false;
|
this.browserInstallExpected = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
async _bindSocketEvents() {
|
async _bindSocketEvents() {
|
||||||
if (!this._frameOff) {
|
if (!this._frameOff) {
|
||||||
|
|
@ -1026,9 +1115,15 @@ const model = {
|
||||||
if (data?.viewer_transport) {
|
if (data?.viewer_transport) {
|
||||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||||
}
|
}
|
||||||
|
this.applyTabScope(data);
|
||||||
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
|
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
|
||||||
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
|
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) {
|
if (incomingBrowserId && !this.activeBrowserId) {
|
||||||
this.setActiveBrowserId(incomingBrowserId, incomingContextId);
|
this.setActiveBrowserId(incomingBrowserId, incomingContextId);
|
||||||
}
|
}
|
||||||
|
|
@ -1046,11 +1141,17 @@ const model = {
|
||||||
this.address = data.state.currentUrl;
|
this.address = data.state.currentUrl;
|
||||||
}
|
}
|
||||||
if (data.image) {
|
if (data.image) {
|
||||||
|
const frameImage = frameImageSource(data);
|
||||||
|
if (!frameImage?.src) return;
|
||||||
const frameBrowserId = incomingBrowserId || this.activeBrowserId;
|
const frameBrowserId = incomingBrowserId || this.activeBrowserId;
|
||||||
this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`, {
|
this.queueFrameRender(frameImage.src, {
|
||||||
browserId: frameBrowserId,
|
browserId: frameBrowserId,
|
||||||
contextId: incomingContextId,
|
contextId: incomingContextId,
|
||||||
dimensions: this.frameDimensionsFromMetadata(data.metadata),
|
dimensions: this.frameDimensionsFromData(data),
|
||||||
|
blob: frameImage.blob,
|
||||||
|
objectUrl: frameImage.objectUrl,
|
||||||
|
useCanvas: true,
|
||||||
|
cleanup: frameImage.cleanup,
|
||||||
onAccepted: () => {
|
onAccepted: () => {
|
||||||
if (
|
if (
|
||||||
this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
|
this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
|
||||||
|
|
@ -1063,13 +1164,15 @@ const model = {
|
||||||
});
|
});
|
||||||
} else if (!data.state) {
|
} else if (!data.state) {
|
||||||
this.cancelFrameRender();
|
this.cancelFrameRender();
|
||||||
this.frameSrc = "";
|
this.clearFrameSrc();
|
||||||
|
this.clearFrameCanvas();
|
||||||
}
|
}
|
||||||
if (!data.image && !data.state) {
|
if (!data.image && !data.state) {
|
||||||
if (!this.activeBrowserId) {
|
if (!this.activeBrowserId) {
|
||||||
this.setActiveBrowserId(null, "");
|
this.setActiveBrowserId(null, "");
|
||||||
this.frameState = null;
|
this.frameState = null;
|
||||||
this.frameSrc = "";
|
this.clearFrameSrc();
|
||||||
|
this.clearFrameCanvas();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._lastFrameAt = Date.now();
|
this._lastFrameAt = Date.now();
|
||||||
|
|
@ -1077,15 +1180,21 @@ const model = {
|
||||||
await websocket.on("browser_viewer_frame", frameHandler);
|
await websocket.on("browser_viewer_frame", frameHandler);
|
||||||
this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
|
this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
|
||||||
}
|
}
|
||||||
if (!this._stateOff) {
|
if (!this._stateOff) {
|
||||||
const stateHandler = ({ data }) => {
|
const stateHandler = ({ data }) => {
|
||||||
if (data?.context_id !== this.contextId) return;
|
if (data?.context_id !== this.contextId) return;
|
||||||
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
|
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
|
||||||
if (data?.viewer_transport) {
|
if (data?.viewer_transport) {
|
||||||
this.viewerTransport = this.normalizeViewerTransport(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);
|
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 command = String(data.command || "").toLowerCase();
|
||||||
const commandBrowserId = this.normalizeBrowserId(data.browser_id);
|
const commandBrowserId = this.normalizeBrowserId(data.browser_id);
|
||||||
const result = data.result || {};
|
const result = data.result || {};
|
||||||
|
|
@ -1102,23 +1211,40 @@ const model = {
|
||||||
|| this.activeBrowserId
|
|| this.activeBrowserId
|
||||||
|| this.firstBrowserId(resultContextId)
|
|| this.firstBrowserId(resultContextId)
|
||||||
);
|
);
|
||||||
if (
|
const stateBrowserId = this.normalizeBrowserId(data.active_browser_id || data.browser_id || data.state?.id);
|
||||||
!this.activeBrowserId
|
if (
|
||||||
|| command === "open"
|
stateBrowserId
|
||||||
|| command === "close"
|
&& (
|
||||||
|| this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
|
!this.activeBrowserId
|
||||||
) {
|
|| this.sameBrowserTab(stateBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
|
||||||
this.setActiveBrowserId(preferredBrowserId, resultContextId);
|
)
|
||||||
}
|
) {
|
||||||
this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId, this.activeBrowserContextId));
|
this.setActiveBrowserId(stateBrowserId, commandContextId);
|
||||||
this.applySnapshot(data.snapshot);
|
}
|
||||||
};
|
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);
|
await websocket.on("browser_viewer_state", stateHandler);
|
||||||
this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
|
this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
queueFrameRender(frameSrc, options = {}) {
|
queueFrameRender(frameSrc, options = {}) {
|
||||||
|
if (this._pendingFrameSrc) {
|
||||||
|
this.releasePendingFrame();
|
||||||
|
}
|
||||||
this._pendingFrameSrc = frameSrc;
|
this._pendingFrameSrc = frameSrc;
|
||||||
this._pendingFrameOptions = options || null;
|
this._pendingFrameOptions = options || null;
|
||||||
if (this._frameRenderHandle) return;
|
if (this._frameRenderHandle) return;
|
||||||
|
|
@ -1148,22 +1274,44 @@ const model = {
|
||||||
async renderDecodedFrame(frameSrc, options = {}, sequence = 0, surfaceSequence = this._surfaceOpenSequence) {
|
async renderDecodedFrame(frameSrc, options = {}, sequence = 0, surfaceSequence = this._surfaceOpenSequence) {
|
||||||
if (!frameSrc) {
|
if (!frameSrc) {
|
||||||
if (sequence === this._frameRenderSequence) {
|
if (sequence === this._frameRenderSequence) {
|
||||||
this.frameSrc = "";
|
this.clearFrameSrc();
|
||||||
|
this.clearFrameCanvas();
|
||||||
}
|
}
|
||||||
return;
|
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) {
|
if (sequence !== this._frameRenderSequence || surfaceSequence !== this._surfaceOpenSequence) {
|
||||||
|
bitmap?.close?.();
|
||||||
|
options?.cleanup?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const viewport = this.currentViewportSize() || this._lastViewport;
|
const viewport = this.currentViewportSize() || this._lastViewport;
|
||||||
if (!this.frameMatchesViewport(dimensions, viewport)) {
|
if (!this.frameMatchesViewport(dimensions, viewport)) {
|
||||||
this.requestViewportSyncAfterRejectedFrame();
|
this.requestViewportSyncAfterRejectedFrame();
|
||||||
if (!this.shouldAcceptMismatchedFrame(dimensions)) {
|
if (!this.shouldAcceptMismatchedFrame(dimensions)) {
|
||||||
|
bitmap?.close?.();
|
||||||
|
options?.cleanup?.();
|
||||||
return;
|
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._lastFrameDimensions = dimensions;
|
||||||
this._lastFrameAt = Date.now();
|
this._lastFrameAt = Date.now();
|
||||||
options?.onAccepted?.();
|
options?.onAccepted?.();
|
||||||
|
|
@ -1175,7 +1323,7 @@ const model = {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
dimensions?.width
|
dimensions?.width
|
||||||
&& dimensions?.height
|
&& dimensions?.height
|
||||||
&& (!this.frameSrc || this._surfaceSwitching || this.isSwitchingBrowser())
|
&& (!this.hasFrame() || this._surfaceSwitching || this.isSwitchingBrowser())
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1246,7 +1394,7 @@ const model = {
|
||||||
|
|
||||||
clearRenderedFrameIfViewportChanged() {
|
clearRenderedFrameIfViewportChanged() {
|
||||||
const viewport = this.currentViewportSize();
|
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;
|
if (this.frameMatchesViewport(this._lastFrameDimensions, viewport)) return;
|
||||||
this.cancelFrameRender();
|
this.cancelFrameRender();
|
||||||
this.resetViewportTracking();
|
this.resetViewportTracking();
|
||||||
|
|
@ -1262,9 +1410,84 @@ const model = {
|
||||||
}
|
}
|
||||||
this._frameRenderHandle = null;
|
this._frameRenderHandle = null;
|
||||||
this._frameRenderCancel = null;
|
this._frameRenderCancel = null;
|
||||||
|
this.releasePendingFrame();
|
||||||
|
this._frameRenderSequence += 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
releasePendingFrame() {
|
||||||
|
this._pendingFrameOptions?.cleanup?.();
|
||||||
this._pendingFrameSrc = "";
|
this._pendingFrameSrc = "";
|
||||||
this._pendingFrameOptions = null;
|
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() {
|
beginCommand() {
|
||||||
|
|
@ -1303,7 +1526,11 @@ const model = {
|
||||||
{ timeoutMs: 20000 },
|
{ timeoutMs: 20000 },
|
||||||
);
|
);
|
||||||
const data = firstOk(response);
|
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);
|
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||||
const result = data.result || {};
|
const result = data.result || {};
|
||||||
const resultContextId = this.normalizeContextId(
|
const resultContextId = this.normalizeContextId(
|
||||||
|
|
@ -1329,7 +1556,8 @@ const model = {
|
||||||
);
|
);
|
||||||
if (!this.activeBrowserId) {
|
if (!this.activeBrowserId) {
|
||||||
this.frameState = null;
|
this.frameState = null;
|
||||||
this.frameSrc = "";
|
this.clearFrameSrc();
|
||||||
|
this.clearFrameCanvas();
|
||||||
}
|
}
|
||||||
if (result.state?.currentUrl || result.currentUrl) {
|
if (result.state?.currentUrl || result.currentUrl) {
|
||||||
this.address = result.state?.currentUrl || result.currentUrl;
|
this.address = result.state?.currentUrl || result.currentUrl;
|
||||||
|
|
@ -1438,7 +1666,8 @@ const model = {
|
||||||
this.error = "";
|
this.error = "";
|
||||||
this.switchingBrowserId = targetId;
|
this.switchingBrowserId = targetId;
|
||||||
this.cancelFrameRender();
|
this.cancelFrameRender();
|
||||||
this.frameSrc = "";
|
this.clearFrameSrc();
|
||||||
|
this.clearFrameCanvas();
|
||||||
this.frameState = browser || null;
|
this.frameState = browser || null;
|
||||||
if (!this.addressFocused && browser?.currentUrl) {
|
if (!this.addressFocused && browser?.currentUrl) {
|
||||||
this.address = browser.currentUrl;
|
this.address = browser.currentUrl;
|
||||||
|
|
@ -1546,6 +1775,15 @@ const model = {
|
||||||
return browsers[0] || null;
|
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 = "") {
|
firstBrowserInContext(contextId = "") {
|
||||||
const normalizedContextId = this.normalizeContextId(contextId);
|
const normalizedContextId = this.normalizeContextId(contextId);
|
||||||
if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
|
if (!normalizedContextId || !Array.isArray(this.browsers)) return null;
|
||||||
|
|
@ -1643,35 +1881,35 @@ const model = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
applySnapshot(snapshot = null) {
|
applySnapshot(snapshot = null) {
|
||||||
if (!snapshot?.image) return;
|
if (!snapshot?.image) return;
|
||||||
const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
|
const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
|
||||||
const snapshotContextId = this.normalizeContextId(snapshot.context_id || snapshot.state?.context_id || this.activeBrowserContextId);
|
const snapshotContextId = this.normalizeContextId(snapshot.context_id || snapshot.state?.context_id || this.activeBrowserContextId);
|
||||||
if (
|
if (
|
||||||
snapshotId
|
snapshotId
|
||||||
&& this.activeBrowserId
|
&& this.activeBrowserId
|
||||||
&& !this.sameBrowserTab(snapshotId, snapshotContextId, this.activeBrowserId, this.activeBrowserContextId)
|
&& !this.sameBrowserTab(snapshotId, snapshotContextId, this.activeBrowserId, this.activeBrowserContextId)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (snapshot.state) {
|
if (snapshot.state) {
|
||||||
this.applyActiveFrameState(snapshot.state);
|
this.applyActiveFrameState(snapshot.state);
|
||||||
}
|
}
|
||||||
const frameBrowserId = snapshotId || this.activeBrowserId;
|
const frameBrowserId = snapshotId || this.activeBrowserId;
|
||||||
this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
|
this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
|
||||||
browserId: frameBrowserId,
|
browserId: frameBrowserId,
|
||||||
contextId: snapshotContextId,
|
contextId: snapshotContextId,
|
||||||
onAccepted: () => {
|
onAccepted: () => {
|
||||||
if (
|
if (
|
||||||
this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
|
this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
|
||||||
&& this.normalizeContextId(this.activeBrowserContextId) === snapshotContextId
|
&& this.normalizeContextId(this.activeBrowserContextId) === snapshotContextId
|
||||||
) {
|
) {
|
||||||
this.switchingBrowserId = null;
|
this.switchingBrowserId = null;
|
||||||
}
|
}
|
||||||
this._surfaceSwitching = false;
|
this._surfaceSwitching = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
isSwitchingBrowser() {
|
isSwitchingBrowser() {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
|
|
@ -1710,8 +1948,8 @@ const model = {
|
||||||
const target = element || event?.currentTarget;
|
const target = element || event?.currentTarget;
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
const naturalWidth = target.naturalWidth || rect.width;
|
const naturalWidth = target.naturalWidth || target.width || rect.width;
|
||||||
const naturalHeight = target.naturalHeight || rect.height;
|
const naturalHeight = target.naturalHeight || target.height || rect.height;
|
||||||
let contentLeft = rect.left;
|
let contentLeft = rect.left;
|
||||||
let contentTop = rect.top;
|
let contentTop = rect.top;
|
||||||
let contentWidth = rect.width;
|
let contentWidth = rect.width;
|
||||||
|
|
@ -1748,6 +1986,7 @@ const model = {
|
||||||
},
|
},
|
||||||
|
|
||||||
handleKeydown(event) {
|
handleKeydown(event) {
|
||||||
|
if (isLocalEditableTarget(event?.target)) return;
|
||||||
const annotateShortcut = event?.key === "." && (event.metaKey || event.ctrlKey) && !event.altKey;
|
const annotateShortcut = event?.key === "." && (event.metaKey || event.ctrlKey) && !event.altKey;
|
||||||
if (annotateShortcut && this._surfaceMounted) {
|
if (annotateShortcut && this._surfaceMounted) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -1862,7 +2101,7 @@ const model = {
|
||||||
},
|
},
|
||||||
|
|
||||||
canAnnotate() {
|
canAnnotate() {
|
||||||
return Boolean(this.activeBrowserId && this.frameSrc && !this.isBusy());
|
return Boolean(this.activeBrowserId && this.hasFrame() && !this.isBusy());
|
||||||
},
|
},
|
||||||
|
|
||||||
activeAnnotationUrl() {
|
activeAnnotationUrl() {
|
||||||
|
|
@ -2023,8 +2262,7 @@ const model = {
|
||||||
},
|
},
|
||||||
|
|
||||||
stagePointForEvent(event) {
|
stagePointForEvent(event) {
|
||||||
const image = this._stageElement?.querySelector?.(".browser-frame") || null;
|
return this.pointerCoordinatesFor(event, this.frameElement());
|
||||||
return this.pointerCoordinatesFor(event, image);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
normalizeAnnotationRect(start = {}, end = {}) {
|
normalizeAnnotationRect(start = {}, end = {}) {
|
||||||
|
|
@ -2425,7 +2663,9 @@ const model = {
|
||||||
const response = await websocket.request("browser_viewer_input", payload, { timeoutMs: 10000 });
|
const response = await websocket.request("browser_viewer_input", payload, { timeoutMs: 10000 });
|
||||||
const data = firstOk(response);
|
const data = firstOk(response);
|
||||||
this.applyActiveFrameState(data.state);
|
this.applyActiveFrameState(data.state);
|
||||||
this.applySnapshot(data.snapshot);
|
if (!this.frameCanvasReady || !this.usesScreencastTransport()) {
|
||||||
|
this.applySnapshot(data.snapshot);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error instanceof Error ? error.message : String(error);
|
this.error = error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
@ -2437,8 +2677,7 @@ const model = {
|
||||||
async sendWheel(event) {
|
async sendWheel(event) {
|
||||||
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
|
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
|
||||||
if (!contextId || !this.activeBrowserId || !event) return;
|
if (!contextId || !this.activeBrowserId || !event) return;
|
||||||
const image = event.currentTarget?.querySelector?.(".browser-frame") || event.target?.closest?.(".browser-frame");
|
const pointer = this.pointerCoordinatesFor(event, this.frameElement());
|
||||||
const pointer = this.pointerCoordinatesFor(event, image);
|
|
||||||
if (!pointer) return;
|
if (!pointer) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
context_id: contextId,
|
context_id: contextId,
|
||||||
|
|
@ -2462,8 +2701,7 @@ const model = {
|
||||||
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
|
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
|
||||||
if (!contextId || !this.activeBrowserId) return;
|
if (!contextId || !this.activeBrowserId) return;
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target?.tagName);
|
if (isLocalEditableTarget(event?.target)) return;
|
||||||
if (editable) return;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const printable = event.key && event.key.length === 1;
|
const printable = event.key && event.key.length === 1;
|
||||||
await websocket.emit("browser_viewer_input", {
|
await websocket.emit("browser_viewer_input", {
|
||||||
|
|
@ -2565,6 +2803,7 @@ const model = {
|
||||||
this._viewerToken = "";
|
this._viewerToken = "";
|
||||||
this.switchingBrowserId = null;
|
this.switchingBrowserId = null;
|
||||||
this.viewerTransport = this.requestedViewerTransport();
|
this.viewerTransport = this.requestedViewerTransport();
|
||||||
|
this.tabScope = "per_context";
|
||||||
this._surfaceMounted = false;
|
this._surfaceMounted = false;
|
||||||
this._surfaceSwitching = false;
|
this._surfaceSwitching = false;
|
||||||
this.commandInFlight = false;
|
this.commandInFlight = false;
|
||||||
|
|
@ -2846,8 +3085,21 @@ const model = {
|
||||||
|
|
||||||
export const store = createStore("browserPage", 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 = {}) => {
|
registerUrlHandler(async (intent = {}) => {
|
||||||
const url = String(intent.url || "").trim();
|
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" };
|
const payload = { url, source: intent.source || "surface-url-intent" };
|
||||||
await openLatestSurface("browser", payload);
|
await openLatestSurface("browser", payload);
|
||||||
return await store.openUrlIntent(url, { source: payload.source });
|
return await store.openUrlIntent(url, { source: payload.source });
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
class="browser-config-field-help"
|
class="browser-config-field-help"
|
||||||
x-show="$store.browserConfig.config.runtime_backend === 'host_required'"
|
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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
@ -66,6 +66,50 @@
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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=<profile-dir>, or set A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS to the full ws://.../devtools/browser/... endpoint.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="browser-config-warning"
|
class="browser-config-warning"
|
||||||
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
|
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
|
||||||
|
|
@ -112,6 +156,44 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label class="browser-config-switch-row">
|
||||||
<span class="browser-config-switch-copy">
|
<span class="browser-config-switch-copy">
|
||||||
<span class="browser-config-field-label">Autofocus active page</span>
|
<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="text"],
|
||||||
|
.browser-config-field input[type="number"],
|
||||||
.browser-config-field select {
|
.browser-config-field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
- Backup JSON and transcript artifacts must remain UTF-8 writable when chat content contains malformed Unicode such as lone surrogates.
|
- 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.
|
- 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.
|
- 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.
|
- Do not discard original context data unless the compaction flow explicitly owns that behavior.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from collections import deque
|
||||||
import models as models_module
|
import models as models_module
|
||||||
from agent import Agent
|
from agent import Agent
|
||||||
from helpers import files, tokens
|
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 (
|
from helpers.persist_chat import (
|
||||||
export_json_chat,
|
export_json_chat,
|
||||||
get_chat_folder_path,
|
get_chat_folder_path,
|
||||||
|
|
@ -145,6 +145,7 @@ async def run_compaction(
|
||||||
|
|
||||||
agent.history = History(agent=agent)
|
agent.history = History(agent=agent)
|
||||||
agent.history.add_message(ai=True, content=compacted_content)
|
agent.history.add_message(ai=True, content=compacted_content)
|
||||||
|
clear_responses_provider_state(agent)
|
||||||
|
|
||||||
# Clear subordinate chain
|
# Clear subordinate chain
|
||||||
agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
|
agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
## Local Contracts
|
## Local Contracts
|
||||||
|
|
||||||
- Keep session concurrency, timeout, streaming, and reset behavior predictable.
|
- 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.
|
- Explicitly target local versus SSH execution runtimes.
|
||||||
- Do not hardcode secrets, SSH credentials, or local user paths.
|
- Do not hardcode secrets, SSH credentials, or local user paths.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import asyncio, os, sys, platform, errno
|
import asyncio, os, sys, platform, errno, signal
|
||||||
|
|
||||||
_IS_WIN = platform.system() == "Windows"
|
_IS_WIN = platform.system() == "Windows"
|
||||||
if _IS_WIN:
|
if _IS_WIN:
|
||||||
import winpty # pip install pywinpty # type: ignore
|
import winpty # pip install pywinpty # type: ignore
|
||||||
import msvcrt
|
import msvcrt
|
||||||
|
|
||||||
|
_CLOSE_TIMEOUT_SECONDS = 2
|
||||||
|
|
||||||
|
|
||||||
def _reconfigure_stream_errors(stream) -> None:
|
def _reconfigure_stream_errors(stream) -> None:
|
||||||
reconfigure = getattr(stream, "reconfigure", None)
|
reconfigure = getattr(stream, "reconfigure", None)
|
||||||
|
|
@ -77,15 +79,16 @@ class TTYSession:
|
||||||
|
|
||||||
# Terminate the process if it exists
|
# Terminate the process if it exists
|
||||||
if self._proc:
|
if self._proc:
|
||||||
|
if getattr(self._proc, "returncode", None) is None:
|
||||||
|
self._signal_process(signal.SIGTERM)
|
||||||
try:
|
try:
|
||||||
if getattr(self._proc, "returncode", None) is None:
|
await asyncio.wait_for(self._proc.wait(), _CLOSE_TIMEOUT_SECONDS)
|
||||||
self._proc.terminate()
|
except asyncio.TimeoutError:
|
||||||
except ProcessLookupError:
|
self._signal_process(signal.SIGKILL)
|
||||||
pass
|
try:
|
||||||
except Exception:
|
await asyncio.wait_for(self._proc.wait(), _CLOSE_TIMEOUT_SECONDS)
|
||||||
pass
|
except Exception:
|
||||||
try:
|
pass
|
||||||
await self._proc.wait()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -93,6 +96,28 @@ class TTYSession:
|
||||||
self._proc = None
|
self._proc = None
|
||||||
self._pump_task = 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):
|
def _release_pty_master(self):
|
||||||
"""Release the POSIX PTY master exactly once.
|
"""Release the POSIX PTY master exactly once.
|
||||||
|
|
||||||
|
|
@ -166,11 +191,7 @@ class TTYSession:
|
||||||
|
|
||||||
# Only attempt to kill if the process is still running
|
# Only attempt to kill if the process is still running
|
||||||
if getattr(self._proc, "returncode", None) is None:
|
if getattr(self._proc, "returncode", None) is None:
|
||||||
try:
|
self._signal_process(signal.SIGKILL)
|
||||||
self._proc.kill()
|
|
||||||
except ProcessLookupError:
|
|
||||||
# Child already gone – treat as successfully killed
|
|
||||||
pass
|
|
||||||
self._release_pty_master()
|
self._release_pty_master()
|
||||||
|
|
||||||
async def read(self, timeout=None):
|
async def read(self, timeout=None):
|
||||||
|
|
@ -241,6 +262,7 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
os.close(slave)
|
os.close(slave)
|
||||||
|
|
||||||
|
|
|
||||||
45
plugins/_commands/AGENTS.md
Normal file
45
plugins/_commands/AGENTS.md
Normal 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
21
plugins/_commands/LICENSE
Normal 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
146
plugins/_commands/README.md
Normal 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.
|
||||||
1
plugins/_commands/__init__.py
Normal file
1
plugins/_commands/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Commands plugin package."""
|
||||||
165
plugins/_commands/api/commands.py
Normal file
165
plugins/_commands/api/commands.py
Normal 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}
|
||||||
5
plugins/_commands/commands/attach.command.yaml
Normal file
5
plugins/_commands/commands/attach.command.yaml
Normal 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
|
||||||
5
plugins/_commands/commands/browser.command.yaml
Normal file
5
plugins/_commands/commands/browser.command.yaml
Normal 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
|
||||||
5
plugins/_commands/commands/chat.command.yaml
Normal file
5
plugins/_commands/commands/chat.command.yaml
Normal 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
|
||||||
6
plugins/_commands/commands/chats.command.yaml
Normal file
6
plugins/_commands/commands/chats.command.yaml
Normal 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
|
||||||
4
plugins/_commands/commands/clear.command.yaml
Normal file
4
plugins/_commands/commands/clear.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: clear
|
||||||
|
description: Clear the visible chat log.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/compact.command.yaml
Normal file
4
plugins/_commands/commands/compact.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: compact
|
||||||
|
description: Open the connector-backed compaction confirmation flow.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
5
plugins/_commands/commands/computer-use.command.yaml
Normal file
5
plugins/_commands/commands/computer-use.command.yaml
Normal 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
|
||||||
305
plugins/_commands/commands/connector_commands.py
Normal file
305
plugins/_commands/commands/connector_commands.py
Normal 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("|", "\\|")
|
||||||
4
plugins/_commands/commands/copy.command.yaml
Normal file
4
plugins/_commands/commands/copy.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: copy
|
||||||
|
description: Copy the currently visible transcript text to the clipboard.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/models.command.yaml
Normal file
4
plugins/_commands/commands/models.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: models
|
||||||
|
description: Open Main/Utility model runtime editor.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/new.command.yaml
Normal file
4
plugins/_commands/commands/new.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: new
|
||||||
|
description: Create a brand-new empty chat context.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/nudge.command.yaml
Normal file
4
plugins/_commands/commands/nudge.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: nudge
|
||||||
|
description: Nudge the current agent run.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/pause.command.yaml
Normal file
4
plugins/_commands/commands/pause.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: pause
|
||||||
|
description: Pause the active agent run.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/plugins.command.yaml
Normal file
4
plugins/_commands/commands/plugins.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: plugins
|
||||||
|
description: Open the installed-only Agent Zero plugin toggle view.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/presets.command.yaml
Normal file
4
plugins/_commands/commands/presets.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: presets
|
||||||
|
description: Open preset picker with Main/Utility model details.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
5
plugins/_commands/commands/profile.command.yaml
Normal file
5
plugins/_commands/commands/profile.command.yaml
Normal 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
|
||||||
5
plugins/_commands/commands/project.command.yaml
Normal file
5
plugins/_commands/commands/project.command.yaml
Normal 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
|
||||||
5
plugins/_commands/commands/queue.command.yaml
Normal file
5
plugins/_commands/commands/queue.command.yaml
Normal 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
|
||||||
4
plugins/_commands/commands/quit.command.yaml
Normal file
4
plugins/_commands/commands/quit.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: quit
|
||||||
|
description: Disconnect and exit the CLI.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/resume.command.yaml
Normal file
4
plugins/_commands/commands/resume.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: resume
|
||||||
|
description: Resume a paused agent run.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/send.command.yaml
Normal file
4
plugins/_commands/commands/send.command.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: send
|
||||||
|
description: Send all queued messages now.
|
||||||
|
type: script
|
||||||
|
script_path: connector_commands.py
|
||||||
4
plugins/_commands/commands/status.command.yaml
Normal file
4
plugins/_commands/commands/status.command.yaml
Normal 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
|
||||||
|
|
@ -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)*")
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
1
plugins/_commands/helpers/__init__.py
Normal file
1
plugins/_commands/helpers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Helpers for the commands plugin."""
|
||||||
1150
plugins/_commands/helpers/commands.py
Normal file
1150
plugins/_commands/helpers/commands.py
Normal file
File diff suppressed because it is too large
Load diff
8
plugins/_commands/plugin.yaml
Normal file
8
plugins/_commands/plugin.yaml
Normal 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
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
Describe the work to perform here.
|
||||||
|
|
||||||
|
{raw}
|
||||||
|
|
@ -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
|
||||||
11
plugins/_commands/tests/conftest.py
Normal file
11
plugins/_commands/tests/conftest.py
Normal 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)
|
||||||
367
plugins/_commands/tests/test_commands_plugin.py
Normal file
367
plugins/_commands/tests/test_commands_plugin.py
Normal 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"
|
||||||
|
)
|
||||||
97
plugins/_commands/tests/test_legacy_migration.py
Normal file
97
plugins/_commands/tests/test_legacy_migration.py
Normal 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()
|
||||||
337
plugins/_commands/tests/test_plugin_command_discovery.py
Normal file
337
plugins/_commands/tests/test_plugin_command_discovery.py
Normal 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)
|
||||||
526
plugins/_commands/webui/commands-slash-store.js
Normal file
526
plugins/_commands/webui/commands-slash-store.js
Normal 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 = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
417
plugins/_commands/webui/commands-store.js
Normal file
417
plugins/_commands/webui/commands-store.js
Normal 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);
|
||||||
240
plugins/_commands/webui/editor.html
Normal file
240
plugins/_commands/webui/editor.html
Normal 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>
|
||||||
395
plugins/_commands/webui/main.html
Normal file
395
plugins/_commands/webui/main.html
Normal 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>
|
||||||
BIN
plugins/_commands/webui/thumbnail.png
Normal file
BIN
plugins/_commands/webui/thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
|
|
@ -17,7 +17,7 @@
|
||||||
- Keep desktop state injected into prompts accurate and bounded.
|
- Keep desktop state injected into prompts accurate and bounded.
|
||||||
- Do not expose desktop routes without the expected auth protections.
|
- 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 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
|
## Work Guidance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ URL_INTENT_MAX_ITEMS = 50
|
||||||
URL_INTENT_MAX_LENGTH = 8192
|
URL_INTENT_MAX_LENGTH = 8192
|
||||||
URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
|
URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
|
||||||
EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.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_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
|
||||||
SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
|
SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
|
||||||
SHUTDOWN_CONFIRM_SECONDS = 8
|
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:
|
def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None:
|
||||||
url_associations = ";".join([url_desktop_id, ""])
|
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 = [
|
lines = [
|
||||||
"[Default Applications]",
|
"[Default Applications]",
|
||||||
*(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()),
|
*(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]",
|
"[Added Associations]",
|
||||||
*(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()),
|
*(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)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
41
plugins/_goal/AGENTS.md
Normal file
41
plugins/_goal/AGENTS.md
Normal 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.
|
||||||
1
plugins/_goal/__init__.py
Normal file
1
plugins/_goal/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
1
plugins/_goal/api/__init__.py
Normal file
1
plugins/_goal/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
55
plugins/_goal/api/goal.py
Normal file
55
plugins/_goal/api/goal.py
Normal 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)}
|
||||||
5
plugins/_goal/commands/goal.command.yaml
Normal file
5
plugins/_goal/commands/goal.command.yaml
Normal 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
Loading…
Add table
Add a link
Reference in a new issue