Compare commits

...

20 commits
v2.9 ... main

Author SHA1 Message Date
Alessandro
b22a144bf5 Repair the pinned Xpra runtime stack
Some checks failed
Build And Publish Docker Images / plan (push) Has been cancelled
Build And Publish Docker Images / build (push) Has been cancelled
Install matching Xpra client packages and carry Kali rolling's ATK introspection package into snapshot-based image builds.

Repair self-updated containers by installing the complete Xpra and GTK stack at the installed Xpra version.
2026-08-19 21:22:17 +02:00
Alessandro
d52a1692c1 Pin Docker desktop package versions
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Pin the Python 3.13-compatible LibreOffice and Xpra releases used by the last successful multi-architecture build.

Use Kali's last snapshot and remove beta probing so repository updates cannot pull Python 3.14 into ready images.
2026-08-19 13:30:55 +02:00
Alessandro
7eb819731c Preserve per-chat composer drafts
Store unsent composer text per chat in session storage and restore it when switching contexts or refreshing the page.

Keep the Welcome composer prompt intact when its first send creates a chat, and cover the behavior with a focused Node regression.
2026-08-19 12:28:42 +02:00
Alessandro
81fcc24364 Support standalone user API and WebUI routes
Add usr/api as a contained fallback after existing built-in and plugin handlers, preserving current route precedence and security gates.

Serve usr/extensions/webui assets from an authenticated, root-contained namespace while keeping built-in extension paths unchanged.

Cover source precedence, authentication, path traversal, and manifest-to-asset routing with focused regressions.
2026-08-19 12:14:29 +02:00
Alessandro
a304c7665f Unify subordinate lifecycle across parallel calls
Route direct and parallel call_subordinate execution through one reusable, persisted A1/A2 child-context lifecycle.

Keep failed children resumable with reset=false and cover sibling reuse, recursive numbering, persistence, and nested task ownership.
2026-08-19 09:30:50 +02:00
Alessandro
cf1bec3ed9 Add Goal mode shortcut to chat composer
Add a Goal mode action to the composer popover that prefills the /goal command, focuses the input, and leaves sending to the user. Cover the prefill-only behavior with a focused plugin test.
2026-08-19 04:50:39 +02:00
Alessandro
e6123e476a Fix backup restore allowed origins
Preserve the destination ALLOWED_ORIGINS during restore so a backup created at another URL does not lock out the new instance.

Restore backup credentials and other portable settings without reloading them into the active process.
2026-08-19 04:26:57 +02:00
Alessandro
f6dd6128d9 Fix Codex OAuth utility responses
Preserve completed SSE output items when the final Codex response envelope omits them so non-streaming utility calls retain their generated text. Add focused regression coverage for the native output-item path.
2026-08-19 04:23:36 +02:00
Alessandro
fdf64a7f29 Scope strict response schema to Codex
Keep generic Responses tool schemas provider-neutral and apply the strict text-only response contract at the Codex OAuth request boundary.

Preserve tool-policy filtering by tightening only an already-advertised response tool, with focused regression coverage for both generic and Codex paths.
2026-08-19 04:04:26 +02:00
Alessandro
758f74fd75 Avoid reload prompts for scoped plugin changes
Keep plugin cache invalidation and Python module refreshes for project- and agent-scoped changes, but reserve frontend reload notifications for global plugin changes that can affect the loaded WebUI bundle.

Cover watchdog and direct-toggle paths with focused regressions and document the scoped lifecycle contract.
2026-08-18 14:02:45 +02:00
Alessandro
9c241bdd2c Add scoped composer references
Use the existing scoped profile, tool policy, skill, and workspace catalogs for inert @ references in the WebUI composer.
2026-08-18 12:40:51 +02:00
Alessandro
add781d3b3 Bundle Agent Zero ACP
Some checks failed
Build And Publish Docker Images / plan (push) Has been cancelled
Build And Publish Docker Images / build (push) Has been cancelled
Ship the built-in ACP session bridge with an editor-hosted A0 CLI transport and a startup migration that removes retired a0_acp installations and scoped overrides.
2026-08-16 19:25:05 +02:00
Alessandro
1fb9363b49 Restore internal Browser tabs automatically
Persist Browser tab ownership and URLs in the shared KVP store, migrate an existing Chromium session once, and restore tabs automatically for per-chat and shared scopes without showing Chromium's crash advisory.

Add composer-style microphone and arrow controls to the annotation dialog while preserving draft-versus-send voice behavior.
2026-08-16 18:02:51 +02:00
Alessandro
340d5ef9dd Simplify Browser setup concurrency
Keep dependency reconciliation, cache migration, and Chromium installation under the Browser hook's single setup lock. Remove the redundant nested installer lock from the Playwright helper.
2026-08-16 17:05:03 +02:00
Alessandro
227b47f53d Share one internal Browser runtime
Route every chat through one persistent Chromium, Xvfb, and Xpra runtime while keeping tab ownership and per-chat limits context-bound.

Share sign-ins across chats, adopt the first requesting legacy profile on upgrade, and close only the owning chat's tabs during reset or removal.

Keep separate and shared tab-strip modes as visibility choices and cover cross-context access, popups, screenshots, startup ownership, and cleanup.
2026-08-16 15:51:32 +02:00
Alessandro
4bf92a6072 Improve Browser startup and annotations
Keep tab loading feedback stable and visible while the interactive Browser starts without waiting for a redundant screenshot.

Add DOM hover feedback, multi-page annotation batches, compact composer-style controls, and shared Whisper draft or send behavior.
2026-08-16 15:22:10 +02:00
Alessandro
a0eefe19bd Honor configured Browser model preset
Activate the selected Browser preset after the first Browser tool call so follow-up model turns use it.

Clear the temporary override at monologue end and preserve the main model as the safe fallback.
2026-08-16 15:21:52 +02:00
Alessandro
005b366b51 Add interactive internal Browser viewport
Render the existing Patchright page through an isolated, authenticated Xpra session with CDP screencast and snapshot fallbacks.

Keep Chromium out of fullscreen, synchronize native viewport resizing across canvas and modal handoffs, and remove Xpra decoration and shadow-cursor artifacts.

Move loading feedback into each Browser tab while reserving the footer for actual errors.
2026-08-16 12:58:17 +02:00
Alessandro
e2f43a3fb8 Harden internal Browser against bot detection
Run Chromium headful through Patchright on a private Xvfb display while keeping Agent Zero's page helpers in Patchright's isolated world.

Reconcile the pinned Patchright package and matching architecture-specific Chromium from Browser plugin hooks so fresh Docker images and self-updated installations converge on the same runtime.
2026-08-13 12:34:04 +02:00
Alessandro
a767824fb6 Fix Responses tool call completion
Recover function calls emitted during streaming when a terminal Responses event omits them. Advertise the final response tool with a strict text-only schema across agent profiles.
2026-08-13 12:34:04 +02:00
83 changed files with 5631 additions and 674 deletions

View file

@ -19,6 +19,7 @@
- `BRANCH` is required for branch-based Docker builds. - `BRANCH` is required for branch-based Docker builds.
- Preserve exposed ports for SSH, HTTP, and tunneled services unless docs and workflows are updated together. - Preserve exposed ports for SSH, HTTP, and tunneled services unless docs and workflows are updated together.
- Keep the two-runtime Python model aligned with the root contract. - Keep the two-runtime Python model aligned with the root contract.
- Keep runtime desktop packages on `kali-last-snapshot`; carry the rolling base's matching ATK introspection package into that transaction, then pin the verified Python 3.13-compatible LibreOffice and complete Xpra runtime versions in `fs/ins/install_additional.sh` for both published architectures.
- 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.

View file

@ -12,80 +12,58 @@ if ! command -v apt-get >/dev/null 2>&1; then
exit 0 exit 0
fi fi
XPRA_PACKAGES=(xpra xpra-x11 xpra-html5) KALI_SUITE="kali-last-snapshot"
LIBREOFFICE_VERSION="4:26.2.4.2-1"
XPRA_VERSION="6.5.2-r0-1"
arch="$(dpkg --print-architecture)"
install_xpra_repo() { XPRA_HTML5_VERSION="19-r1-1"
local os_id="" if [ "$arch" = "arm64" ]; then
local codename="" XPRA_HTML5_VERSION="21-r1-1"
local uri="https://xpra.org" fi
local suite="trixie"
local arch
arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)" LIBREOFFICE_PACKAGES=(
"libreoffice-core=$LIBREOFFICE_VERSION"
"libreoffice-writer=$LIBREOFFICE_VERSION"
"libreoffice-calc=$LIBREOFFICE_VERSION"
"libreoffice-impress=$LIBREOFFICE_VERSION"
"libreoffice-gtk3=$LIBREOFFICE_VERSION"
"python3-uno=$LIBREOFFICE_VERSION"
)
XPRA_PACKAGES=(
"xpra-common=$XPRA_VERSION"
"xpra-server=$XPRA_VERSION"
"xpra-client=$XPRA_VERSION"
"xpra-client-gtk3=$XPRA_VERSION"
"xpra-x11=$XPRA_VERSION"
"xpra-html5=$XPRA_HTML5_VERSION"
)
if [ -r /etc/os-release ]; then apt-get update
# shellcheck disable=SC1091 ATK_VERSION="$(dpkg-query -W -f='${Version}' libatk1.0-0t64)"
. /etc/os-release ATK_GIR_PACKAGE="/tmp/gir1.2-atk-1.0_${ATK_VERSION}_${arch}.deb"
os_id="${ID:-}" (cd /tmp && apt-get download "gir1.2-atk-1.0=$ATK_VERSION")
codename="${VERSION_CODENAME:-}"
fi
if [ "$os_id" = "kali" ]; then for source in /etc/apt/sources.list /etc/apt/sources.list.d/kali.sources; do
uri="https://xpra.org/beta" [ ! -f "$source" ] || sed -i "s/kali-rolling/$KALI_SUITE/g" "$source"
suite="sid" done
elif [ "$codename" = "sid" ] || [ "$codename" = "forky" ]; then
uri="https://xpra.org/beta"
suite="$codename"
elif [ -n "$codename" ]; then
suite="$codename"
fi
apt-get update apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates wget DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates wget
configure_xpra_repo "$uri" "$suite" "$arch" wget -O /usr/share/keyrings/xpra.asc https://xpra.org/xpra.asc
apt-get update cat >/etc/apt/sources.list.d/xpra.sources <<EOF
if ! xpra_install_check; then
echo "xpra packages are not installable from ${uri} ${suite} for ${arch}; falling back to https://xpra.org trixie"
XPRA_PACKAGES=(xpra-server xpra-x11 xpra-html5)
configure_xpra_repo "https://xpra.org" "trixie" "$arch"
apt-get update
if ! xpra_install_check; then
cat /tmp/xpra-install-check.log
exit 1
fi
fi
}
xpra_install_check() {
DEBIAN_FRONTEND=noninteractive apt-get install -s --no-install-recommends "${XPRA_PACKAGES[@]}" >/tmp/xpra-install-check.log 2>&1
}
configure_xpra_repo() {
local uri="$1"
local suite="$2"
local arch="$3"
wget -O /usr/share/keyrings/xpra.asc https://xpra.org/xpra.asc
cat >/etc/apt/sources.list.d/xpra.sources <<EOF
Types: deb Types: deb
URIs: ${uri} URIs: https://xpra.org
Suites: ${suite} Suites: trixie
Components: main Components: main
Signed-By: /usr/share/keyrings/xpra.asc Signed-By: /usr/share/keyrings/xpra.asc
Architectures: ${arch} Architectures: $arch
EOF EOF
}
install_xpra_repo
apt-get update apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
libreoffice-core \ "$ATK_GIR_PACKAGE" \
libreoffice-writer \ gir1.2-gtk-3.0 \
libreoffice-calc \ "${LIBREOFFICE_PACKAGES[@]}" \
libreoffice-impress \
libreoffice-gtk3 \
python3-uno \
"${XPRA_PACKAGES[@]}" \ "${XPRA_PACKAGES[@]}" \
xfce4-session \ xfce4-session \
xfwm4 \ xfwm4 \
@ -97,8 +75,12 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
libglib2.0-bin \ libglib2.0-bin \
xfce4-terminal \ xfce4-terminal \
x11-xserver-utils \ x11-xserver-utils \
x11-utils \
x11-apps \
xdotool \ xdotool \
xclip \
xauth \ xauth \
xvfb \
dbus-x11 \ dbus-x11 \
fonts-dejavu \ fonts-dejavu \
fonts-liberation \ fonts-liberation \
@ -108,4 +90,5 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
fonts-noto-cjk \ fonts-noto-cjk \
fonts-noto-color-emoji fonts-noto-color-emoji
rm -f "$ATK_GIR_PACKAGE"
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*

View file

@ -4,13 +4,10 @@ set -e
# activate venv # activate venv
. "/ins/setup_venv.sh" "$@" . "/ins/setup_venv.sh" "$@"
# install playwright if not installed (should be from requirements.txt)
uv pip install playwright
# set PW installation path to temporary Browser runtime storage # set PW installation path to temporary Browser runtime storage
export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright
mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"
# install chromium with dependencies # preinstall Chromium for fresh images; the Browser hook also reconciles self-updated installs
apt-get install -y fonts-unifont libnss3 libnspr4 libatk1.0-0 libatspi2.0-0 libxcomposite1 libxdamage1 libatk-bridge2.0-0 libcups2 apt-get install -y fonts-unifont libnss3 libnspr4 libatk1.0-0 libatspi2.0-0 libxcomposite1 libxdamage1 libatk-bridge2.0-0 libcups2
playwright install chromium patchright install chromium --no-shell

View file

@ -234,7 +234,7 @@ See [MCP Setup](mcp-setup.md) for MCP setup.
## Troubleshooting ## Troubleshooting
- **Browser says Playwright is missing:** Docker installs already include the browser. In local development, let Agent Zero install it on first use or preinstall it with `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium`. - **Browser says Chromium is missing:** Docker installs already include the browser. In local development, let Agent Zero install it on first use or preinstall it with `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright patchright install chromium --no-shell`.
- **The Browser surface does not open automatically:** That is expected. Open the Browser surface manually or ask the agent to show it. - **The Browser surface does not open automatically:** That is expected. Open the Browser surface manually or ask the agent to show it.
- **The Canvas does not follow the agent:** Enable **Autofocus active page** in Browser settings. - **The Canvas does not follow the agent:** Enable **Autofocus active page** in Browser settings.
- **Bring Your Own Browser cannot start:** Keep A0 CLI connected, verify Browser location is **Bring Your Own Browser**, and check `/browser status` in A0 CLI. - **Bring Your Own Browser cannot start:** Keep A0 CLI connected, verify Browser location is **Bring Your Own Browser**, and check `/browser status` in A0 CLI.

View file

@ -26,7 +26,7 @@ Refer to the [Choosing your LLMs](../setup/installation.md#installing-and-using-
**7. How can I make Agent Zero retain memory between sessions?** **7. How can I make Agent Zero retain memory between sessions?**
Use **Settings -> Backup & Restore** and avoid mapping the entire `/a0` directory. See [How to update Agent Zero](../setup/installation.md#how-to-update-agent-zero). Use **Settings -> Backup & Restore** and avoid mapping the entire `/a0` directory. See [How to update Agent Zero](../setup/installation.md#how-to-update-agent-zero).
**8. My browser tool fails or says Playwright is missing. What now?** **8. My browser tool fails or says Chromium is missing. What now?**
In normal Docker installs, the Browser already includes what it needs. In normal Docker installs, the Browser already includes what it needs.
@ -35,7 +35,7 @@ browser the first time it is needed. To install it ahead of time, run this from
the project root after installing Python requirements: the project root after installing Python requirements:
```bash ```bash
PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium PLAYWRIGHT_BROWSERS_PATH=tmp/playwright patchright install chromium --no-shell
``` ```
If **Bring Your Own Browser** mode fails: If **Bring Your Own Browser** mode fails:

View file

@ -68,12 +68,12 @@ Now when you select one of the python files in the project, you should see prope
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright playwright install chromium PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright patchright install chromium --no-shell
``` ```
The first command installs Python dependencies. The first command installs Python dependencies.
The second command installs full Playwright Chromium into `./tmp/playwright`, The second command installs full Patchright Chromium into `./tmp/playwright`,
relative to the project root. Docker images use the absolute path relative to the project root. Docker images use the absolute path
`/a0/tmp/playwright` and ship Chromium preinstalled. `/a0/tmp/playwright` and ship Chromium preinstalled.

View file

@ -214,7 +214,7 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
return await cached() return await cached()
# Resolve file path for the handler # Resolve file path for the handler
# Try built-in api folder first, then plugin api folders # Try built-in and plugin api folders before the user fallback
handler_cls: type[ApiHandler] | None = None handler_cls: type[ApiHandler] | None = None
# Check built-in python/api/<path>.py # Check built-in python/api/<path>.py
@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
if classes: if classes:
handler_cls = classes[0] handler_cls = classes[0]
# Check user api/<path>.py
if handler_cls is None:
user_api_dir = files.get_abs_path(files.USER_DIR, files.API_DIR)
user_file = files.get_abs_path(user_api_dir, f"{path}.py")
if files.is_in_dir(user_file, user_api_dir) and files.exists(user_file):
classes = load_classes_from_file(user_file, ApiHandler)
if classes:
handler_cls = classes[0]
if handler_cls is None: if handler_cls is None:
return Response(f"API endpoint not found: {path}", 404) return Response(f"API endpoint not found: {path}", 404)

View file

@ -45,6 +45,7 @@
## Key Concepts ## Key Concepts
- Important called helpers/classes observed in the source: `wraps`, `app.add_url_rule`, `watchdog.add_watchdog`, `cls.requires_auth`, `_use_context`, `login.get_credentials_hash`, `files.get_abs_path`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `handler_cls.requires_auth`, `handler_cls.requires_loopback`, `cache.add`, `PrintStyle.debug`, `cache.clear`, `get_settings`, `f`, `is_loopback_address`, `Response`, `redirect`, `files.is_in_dir`. - Important called helpers/classes observed in the source: `wraps`, `app.add_url_rule`, `watchdog.add_watchdog`, `cls.requires_auth`, `_use_context`, `login.get_credentials_hash`, `files.get_abs_path`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `handler_cls.requires_auth`, `handler_cls.requires_loopback`, `cache.add`, `PrintStyle.debug`, `cache.clear`, `get_settings`, `f`, `is_loopback_address`, `Response`, `redirect`, `files.is_in_dir`.
- HTTP handlers retain built-in `api/` and explicit plugin API precedence, then fall back to standalone `usr/api/`; built-in and user roots are containment-checked, and every loaded handler keeps its declared authentication, CSRF, API-key, loopback, and method gates.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes. - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance ## Work Guidance

View file

@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional
from pathspec import PathSpec from pathspec import PathSpec
from helpers import files, runtime, git from helpers import files, runtime, git, dotenv
from helpers.localization import Localization from helpers.localization import Localization
from helpers.print_style import PrintStyle from helpers.print_style import PrintStyle
@ -608,6 +608,9 @@ class BackupService:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Restore files from backup archive""" """Restore files from backup archive"""
allowed_origins = dotenv.get_dotenv_value("ALLOWED_ORIGINS", "")
dotenv_path = os.path.abspath(dotenv.get_dotenv_file_path())
# Save uploaded file temporarily # Save uploaded file temporarily
temp_dir = tempfile.mkdtemp() temp_dir = tempfile.mkdtemp()
temp_file = os.path.join(temp_dir, "backup.zip") temp_file = os.path.join(temp_dir, "backup.zip")
@ -725,6 +728,11 @@ class BackupService:
with zipf.open(archive_path) as source, open(target_path, 'wb') as target: with zipf.open(archive_path) as source, open(target_path, 'wb') as target:
shutil.copyfileobj(source, target) shutil.copyfileobj(source, target)
if os.path.abspath(target_path) == dotenv_path:
dotenv.save_dotenv_value(
"ALLOWED_ORIGINS", allowed_origins, reload_env=False
)
restored_files.append({ restored_files.append({
"archive_path": archive_path, "archive_path": archive_path,
"original_path": original_path, "original_path": original_path,

View file

@ -27,6 +27,7 @@
- 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/**`. - Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`.
- Restoring `usr/.env` preserves the destination instance's allowed origins while restoring authentication and other portable configuration from the archive.
## Key Concepts ## Key Concepts

View file

@ -21,7 +21,7 @@ def get_dotenv_value(key: str, default: Any = None):
# load_dotenv() # load_dotenv()
return os.getenv(key, default) return os.getenv(key, default)
def save_dotenv_value(key: str, value: str): def save_dotenv_value(key: str, value: str, reload_env: bool = True):
if value is None: if value is None:
value = "" value = ""
dotenv_path = get_dotenv_file_path() dotenv_path = get_dotenv_file_path()
@ -40,4 +40,5 @@ def save_dotenv_value(key: str, value: str):
f.seek(0) f.seek(0)
f.writelines(lines) f.writelines(lines)
f.truncate() f.truncate()
load_dotenv() if reload_env:
load_dotenv()

View file

@ -14,7 +14,7 @@
- `load_dotenv()` - `load_dotenv()`
- `get_dotenv_file_path()` - `get_dotenv_file_path()`
- `get_dotenv_value(key: str, default: Any=...)` - `get_dotenv_value(key: str, default: Any=...)`
- `save_dotenv_value(key: str, value: str)` - `save_dotenv_value(key: str, value: str, reload_env: bool=...)`
- Notable constants/configuration names: `KEY_AUTH_LOGIN`, `KEY_AUTH_PASSWORD`, `KEY_RFC_PASSWORD`, `KEY_ROOT_PASSWORD`. - Notable constants/configuration names: `KEY_AUTH_LOGIN`, `KEY_AUTH_PASSWORD`, `KEY_RFC_PASSWORD`, `KEY_ROOT_PASSWORD`.
## Runtime Contracts ## Runtime Contracts
@ -23,6 +23,7 @@
- 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.
- Observed side-effect areas: filesystem reads, filesystem writes, secret handling. - Observed side-effect areas: filesystem reads, filesystem writes, secret handling.
- Imported dependency areas include: `dotenv`, `files`, `os`, `re`, `typing`. - Imported dependency areas include: `dotenv`, `files`, `os`, `re`, `typing`.
- `save_dotenv_value(..., reload_env=False)` updates the persisted file without changing the running process environment.
## Key Concepts ## Key Concepts

View file

@ -428,7 +428,13 @@ class LiteLLMTransport:
) -> LLMResult | None: ) -> LLMResult | None:
if parser.completed_response is None: if parser.completed_response is None:
return None return None
return self._llm_result_from_response(parser.completed_response, request) response = _object_to_dict(parser.completed_response)
output = _as_list(response.get("output"))
if parser.function_calls and not any(
_get_value(item, "type") == "function_call" for item in output
):
response["output"] = [*output, *parser.function_calls.values()]
return self._llm_result_from_response(response, request)
def _stream_result_from_chat_parser( def _stream_result_from_chat_parser(
self, parser: "ChatCompletionsStreamParser" self, parser: "ChatCompletionsStreamParser"

View file

@ -33,6 +33,7 @@
- Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error. - Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error.
- Fall back to Chat Completions when LiteLLM's Responses mock streaming path tries to JSON-decode a real SSE stream before any output. - Fall back to Chat Completions when LiteLLM's Responses mock streaming path tries to JSON-decode a real SSE stream before any output.
- Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items. - Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items.
- Preserve Responses function calls collected from stream events when a terminal completed event omits them.
- Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported. - Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
- Keep prompt-cache markers only for providers that accept them. - Keep prompt-cache markers only for providers that accept them.

View file

@ -23,6 +23,7 @@ PARALLEL_WORKER_JOB_KEY = "_parallel_job_id"
PARALLEL_WORKER_KIND_KEY = "_parallel_worker_kind" PARALLEL_WORKER_KIND_KEY = "_parallel_worker_kind"
CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id" CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind" CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label" CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id" CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id"
@ -53,6 +54,7 @@ class ParallelJob:
tool_name: str tool_name: str
tool_args: dict[str, Any] tool_args: dict[str, Any]
kind: JobKind kind: JobKind
parent_agent: "Agent | None" = field(default=None, repr=False)
state: JobState = "pending" state: JobState = "pending"
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
started_at: float | None = None started_at: float | None = None
@ -208,6 +210,7 @@ async def start_parallel_jobs(
tool_name=call.tool_name, tool_name=call.tool_name,
tool_args=call.tool_args, tool_args=call.tool_args,
kind=kind, kind=kind,
parent_agent=agent,
) )
job_store[job.id] = job job_store[job.id] = job
jobs.append(job) jobs.append(job)
@ -218,6 +221,8 @@ async def start_parallel_jobs(
job.started_at = time.time() job.started_at = time.time()
task = DeferredTask(thread_name=THREAD_BACKGROUND) task = DeferredTask(thread_name=THREAD_BACKGROUND)
job.deferred_task = task job.deferred_task = task
if _parallel_worker_kind(agent) == "subordinate" and context.task:
context.task.add_child_task(task)
task.start_task(_run_parallel_job, context.id, job.id) task.start_task(_run_parallel_job, context.id, job.id)
except Exception as exc: except Exception as exc:
_finish_job(job, "error", error=str(exc)) _finish_job(job, "error", error=str(exc))
@ -410,34 +415,39 @@ async def _run_parallel_job(parent_context_id: str, job_id: str) -> None:
async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str: async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str:
from agent import AgentContext, AgentContextType, UserMessage from agent import AgentContext
from helpers import message_queue, persist_chat
from helpers.tool_policy import ensure_tool_allowed from helpers.tool_policy import ensure_tool_allowed
from tools.call_subordinate import _validate_subordinate_profile from tools.call_subordinate import get_or_create_subordinate, run_subordinate
parent_context = AgentContext.get(parent_context_id) parent_context = AgentContext.get(parent_context_id)
if not parent_context: if not parent_context:
raise ValueError("Parent context not found.") raise ValueError("Parent context not found.")
ensure_tool_allowed(parent_context.agent0, "call_subordinate") parent_agent = job.parent_agent or parent_context.agent0
ensure_tool_allowed(parent_agent, "call_subordinate")
args = job.tool_args args = job.tool_args
message = str(args.get("message") or "").strip() message = str(args.get("message") or "").strip()
if not message: if not message:
raise ValueError("call_subordinate requires `tool_args.message`.") raise ValueError("call_subordinate requires `tool_args.message`.")
profile = _validate_subordinate_profile( context_id = str(args.get("context_id") or args.get("agent_id") or "").strip()
parent_context.agent0, reset = args.get("reset", False)
str(args.get("profile") or args.get("agent_profile") or ""), slot = (
job.id
if coerce_bool(reset, False) and not context_id
else "default"
) )
attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else [] attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else []
attachments = [str(item) for item in attachments] subordinate = get_or_create_subordinate(
parent_agent,
child_name = _subordinate_context_name(job) profile=str(args.get("profile") or args.get("agent_profile") or ""),
worker_context = AgentContext( reset=reset,
config=_clone_config(parent_context.config, profile=profile), context_id=context_id,
name=child_name, name=str(args.get("name") or ""),
type=AgentContextType.USER, message=message,
slot=slot,
) )
worker_context = subordinate.context
job.worker_context_id = worker_context.id job.worker_context_id = worker_context.id
if job.deferred_task: if job.deferred_task:
worker_context.task = job.deferred_task worker_context.task = job.deferred_task
@ -445,30 +455,9 @@ async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob)
worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id) worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id)
worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id) worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id)
worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind) worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind)
worker_context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent_context.id)
worker_context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "parallel")
worker_context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, child_name)
worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id) worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id)
worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name) worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name)
_copy_project(parent_context, worker_context) return await run_subordinate(parent_agent, subordinate, message, attachments)
system_prompt = _subordinate_worker_system_prompt(profile)
message_queue.log_user_message(worker_context, message, attachments, source=" (parallel)")
worker_context.agent0.hist_add_user_message(
UserMessage(
message=message,
attachments=attachments,
system_message=[system_prompt],
)
)
persist_chat.save_tmp_chat(worker_context)
try:
result = await worker_context.agent0.monologue()
worker_context.agent0.history.new_topic()
return result
finally:
persist_chat.save_tmp_chat(worker_context)
async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str: async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
@ -711,16 +700,13 @@ def _job_snapshot(job: ParallelJob, *, include_result: bool) -> dict[str, Any]:
return data return data
def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig": def _clone_config(config: "AgentConfig") -> "AgentConfig":
try: try:
cloned = replace( return replace(
config, config,
knowledge_subdirs=list(config.knowledge_subdirs), knowledge_subdirs=list(config.knowledge_subdirs),
additional=dict(config.additional), additional=dict(config.additional),
) )
if profile:
cloned.profile = profile
return cloned
except Exception: except Exception:
return config return config
@ -734,27 +720,3 @@ def _copy_project(parent_context: "AgentContext", worker_context: "AgentContext"
projects.activate_project(worker_context.id, project_name, mark_dirty=False) projects.activate_project(worker_context.id, project_name, mark_dirty=False)
except Exception: except Exception:
pass pass
def _subordinate_worker_system_prompt(profile: str) -> str:
lines = [
"You are running as an isolated parallel worker for a parent Agent Zero chat.",
"Return a concise final textual summary for the parent. Artifacts and files are supplementary, not a substitute for the textual result.",
]
if profile:
lines.append(f"Act with the `{profile}` profile's expertise and priorities.")
return "\n".join(lines)
def _subordinate_context_name(job: ParallelJob) -> str:
name = str(job.tool_args.get("name") or "").strip()
if name:
return name
message = str(job.tool_args.get("message") or "").strip()
label = _short_label(message)
return label or f"Parallel subordinate {job.index + 1}"
def _short_label(text: str, limit: int = 80) -> str:
compact = " ".join(text.split())
return compact[:limit].rstrip()

View file

@ -26,11 +26,11 @@
- Normalization accepts full agent-reply-shaped objects when `tool_name` and `tool_args` are present; non-contract planning fields such as `thoughts` or `headline` are ignored. - Normalization accepts full agent-reply-shaped objects when `tool_name` and `tool_args` are present; non-contract planning fields such as `thoughts` or `headline` are ignored.
- `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification. - `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification.
- Normalization rejects `document_query` and `response` inside `parallel`: document parsing and Q&A must run sequentially, while `response` must remain top-level so it can end the message loop. - Normalization rejects `document_query` and `response` inside `parallel`: document parsing and Q&A must run sequentially, while `response` must remain top-level so it can end the message loop.
- `call_subordinate` jobs first enforce the parent profile's delegation policy - `call_subordinate` jobs first enforce the actual calling agent's delegation policy, then call the same creation and execution functions as direct delegation in `tools/call_subordinate.py`; this helper does not construct or prompt a second kind of subordinate.
and validate the requested profile through the sequential delegation owner, - Fresh parallel sibling calls create distinct `parent.number + 1` child agents. Their job snapshots expose stable `context_id` values that direct or parallel `reset=false` calls can continue after success or failure.
then run in isolated child chat contexts tagged with parent-chat metadata; - Jobs retain their actual parent agent so parallel calls made by A1 create A2 rather than falling back to a context's A0.
they must not be added to the scheduler task list and may use normal child-chat - Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat tools including `parallel`.
tools, including `parallel`. - Nested parallel jobs started by a parallel subordinate are registered as child `DeferredTask` instances so stopping the ancestor also stops its descendants.
- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`. - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
- 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.

View file

@ -121,7 +121,7 @@ class PluginUpdateInfo(BaseModel):
def register_watchdogs(): def register_watchdogs():
def on_plugin_change(events: list[WatchItem]): def on_plugin_change(events: list[WatchItem], frontend_reload: bool = True):
plugin_names: list[str] = [] plugin_names: list[str] = []
for path, _event in events: for path, _event in events:
path = path.replace("\\", "/") path = path.replace("\\", "/")
@ -132,7 +132,11 @@ def register_watchdogs():
plugin_names.append(plugin_name) plugin_names.append(plugin_name)
print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names) print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names)
python_change = any(path.endswith('.py') for path, _event in events) python_change = any(path.endswith('.py') for path, _event in events)
after_plugin_change(plugin_names or None, python_change=python_change) after_plugin_change(
plugin_names or None,
python_change=python_change,
frontend_reload=frontend_reload,
)
relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT] relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT]
@ -162,7 +166,7 @@ def register_watchdogs():
*expand_patterns(f"*/{projects.PROJECT_META_DIR}/plugins/"), *expand_patterns(f"*/{projects.PROJECT_META_DIR}/plugins/"),
*expand_patterns(f"*/{projects.PROJECT_META_DIR}/agents/*/plugins/"), *expand_patterns(f"*/{projects.PROJECT_META_DIR}/agents/*/plugins/"),
], ],
handler=on_plugin_change, handler=lambda events: on_plugin_change(events, frontend_reload=False),
) )
# add watchdogs for plugin overrides in /agents/plugins and /usr/agents/plugins # add watchdogs for plugin overrides in /agents/plugins and /usr/agents/plugins
@ -173,16 +177,21 @@ def register_watchdogs():
files.get_abs_path(subagents.USER_AGENTS_DIR), files.get_abs_path(subagents.USER_AGENTS_DIR),
], ],
patterns=[*expand_patterns(f"*/plugins/*/")], patterns=[*expand_patterns(f"*/plugins/*/")],
handler=on_plugin_change, handler=lambda events: on_plugin_change(events, frontend_reload=False),
) )
@extension.extensible @extension.extensible
def after_plugin_change(plugin_names: list[str] | None = None, python_change:bool=False): def after_plugin_change(
plugin_names: list[str] | None = None,
python_change: bool = False,
frontend_reload: bool = True,
):
clear_plugin_cache(plugin_names) clear_plugin_cache(plugin_names)
if python_change: if python_change:
refresh_plugin_modules(plugin_names) refresh_plugin_modules(plugin_names)
send_frontend_reload_notification(plugin_names) if frontend_reload:
send_frontend_reload_notification(plugin_names)
def refresh_plugin_modules(plugin_names: list[str] | None = None): def refresh_plugin_modules(plugin_names: list[str] | None = None):
@ -582,7 +591,9 @@ def toggle_plugin(
files.write_file(enabled_file, "") files.write_file(enabled_file, "")
else: else:
files.write_file(disabled_file, "") files.write_file(disabled_file, "")
after_plugin_change([plugin_name]) after_plugin_change(
[plugin_name], frontend_reload=not (project_name or agent_profile)
)
@extension.extensible @extension.extensible

View file

@ -17,7 +17,7 @@
- `PluginUpdateInfo` (`BaseModel`) - `PluginUpdateInfo` (`BaseModel`)
- Top-level functions: - Top-level functions:
- `register_watchdogs()` - `register_watchdogs()`
- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=...)` - `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=..., frontend_reload: bool=...)`
- `refresh_plugin_modules(plugin_names: list[str] | None=...)` - `refresh_plugin_modules(plugin_names: list[str] | None=...)`
- `clear_plugin_cache(plugin_names: list[str] | None=...)` - `clear_plugin_cache(plugin_names: list[str] | None=...)`
- `get_plugin_roots(plugin_name: str=...) -> List[str]`: Plugin root directories, ordered by priority (user first). - `get_plugin_roots(plugin_name: str=...) -> List[str]`: Plugin root directories, ordered by priority (user first).
@ -53,6 +53,8 @@
stale global or scoped disable files, and disable attempts are rejected. stale global or scoped disable files, and disable attempts are rejected.
- Config hooks receive `hook_context={"caller": caller}` with one of `ui`, - Config hooks receive `hook_context={"caller": caller}` with one of `ui`,
`agent`, or `api`; this is behavioral context, not an authorization boundary. `agent`, or `api`; this is behavioral context, not an authorization boundary.
- Project- and agent-scoped plugin changes invalidate runtime caches without a
frontend reload prompt because the loaded WebUI extension bundle is global.
- 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.
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling.
- Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`. - Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`.

View file

@ -17,6 +17,7 @@
owns the Responses-specific prompt-name compatibility rules. owns the Responses-specific prompt-name compatibility rules.
- Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename. - Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename.
- Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates. - Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates.
- Keep emitted schemas provider-neutral; provider-specific strictness belongs at the provider request boundary.
- Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types. - Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types.
- Native local-tool descriptions reuse the tool catalog's compact prompt - Native local-tool descriptions reuse the tool catalog's compact prompt
description; Responses retains native-name mapping, schema derivation, and description; Responses retains native-name mapping, schema derivation, and

View file

@ -205,6 +205,12 @@ class UiServerRuntime:
handlers.serve_extension_asset, handlers.serve_extension_asset,
methods=["GET"], methods=["GET"],
) )
self.webapp.add_url_rule(
"/usr/extensions/webui/<path:asset_path>",
"serve_user_extension_asset",
handlers.serve_user_extension_asset,
methods=["GET"],
)
self._routes_registered = True self._routes_registered = True
def register_transport_handlers(self) -> None: def register_transport_handlers(self) -> None:
@ -403,9 +409,19 @@ class UiRouteHandlers:
@requires_auth @requires_auth
async def serve_extension_asset(self, asset_path): async def serve_extension_asset(self, asset_path):
exts = files.get_abs_path("extensions/webui") return self._serve_extension_asset(
path = files.get_abs_path(exts, asset_path) files.get_abs_path("extensions/webui"), asset_path
if not files.is_in_dir(path, exts): )
@requires_auth
async def serve_user_extension_asset(self, asset_path):
return self._serve_extension_asset(
files.get_abs_path(files.USER_DIR, "extensions/webui"), asset_path
)
def _serve_extension_asset(self, extension_dir, asset_path):
path = files.get_abs_path(extension_dir, asset_path)
if not files.is_in_dir(path, extension_dir):
return Response("Access denied", 403) return Response("Access denied", 403)
return send_file(path) return send_file(path)

View file

@ -28,6 +28,7 @@
- `async serve_builtin_plugin_asset(self, plugin_name, asset_path)` - `async serve_builtin_plugin_asset(self, plugin_name, asset_path)`
- `async serve_plugin_asset(self, plugin_name, asset_path)` - `async serve_plugin_asset(self, plugin_name, asset_path)`
- `async serve_extension_asset(self, asset_path)` - `async serve_extension_asset(self, asset_path)`
- `async serve_user_extension_asset(self, asset_path)`
- Top-level functions: - Top-level functions:
- `_positive_int_env(name: str, default: int) -> int` - `_positive_int_env(name: str, default: int) -> int`
- `configure_process_environment() -> None` - `configure_process_environment() -> None`
@ -45,6 +46,7 @@
- Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`. - Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
- `serve_index()` bootstraps the normalized UI control visibility map, timezone and time-format preferences, and the complete enabled WebUI extension manifest so startup extension discovery requires no per-surface API requests. - `serve_index()` bootstraps the normalized UI control visibility map, timezone and time-format preferences, and the complete enabled WebUI extension manifest so startup extension discovery requires no per-surface API requests.
- Authenticated extension asset routes serve root-contained files from both `extensions/webui/` and `usr/extensions/webui/`, matching the URLs emitted by the WebUI extension manifest.
- The authenticated `/` route uses `serve_splash()` to return the no-store, self-contained bootstrap document. The authenticated extensionless `/ui/index` route renders the existing index and runtime/user placeholders for the splash to install into the current document without navigation; `/index.html` remains a direct fallback for the same rendering path. The authenticated `/safe` route first returns a no-store, self-contained document that unregisters all origin service workers, then renders the existing index through `serve_index()` when its internal `__direct=1` marker is present; it never initializes the asset bundle or a worker. The authenticated `serve_ui_asset_bundle()` endpoint passes the application entry URL to the generic recursive bundler and supports gzip transfer and payload-specific ETag revalidation while component, extension, and Alpine lifecycles remain unchanged. - The authenticated `/` route uses `serve_splash()` to return the no-store, self-contained bootstrap document. The authenticated extensionless `/ui/index` route renders the existing index and runtime/user placeholders for the splash to install into the current document without navigation; `/index.html` remains a direct fallback for the same rendering path. The authenticated `/safe` route first returns a no-store, self-contained document that unregisters all origin service workers, then renders the existing index through `serve_index()` when its internal `__direct=1` marker is present; it never initializes the asset bundle or a worker. The authenticated `serve_ui_asset_bundle()` endpoint passes the application entry URL to the generic recursive bundler and supports gzip transfer and payload-specific ETag revalidation while component, extension, and Alpine lifecycles remain unchanged.
- The Starlette HTTP branch applies negotiated gzip to responses of at least 1 KiB at compression level 6 while preserving already encoded responses; Socket.IO remains outside that middleware branch. - The Starlette HTTP branch applies negotiated gzip to responses of at least 1 KiB at compression level 6 while preserving already encoded responses; Socket.IO remains outside that middleware branch.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes. - Keep request/response, tool, or helper semantics documented here at the same time as source changes.

View file

@ -115,29 +115,38 @@ def get_registry() -> VirtualDesktopRegistry:
return _registry return _registry
def session_url(token: str, *, title: str = "Desktop") -> str: def session_url(
token: str,
*,
title: str = "Desktop",
encoding: str = "jpeg",
quality: int = 85,
speed: int = 80,
file_transfer: bool = True,
printing: bool = True,
) -> str:
quoted_token = quote(str(token), safe="") quoted_token = quote(str(token), safe="")
base_path = f"{SESSION_PATH}/{quoted_token}/" base_path = f"{SESSION_PATH}/{quoted_token}/"
query = urlencode( options = {
{ "path": base_path,
"path": base_path, "title": title,
"title": title, "quality": str(max(0, min(100, int(quality)))),
"encoding": "jpeg", "speed": str(max(0, min(100, int(speed)))),
"quality": "85", "sharing": "true",
"speed": "80", "clipboard": "true",
"sharing": "true", "clipboard_direction": "both",
"clipboard": "true", "clipboard_poll": "true",
"clipboard_direction": "both", "clipboard_preferred_format": "text/plain",
"clipboard_poll": "true", "printing": str(bool(printing)).lower(),
"clipboard_preferred_format": "text/plain", "file_transfer": str(bool(file_transfer)).lower(),
"printing": "true", "sound": "false",
"file_transfer": "true", "offscreen": "true",
"sound": "false", "floating_menu": "false",
"offscreen": "true", "xpramenu": "false",
"floating_menu": "false", }
"xpramenu": "false", if encoding:
}, options["encoding"] = str(encoding)
) query = urlencode(options)
return f"{base_path}index.html?{query}" return f"{base_path}index.html?{query}"
@ -264,6 +273,7 @@ def resize_display(
keys: tuple[str, ...] = (), keys: tuple[str, ...] = (),
xauthority: str = "", xauthority: str = "",
home: str = "", home: str = "",
settle_seconds: float = 0.15,
) -> dict[str, Any]: ) -> dict[str, Any]:
target_width, target_height = normalize_size(width, height, max_width=max_width, max_height=max_height) target_width, target_height = normalize_size(width, height, max_width=max_width, max_height=max_height)
xrandr = shutil.which("xrandr") xrandr = shutil.which("xrandr")
@ -296,7 +306,8 @@ def resize_display(
timeout=4, timeout=4,
env=env, env=env,
) )
time.sleep(0.15) if settle_seconds > 0:
time.sleep(settle_seconds)
current = current_display_size(display, xauthority=xauthority, home=home) current = current_display_size(display, xauthority=xauthority, home=home)
ok = current == (target_width, target_height) ok = current == (target_width, target_height)
if ok: if ok:

View file

@ -23,13 +23,13 @@
- `proxy_for_token(token: str) -> VirtualDesktopEndpoint | None` - `proxy_for_token(token: str) -> VirtualDesktopEndpoint | None`
- `resize_session(token: str, width: int, height: int) -> dict[str, Any]` - `resize_session(token: str, width: int, height: int) -> dict[str, Any]`
- `get_registry() -> VirtualDesktopRegistry` - `get_registry() -> VirtualDesktopRegistry`
- `session_url(token: str, title: str=...) -> str` - `session_url(token: str, title: str=..., encoding: str=..., quality: int=..., speed: int=..., file_transfer: bool=..., printing: bool=...) -> str`
- `collect_status() -> dict[str, Any]` - `collect_status() -> dict[str, Any]`
- `find_xpra_html_root() -> Path | None` - `find_xpra_html_root() -> Path | None`
- `_package_installed(package: str) -> bool` - `_package_installed(package: str) -> bool`
- `normalize_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=...) -> tuple[int, int]` - `normalize_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=...) -> tuple[int, int]`
- `normalize_desktop_display_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=..., min_aspect_ratio: float=...) -> tuple[int, int]` - `normalize_desktop_display_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=..., min_aspect_ratio: float=...) -> tuple[int, int]`
- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=...) -> dict[str, Any]` - `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=..., settle_seconds: float=...) -> dict[str, Any]`
- `_ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None` - `_ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None`
- `_select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]` - `_select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]`
- `_xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]` - `_xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]`
@ -54,6 +54,8 @@
- Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `get_registry.register`, `get_registry.unregister`, `get_registry.proxy_for_token`, `get_registry.resize`, `quote`, `urlencode`, `find_xpra_html_root`, `subprocess.run`, `normalize_size`, `shutil.which`, `_display_env`, `current_display_size`, `_ensure_xrandr_mode`, `_select_xrandr_mode`, `time.sleep`, `strip`, `_xrandr_output_modes`, `result.stdout.splitlines`. - Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `get_registry.register`, `get_registry.unregister`, `get_registry.proxy_for_token`, `get_registry.resize`, `quote`, `urlencode`, `find_xpra_html_root`, `subprocess.run`, `normalize_size`, `shutil.which`, `_display_env`, `current_display_size`, `_ensure_xrandr_mode`, `_select_xrandr_mode`, `time.sleep`, `strip`, `_xrandr_output_modes`, `result.stdout.splitlines`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes. - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
- Session URLs keep Desktop's JPEG, printing, and file-transfer defaults while allowing restricted viewers such as Browser to negotiate encoding and disable unrelated capabilities.
- Display resizing keeps the Desktop settle delay by default; latency-sensitive callers may skip it when they immediately verify the XRandR size.
## Work Guidance ## Work Guidance

17
plugins/_a0_acp/README.md Normal file
View file

@ -0,0 +1,17 @@
# Agent Client Protocol
`_a0_acp` is the bundled Agent Client Protocol bridge. ACP-capable editors
start the local connector with:
```bash
a0 acp --host http://localhost:32081
```
The connector owns editor-hosted files and terminal access. The Agent Zero
runtime owns ACP session metadata, history, modes, and model settings. The
default transport is the connector; the hidden `transport: container` setting
is only a compatibility fallback for an already configured legacy `a0_acp`
plugin inside the selected container.
On startup, Agent Zero removes retired `usr/plugins/a0_acp` installations and
their project or agent overrides. The bundled `_a0_acp` configuration is kept.

View file

@ -0,0 +1,223 @@
"""Authenticated ACP session metadata API for the host-side A0 CLI."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from helpers.api import Request, Response
from plugins._a0_connector.api.v1.base import ProtectedConnectorApiHandler
PLUGIN_NAME = "_a0_acp"
CTX_IS_ACP = "acp_session"
CTX_CWD = "acp_cwd"
CTX_ADDITIONAL_DIRECTORIES = "acp_additional_directories"
CTX_MODE = "acp_mode"
CTX_MODEL_ID = "acp_model_id"
CTX_CONFIG_OPTIONS = "acp_config_options"
CTX_TRANSPORT = "acp_transport"
CTX_WORKDIR = "workdir_path"
_VALID_MODES = {"default", "plan", "act"}
_MAX_PATHS = 32
_MAX_PATH_LENGTH = 4096
def _config() -> dict[str, Any]:
from helpers.plugins import get_plugin_config
return dict(get_plugin_config(PLUGIN_NAME) or {})
def _paths(value: object) -> list[str]:
if not isinstance(value, list):
return []
return [
str(path).strip()
for path in value[:_MAX_PATHS]
if str(path).strip() and len(str(path).strip()) <= _MAX_PATH_LENGTH
]
def _mode(value: object) -> str:
mode = str(value or "default").strip().lower()
return mode if mode in _VALID_MODES else "default"
def _timestamp(value: object) -> str:
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value or "")
def _session_payload(context) -> dict[str, Any]:
return {
"session_id": context.id,
"title": context.name or "Agent Zero ACP",
"cwd": str(context.get_data(CTX_CWD) or ""),
"additional_directories": _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
"updated_at": _timestamp(context.last_message or context.created_at),
"mode": _mode(context.get_data(CTX_MODE)),
"model_id": str(context.get_data(CTX_MODEL_ID) or ""),
}
def _mark_dirty(context_id: str, reason: str) -> None:
try:
from helpers.state_monitor_integration import mark_dirty_for_context
mark_dirty_for_context(context_id, reason=reason)
except Exception:
return
class Session(ProtectedConnectorApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
del request
action = str(input.get("action") or "config").strip().lower()
if action == "config":
return {"ok": True, "config": _config()}
if action == "list":
return self._list_sessions(input)
if action == "configure":
return self._configure(input)
if action == "fork":
return self._fork(input)
if action == "close":
return self._close(input)
if action == "set_mode":
return self._set_value(input, CTX_MODE, _mode(input.get("mode")))
if action == "set_model":
return self._set_value(input, CTX_MODEL_ID, str(input.get("model_id") or "").strip())
if action == "set_config_option":
return self._set_config_option(input)
return Response(status=400, response=f"Unknown ACP action: {action}")
def _context(self, input: dict):
from agent import AgentContext
context_id = str(input.get("context_id") or input.get("session_id") or "").strip()
if not context_id:
return None, Response(status=400, response="context_id is required")
context = AgentContext.get(context_id)
if context is None:
return None, Response(status=404, response="ACP session not found")
return context, None
def _list_sessions(self, input: dict) -> dict:
from agent import AgentContext
from helpers import persist_chat
persist_chat.load_tmp_chats()
cwd = str(input.get("cwd") or "").strip()
sessions = [
_session_payload(context)
for context in AgentContext.all()
if context.get_data(CTX_IS_ACP)
and (not cwd or str(context.get_data(CTX_CWD) or "") == cwd)
]
sessions.sort(key=lambda session: str(session["updated_at"]), reverse=True)
return {"ok": True, "sessions": sessions}
def _configure(self, input: dict) -> dict | Response:
from helpers import persist_chat
config = _config()
if not bool(config.get("enabled", True)):
return Response(status=403, response="ACP is disabled in Agent Zero settings")
context, error = self._context(input)
if error:
return error
cwd = str(input.get("cwd") or "").strip()
if not cwd or len(cwd) > _MAX_PATH_LENGTH:
return Response(status=400, response="A valid ACP workspace path is required")
transport = str(config.get("transport") or "connector").strip().lower()
if transport not in {"connector", "container"}:
transport = "connector"
context.set_data(CTX_IS_ACP, True)
context.set_data(CTX_CWD, cwd)
context.set_data(CTX_ADDITIONAL_DIRECTORIES, _paths(input.get("additional_directories")))
context.set_data(CTX_MODE, _mode(input.get("mode")))
context.set_data(CTX_TRANSPORT, transport)
if transport == "container":
container_workspace = str(config.get("container_workspace_root") or "").strip()
if container_workspace:
context.set_data(CTX_WORKDIR, container_workspace)
if not context.name:
context.name = Path(cwd).name or "Agent Zero ACP"
persist_chat.save_tmp_chat(context)
_mark_dirty(context.id, "a0_acp.configure")
return {"ok": True, "session": _session_payload(context), "config": config}
def _fork(self, input: dict) -> dict | Response:
from agent import AgentContext
from helpers import persist_chat
context, error = self._context(input)
if error:
return error
if not context.get_data(CTX_IS_ACP):
return Response(status=400, response="Only ACP sessions can be forked through ACP")
new_ids = persist_chat.load_json_chats([persist_chat.export_json_chat(context)])
if not new_ids:
return Response(status=500, response="Could not fork ACP session")
fork = AgentContext.get(new_ids[0])
if fork is None:
return Response(status=500, response="Forked ACP session could not be loaded")
fork.name = f"{context.name or 'Agent Zero ACP'} (fork)"
fork.set_data(CTX_IS_ACP, True)
fork.set_data(CTX_CWD, str(input.get("cwd") or context.get_data(CTX_CWD) or ""))
fork.set_data(
CTX_ADDITIONAL_DIRECTORIES,
_paths(input.get("additional_directories"))
or _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
)
fork.set_data(CTX_MODE, _mode(context.get_data(CTX_MODE)))
fork.set_data(CTX_TRANSPORT, context.get_data(CTX_TRANSPORT) or "connector")
persist_chat.save_tmp_chat(fork)
_mark_dirty(fork.id, "a0_acp.fork")
return {"ok": True, "session": _session_payload(fork)}
def _close(self, input: dict) -> dict | Response:
from agent import AgentContext
from helpers import persist_chat
context, error = self._context(input)
if error:
return error
context.kill_process()
AgentContext.remove(context.id)
persist_chat.remove_chat(context.id)
return {"ok": True}
def _set_value(self, input: dict, key: str, value: object) -> dict | Response:
from helpers import persist_chat
context, error = self._context(input)
if error:
return error
context.set_data(key, value)
persist_chat.save_tmp_chat(context)
_mark_dirty(context.id, f"a0_acp.{key}")
return {"ok": True, "session": _session_payload(context)}
def _set_config_option(self, input: dict) -> dict | Response:
from helpers import persist_chat
context, error = self._context(input)
if error:
return error
config_id = str(input.get("config_id") or "").strip()
if not config_id:
return Response(status=400, response="config_id is required")
options = context.get_data(CTX_CONFIG_OPTIONS)
options = dict(options) if isinstance(options, dict) else {}
options[config_id] = input.get("value")
context.set_data(CTX_CONFIG_OPTIONS, options)
persist_chat.save_tmp_chat(context)
_mark_dirty(context.id, "a0_acp.config_option")
return {"ok": True, "config_options": options}

View file

@ -0,0 +1,15 @@
# The normal ACP transport is the A0 CLI running on the editor host.
enabled: true
agent_profile: ""
host_file_access: read_write
host_code_execution: true
session_history: true
# Advanced compatibility transport. These values are intentionally not exposed
# by the standard settings UI because they require a preconfigured legacy ACP
# plugin inside the selected container.
transport: connector
container_id: ""
container_workdir: /a0
container_python: /opt/venv-a0/bin/python
container_workspace_root: ""

View file

@ -0,0 +1,17 @@
from agent import LoopData
from helpers.extension import Extension
_MODE_PROMPTS = {
"plan": "ACP session mode: plan first. Prefer analysis and tradeoffs. Do not modify files unless the user explicitly asks.",
"act": "ACP session mode: act. Complete actionable work end-to-end with focused implementation and validation.",
}
class AcpMode(Extension):
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
if not self.agent or not self.agent.context.get_data("acp_session"):
return
prompt = _MODE_PROMPTS.get(str(self.agent.context.get_data("acp_mode") or ""))
if prompt:
loop_data.extras_temporary["acp_mode"] = prompt

View file

@ -0,0 +1,53 @@
"""Retire the former community ACP plugin after Core ships its replacement."""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from helpers import cache, files
from helpers.extension import Extension
from helpers.print_style import PrintStyle
LEGACY_PLUGIN_NAME = "a0_acp"
class LegacyAcpMigration(Extension):
def execute(self, **kwargs: Any) -> None:
result = migrate_legacy_acp()
if result["removed_roots"]:
PrintStyle.info("Removed retired ACP plugin files:", result["removed_roots"])
def migrate_legacy_acp(base_dir: str | Path | None = None) -> dict[str, list[str]]:
root = Path(base_dir or files.get_abs_path("")).resolve()
removed_roots: list[str] = []
errors: list[str] = []
for plugin_root in _legacy_plugin_roots(root):
try:
if plugin_root.is_dir() and not plugin_root.is_symlink():
shutil.rmtree(plugin_root)
else:
plugin_root.unlink()
removed_roots.append(str(plugin_root))
except OSError as exc:
errors.append(f"Could not remove retired ACP plugin at {plugin_root}: {exc}")
if removed_roots:
cache.clear("*(plugins)*")
cache.clear("*(extensions)*")
cache.clear("*(api)*")
return {"removed_roots": removed_roots, "errors": errors}
def _legacy_plugin_roots(root: Path) -> list[Path]:
candidates = [
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}"),
]
return [candidate for candidate in candidates if candidate.exists() or candidate.is_symlink()]

View file

@ -0,0 +1,9 @@
name: _a0_acp
title: Agent Client Protocol
description: Connect ACP-capable editors through the local A0 CLI connector.
version: "2.0"
settings_sections:
- external
per_project_config: false
per_agent_config: false
always_enabled: true

View file

@ -0,0 +1,29 @@
from pathlib import Path
from plugins._a0_acp.extensions.python.startup_migration._10_migrate_legacy_acp import (
migrate_legacy_acp,
)
def test_migrate_legacy_acp_removes_all_stale_plugin_roots(tmp_path: Path) -> None:
stale_roots = [
tmp_path / "usr" / "plugins" / "a0_acp",
tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "plugins" / "a0_acp",
tmp_path / "usr" / "agents" / "reviewer" / "plugins" / "a0_acp",
]
bundled_config = tmp_path / "usr" / "plugins" / "_a0_acp" / "config.json"
for root in stale_roots:
(root / ".git").mkdir(parents=True)
(root / "plugin.yaml").write_text("name: a0_acp\n", encoding="utf-8")
(root / ".git" / "config").write_text("[core]\n", encoding="utf-8")
bundled_config.parent.mkdir(parents=True)
bundled_config.write_text('{"enabled": true}\n', encoding="utf-8")
result = migrate_legacy_acp(tmp_path)
assert len(result["removed_roots"]) == len(stale_roots)
assert result["errors"] == []
assert all(not root.exists() for root in stale_roots)
assert bundled_config.read_text(encoding="utf-8") == '{"enabled": true}\n'
assert migrate_legacy_acp(tmp_path) == {"removed_roots": [], "errors": []}

View file

@ -0,0 +1,19 @@
from datetime import datetime, timezone
from plugins._a0_acp.api.session import _session_payload
class _Context:
id = "ctx-acp"
name = "ACP"
created_at = datetime(2026, 8, 16, tzinfo=timezone.utc)
last_message = datetime(2026, 8, 16, 12, 34, tzinfo=timezone.utc)
def get_data(self, key: str):
return {"acp_cwd": "/workspace", "acp_mode": "default"}.get(key)
def test_session_payload_serializes_datetime_metadata() -> None:
payload = _session_payload(_Context())
assert payload["updated_at"] == "2026-08-16T12:34:00+00:00"

View file

@ -0,0 +1,81 @@
<html>
<head>
<title>Agent Client Protocol</title>
</head>
<body>
<div x-data>
<template x-if="config">
<div>
<div class="section-title">Agent Client Protocol</div>
<div class="section-description">Connect an ACP-capable editor through the A0 CLI running on the editor computer.</div>
<div class="field">
<div class="field-label">
<div class="field-title">Enable ACP</div>
<div class="field-description">Allows A0 CLI ACP sessions for this Agent Zero instance.</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.enabled" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Default agent profile</div>
<div class="field-description">Optional profile key used for new ACP sessions.</div>
</div>
<div class="field-control"><input type="text" x-model="config.agent_profile" autocomplete="off" /></div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Editor workspace access</div>
<div class="field-description">Controls whether Agent Zero can write through the connected A0 CLI.</div>
</div>
<div class="field-control">
<select x-model="config.host_file_access">
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Editor terminal access</div>
<div class="field-description">Allows code execution on the computer running the A0 CLI.</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.host_code_execution" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Session history</div>
<div class="field-description">Lets ACP editors list and resume their prior Agent Zero sessions.</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.session_history" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field">
<div class="field-label"><div class="field-title">Editor command</div></div>
<div class="field-control"><code>a0 acp --host &lt;agent-zero-url&gt;</code></div>
</div>
</div>
</template>
</div>
</body>
</html>

View file

@ -2,27 +2,40 @@
## Purpose ## Purpose
- Own the built-in Playwright browser tool and WebUI browser viewer. - Own the built-in Patchright browser tool and WebUI browser viewer.
- Bridge browser automation, page inspection helpers, and browser panel UI. - Bridge browser automation, page inspection helpers, and browser panel UI.
## Ownership ## Ownership
- `plugin.yaml` and `default_config.yaml` own metadata and browser settings defaults. - `plugin.yaml` and `default_config.yaml` own metadata and browser settings defaults.
- `tools/browser.py` owns the agent-facing browser tool. - `tools/browser.py` owns the agent-facing browser tool.
- `helpers/` owns Playwright runtime, selectors, URL helpers, extension management, and connector runtime logic. - `helpers/` owns the Patchright runtime, private interactive display, selectors, URL helpers, extension management, and connector runtime logic.
- `api/` owns status, extension, and browser WebSocket handlers. - `api/` owns status, extension, and browser WebSocket handlers.
- `assets/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own browser scripts, prompts, skill guidance, hook contributions, and UI. - `assets/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own browser scripts, prompts, skill guidance, hook contributions, and UI.
## Local Contracts ## Local Contracts
- Keep browser actions safe around external pages, credentials, and user data. - Keep browser actions safe around external pages, credentials, and user data.
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding. - Preserve Patchright 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 the authenticated Xpra HTML5 viewer for its existing Patchright page. Keep live CDP screencast and lightweight snapshots as automatic fallbacks.
- Do not block an available interactive viewer on a redundant Chromium screenshot; capture initial snapshots only for fallback transports.
- Keep headful Chromium in a normal window with its own toolbar clipped above the private display; do not use browser fullscreen, which shows Chromium's exit warning.
- Persist open-tab ownership and URLs through the shared KVP store; automatically restore the current chat when its Browser surface opens in per-chat mode and every saved chat in shared mode, then hide Chromium's redundant crash-restore advisory.
- When no Browser tab manifest exists yet, use Chromium's last session once to migrate open tabs into the owned manifest.
- Throttle interactive resize updates throughout a drag and let the native-sized Chromium viewport follow the private display; do not defer all layout updates until resizing stops.
- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size.
- Present the Xpra shadow window as the raw browser canvas: remove its HTML decoration and shadow pointer while preserving exact viewport geometry.
- Keep one internal Chromium, Xvfb, and Xpra runtime per Agent Zero process with one unguessable gateway token.
- Bind Browser Xpra endpoints to loopback, route them through the authenticated virtual-desktop gateway, and keep file transfer, URL opening, printing, and audio disabled.
- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the `<img>`/data URL path for snapshots and fallback rendering. - 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. - 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 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 WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other context handles only when the Browser settings tab scope is `shared`.
- Share one persistent internal-Browser sign-in profile across chats while enforcing tab ownership through context-bound runtime handles; resetting or removing a chat closes only its tabs and never deletes the shared profile.
- On first shared-profile use after an upgrade, adopt the first requesting chat's legacy Browser profile when one exists.
- Show an accessible in-panel startup state while the on-demand shared Browser runtime is cold-starting; keep that one runtime warm until Browser configuration changes or Agent Zero shuts down.
- 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, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`. - For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket 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 must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
@ -30,8 +43,14 @@
- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces. - 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.
- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. - Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. After the first Browser tool call, use the selected preset for subsequent model turns in that monologue and clear it at monologue end.
- Annotation mode highlights the DOM element under the pointer, keeps saved overlays page-local, and may batch annotated pages only within the active chat context.
- Annotation voice input reuses Whisper STT's configured draft/send delivery mode and shared microphone state.
- Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes. - Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
- Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.
- Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads.
- `hooks.prepare_playwright_cache()` owns reconciliation of the pinned Patchright package and Chromium binary so repository self-updates and fresh images use the same setup path.
- Browser startup must install the shared virtual-desktop route hook itself; do not make Browser depend on the Desktop plugin being enabled.
## Work Guidance ## Work Guidance
@ -44,7 +63,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. - For viewer render-path changes, verify direct iframe interaction reaches the same page controlled by Patchright, separate contexts use separate displays, and an unavailable Xpra runtime falls back to CDP screencast/snapshot rendering.
- 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

View file

@ -1,5 +1,6 @@
from helpers.api import ApiHandler, Request from helpers.api import ApiHandler, Request
from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
from plugins._browser.helpers.interactive_view import collect_status as collect_interactive_status
from plugins._browser.helpers.playwright import ( from plugins._browser.helpers.playwright import (
get_playwright_binary, get_playwright_binary,
get_playwright_cache_dir, get_playwright_cache_dir,
@ -37,5 +38,6 @@ class Status(ApiHandler):
"requires_full_browser": launch_config["requires_full_browser"], "requires_full_browser": launch_config["requires_full_browser"],
}, },
"host_browser": host_browser, "host_browser": host_browser,
"interactive_view": collect_interactive_status(),
"contexts": known_context_ids(), "contexts": known_context_ids(),
} }

View file

@ -14,7 +14,11 @@ from plugins._browser.helpers.config import (
TAB_SCOPE_KEY, TAB_SCOPE_KEY,
get_browser_config, get_browser_config,
) )
from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions from plugins._browser.helpers.runtime import (
get_runtime,
has_restorable_browser_tabs,
list_runtime_sessions,
)
FRAME_READ_TIMEOUT_SECONDS = 0.5 FRAME_READ_TIMEOUT_SECONDS = 0.5
@ -25,7 +29,12 @@ SCREENCAST_STREAM_QUALITY = 80
SCREENSHOT_QUALITY = 92 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_TRANSPORT_INTERACTIVE = "interactive"
VIEWER_TRANSPORTS = {
VIEWER_TRANSPORT_INTERACTIVE,
VIEWER_TRANSPORT_SCREENCAST,
VIEWER_TRANSPORT_SNAPSHOT,
}
class WsBrowser(WsHandler): class WsBrowser(WsHandler):
@ -75,6 +84,8 @@ class WsBrowser(WsHandler):
create_browser = self._bool(data.get("create_browser", data.get("createBrowser"))) create_browser = self._bool(data.get("create_browser", data.get("createBrowser")))
runtime = await get_runtime(context_id, create=create_browser) runtime = await get_runtime(context_id, create=create_browser)
if not runtime and not create_browser and has_restorable_browser_tabs(context_id):
runtime = await get_runtime(context_id)
listing = {"browsers": [], "last_interacted_browser_id": None} listing = {"browsers": [], "last_interacted_browser_id": None}
browsers: list[dict[str, Any]] = [] browsers: list[dict[str, Any]] = []
if runtime: if runtime:
@ -87,8 +98,19 @@ class WsBrowser(WsHandler):
if opened.get("id"): if opened.get("id"):
listing["last_interacted_browser_id"] = opened.get("id") listing["last_interacted_browser_id"] = opened.get("id")
active_id = self._active_browser_id(listing, data.get("browser_id")) active_id = self._active_browser_id(listing, data.get("browser_id"))
requested_transport = self._viewer_transport(data)
viewer_transport, interactive_view = await self._effective_viewer(
runtime,
active_id,
data,
)
initial_viewport = self._viewport_from_data(data) initial_viewport = self._viewport_from_data(data)
if runtime and active_id and initial_viewport: if (
runtime
and active_id
and initial_viewport
and viewer_transport != VIEWER_TRANSPORT_INTERACTIVE
):
await runtime.call( await runtime.call(
"set_viewport", "set_viewport",
active_id, active_id,
@ -103,7 +125,6 @@ class WsBrowser(WsHandler):
if existing: if existing:
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)
binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames"))) binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames")))
slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames))) slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames)))
capture_scale = self._capture_scale_from_data(data) capture_scale = self._capture_scale_from_data(data)
@ -120,9 +141,16 @@ class WsBrowser(WsHandler):
capture_scale=capture_scale, 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,
viewer_transport=viewer_transport,
)
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) if viewer_transport != VIEWER_TRANSPORT_INTERACTIVE:
snapshot = await self._snapshot_for_browser(runtime, active_id)
browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers) browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
@ -136,6 +164,14 @@ class WsBrowser(WsHandler):
"tab_scope": tab_scope, "tab_scope": tab_scope,
"viewer_id": viewer_id, "viewer_id": viewer_id,
"viewer_transport": viewer_transport, "viewer_transport": viewer_transport,
"interactive_view": interactive_view,
"viewer_fallback_reason": (
str(interactive_view.get("error") or "")
if requested_transport == VIEWER_TRANSPORT_INTERACTIVE
and interactive_view
and not interactive_view.get("available")
else ""
),
"binary_frames": binary_frames, "binary_frames": binary_frames,
"slim_frames": slim_frames, "slim_frames": slim_frames,
} }
@ -253,7 +289,20 @@ 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) active_id = self._active_browser_id(
listing,
self._result_browser_id(result) or browser_id,
)
viewer_transport, interactive_view = await self._effective_viewer(
runtime,
active_id,
data,
)
snapshot = (
None
if viewer_transport == VIEWER_TRANSPORT_INTERACTIVE
else await self._snapshot_for_result(runtime, result)
)
browsers, all_browsers, tab_scope = await self._tabs_for_scope( browsers, all_browsers, tab_scope = await self._tabs_for_scope(
context_id, context_id,
listing.get("browsers") or [], listing.get("browsers") or [],
@ -273,7 +322,8 @@ class WsBrowser(WsHandler):
"all_browsers": all_browsers, "all_browsers": all_browsers,
"tab_scope": tab_scope, "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": viewer_transport,
"interactive_view": interactive_view,
}, },
correlation_id=data.get("correlationId"), correlation_id=data.get("correlationId"),
) )
@ -288,7 +338,8 @@ class WsBrowser(WsHandler):
"command": command, "command": command,
"browser_id": browser_id, "browser_id": browser_id,
"viewer_id": viewer_id, "viewer_id": viewer_id,
"viewer_transport": self._viewer_transport(data), "viewer_transport": viewer_transport,
"interactive_view": interactive_view,
} }
async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult: async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@ -326,12 +377,15 @@ class WsBrowser(WsHandler):
text=str(data.get("text") or ""), text=str(data.get("text") or ""),
) )
elif input_type == "viewport": elif input_type == "viewport":
viewer_transport = self._viewer_transport(data)
result = await runtime.call( result = await runtime.call(
"set_viewport", "set_viewport",
browser_id, browser_id,
int(data.get("width") or 0), int(data.get("width") or 0),
int(data.get("height") or 0), int(data.get("height") or 0),
restart_screencast=bool(data.get("restart_stream")), restart_screencast=bool(data.get("restart_stream")),
resize_interactive=viewer_transport == VIEWER_TRANSPORT_INTERACTIVE,
include_state=viewer_transport != VIEWER_TRANSPORT_INTERACTIVE,
) )
elif input_type == "wheel": elif input_type == "wheel":
result = await runtime.call( result = await runtime.call(
@ -582,6 +636,8 @@ 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 = "",
*,
viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
) -> None: ) -> None:
last_signature = None last_signature = None
while True: while True:
@ -594,7 +650,7 @@ class WsBrowser(WsHandler):
sid, sid,
context_id, context_id,
viewer_id=viewer_id, viewer_id=viewer_id,
frame_source=VIEWER_TRANSPORT_SNAPSHOT, frame_source=viewer_transport,
) )
last_signature = signature last_signature = signature
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS) await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
@ -625,7 +681,7 @@ class WsBrowser(WsHandler):
browsers=browsers, browsers=browsers,
viewer_id=viewer_id, viewer_id=viewer_id,
state=state, state=state,
viewer_transport=VIEWER_TRANSPORT_SNAPSHOT, viewer_transport=viewer_transport,
) )
last_signature = signature last_signature = signature
await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS) await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
@ -653,6 +709,36 @@ class WsBrowser(WsHandler):
active_id = browsers[0].get("id") active_id = browsers[0].get("id")
return active_id return active_id
async def _effective_viewer(
self,
runtime: Any,
browser_id: int | str | None,
data: dict[str, Any],
) -> tuple[str, dict[str, Any] | None]:
requested = self._viewer_transport(data)
if requested != VIEWER_TRANSPORT_INTERACTIVE or not runtime or not browser_id:
return requested, None
viewport = self._viewport_from_data(data) or {}
try:
viewer = await runtime.call(
"interactive_viewer",
browser_id,
width=int(viewport.get("width") or 0),
height=int(viewport.get("height") or 0),
)
except Exception as exc:
viewer = {"available": False, "error": str(exc)}
if viewer.get("available"):
return VIEWER_TRANSPORT_INTERACTIVE, viewer
return VIEWER_TRANSPORT_SCREENCAST, viewer
@staticmethod
def _result_browser_id(result: Any) -> int | str | None:
if not isinstance(result, dict):
return None
state = result.get("state") if isinstance(result.get("state"), dict) else result
return state.get("id") if isinstance(state, dict) else None
@staticmethod @staticmethod
def _state_for_browser( def _state_for_browser(
browsers: list[dict[str, Any]], browsers: list[dict[str, Any]],

View file

@ -8,7 +8,8 @@ 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: # Browser tab visibility in the WebUI. Both modes use the same internal
# Chromium runtime and sign-in profile:
# - per_context: each chat shows only its own Browser tabs. # - per_context: each chat shows only its own Browser tabs.
# - shared: show Browser tabs from all active chats. # - shared: show Browser tabs from all active chats.
browser_tab_scope: "per_context" browser_tab_scope: "per_context"

View file

@ -0,0 +1,14 @@
from helpers.extension import Extension
from plugins._browser.helpers.config import (
browser_model_is_active,
resolve_browser_model,
)
class BrowserModelProvider(Extension):
def execute(self, data: dict = {}, **kwargs):
if self.agent and browser_model_is_active(self.agent):
data["result"] = resolve_browser_model(
self.agent,
fallback=data.get("result"),
)

View file

@ -0,0 +1,8 @@
from helpers.extension import Extension
from plugins._browser.helpers.config import clear_browser_model
class BrowserModelCleanup(Extension):
def execute(self, **kwargs):
if self.agent:
clear_browser_model(self.agent)

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import threading import threading
from typing import Any from typing import Any
from helpers import virtual_desktop_routes
from helpers.extension import Extension from helpers.extension import Extension
from helpers.print_style import PrintStyle from helpers.print_style import PrintStyle
from plugins._browser import hooks from plugins._browser import hooks
@ -13,6 +14,7 @@ _startup_migration_thread: threading.Thread | None = None
class BrowserPlaywrightCacheMigration(Extension): class BrowserPlaywrightCacheMigration(Extension):
def execute(self, **kwargs): def execute(self, **kwargs):
virtual_desktop_routes.install_route_hooks()
_start_background_cache_migration() _start_background_cache_migration()
@ -33,7 +35,7 @@ def _start_background_cache_migration() -> threading.Thread:
def _migrate_cache_safely() -> None: def _migrate_cache_safely() -> None:
try: try:
_log_cache_migration_result(hooks.cleanup_playwright_cache()) _log_cache_migration_result(hooks.prepare_playwright_cache())
except Exception as exc: except Exception as exc:
PrintStyle.warning("Browser Playwright cache migration failed:", exc) PrintStyle.warning("Browser Playwright cache migration failed:", exc)

View file

@ -9,6 +9,7 @@ if TYPE_CHECKING:
PLUGIN_NAME = "_browser" PLUGIN_NAME = "_browser"
MODEL_PRESET_KEY = "model_preset" MODEL_PRESET_KEY = "model_preset"
BROWSER_MODEL_ACTIVE_KEY = "_browser_model_active"
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" TAB_SCOPE_KEY = "browser_tab_scope"
@ -30,11 +31,6 @@ 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
DEFAULT_HOST_BROWSER_PRIVACY_POLICY = "allow" DEFAULT_HOST_BROWSER_PRIVACY_POLICY = "allow"
BASE_BROWSER_ARGS = [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
]
def _normalize_extension_paths(value: Any) -> list[str]: def _normalize_extension_paths(value: Any) -> list[str]:
@ -324,10 +320,34 @@ def resolve_browser_model_selection(
} }
def resolve_browser_model(agent: "Agent", settings: dict[str, Any] | None = None): def activate_browser_model(agent: "Agent") -> dict[str, Any]:
selection = resolve_browser_model_selection(agent=agent)
agent.set_data(
BROWSER_MODEL_ACTIVE_KEY,
selection["selected_preset_name"]
if selection["source_kind"] == "preset"
else "",
)
return selection
def clear_browser_model(agent: "Agent") -> None:
agent.set_data(BROWSER_MODEL_ACTIVE_KEY, "")
def browser_model_is_active(agent: "Agent") -> bool:
return bool(agent.get_data(BROWSER_MODEL_ACTIVE_KEY))
def resolve_browser_model(
agent: "Agent",
settings: dict[str, Any] | None = None,
fallback: Any = None,
):
selection = resolve_browser_model_selection(agent=agent, settings=settings) selection = resolve_browser_model_selection(agent=agent, settings=settings)
if selection["source_kind"] == "main": if selection["source_kind"] == "main":
return agent.get_chat_model() clear_browser_model(agent)
return fallback if fallback is not None else agent.get_chat_model()
import models import models
from plugins._model_config.helpers import model_config from plugins._model_config.helpers import model_config
@ -388,7 +408,7 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An
def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]: def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]:
config = normalize_browser_config(settings) config = normalize_browser_config(settings)
extensions = describe_browser_extensions(config) extensions = describe_browser_extensions(config)
args = list(BASE_BROWSER_ARGS) args = ["--hide-crash-restore-bubble"]
channel: str | None = None channel: str | None = None
browser_mode = "chromium" browser_mode = "chromium"
proxy = None proxy = None

View file

@ -0,0 +1,293 @@
from __future__ import annotations
import os
import select
import shutil
import socket
import subprocess
import threading
import time
import uuid
from pathlib import Path
from typing import Any
from helpers import files, virtual_desktop
from helpers.print_style import PrintStyle
DEFAULT_WIDTH = 1024
DEFAULT_HEIGHT = 768
START_TIMEOUT_SECONDS = 15.0
def collect_status() -> dict[str, Any]:
binaries = {
name: shutil.which(name) or ""
for name in ("Xvfb", "xpra", "xrandr")
}
html_root = virtual_desktop.find_xpra_html_root()
missing = [name for name, path in binaries.items() if not path]
if not html_root:
missing.append("xpra-html5")
return {
"available": not missing,
"missing": missing,
"binaries": binaries,
"xpra_html_root": str(html_root or ""),
}
class BrowserInteractiveView:
"""Own one private X display and its optional Xpra viewer."""
def __init__(self, context_id: str) -> None:
self.context_id = str(context_id)
self.token = f"browser-{uuid.uuid4().hex}"
self.state_dir = Path(files.get_abs_path("tmp", "browser", "displays", self.token))
self.display: int | None = None
self.port = 0
self.width = DEFAULT_WIDTH
self.height = DEFAULT_HEIGHT
self._xvfb: subprocess.Popen[Any] | None = None
self._xpra: subprocess.Popen[Any] | None = None
self._lock = threading.RLock()
@property
def display_name(self) -> str:
return f":{self.display}" if self.display is not None else ""
def ensure_display(self) -> str:
with self._lock:
if self._running(self._xvfb) and self.display is not None:
return self.display_name
self._stop_locked()
xvfb = shutil.which("Xvfb")
if not xvfb:
return ""
self.state_dir.mkdir(parents=True, exist_ok=True)
self.state_dir.chmod(0o700)
read_fd, write_fd = os.pipe()
try:
process = subprocess.Popen(
[
xvfb,
"-displayfd",
str(write_fd),
"-screen",
"0",
f"{virtual_desktop.MAX_WIDTH}x{virtual_desktop.MAX_HEIGHT}x24",
"+extension",
"GLX",
"+extension",
"RANDR",
"+extension",
"RENDER",
"+extension",
"Composite",
"-nolisten",
"tcp",
"-noreset",
"-ac",
],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
pass_fds=(write_fd,),
)
except OSError:
os.close(read_fd)
return ""
finally:
os.close(write_fd)
try:
ready, _, _ = select.select([read_fd], [], [], 5)
display_number = os.read(read_fd, 32).decode().strip() if ready else ""
finally:
os.close(read_fd)
if not display_number.isdigit() or process.poll() is not None:
self._terminate(process)
return ""
self._xvfb = process
self.display = int(display_number)
self.resize(self.width, self.height)
return self.display_name
def ensure_viewer(self, width: int = 0, height: int = 0) -> dict[str, Any]:
with self._lock:
display_name = self.ensure_display()
if not display_name:
return self._unavailable("Xvfb is unavailable.")
status = collect_status()
if not status["available"]:
return self._unavailable(
f"Interactive Browser runtime needs: {', '.join(status['missing'])}."
)
self.resize(width or self.width, height or self.height)
if not self._running(self._xpra):
try:
self._start_xpra(str(status["binaries"]["xpra"]))
except Exception as exc:
PrintStyle.warning(f"Interactive Browser viewer failed to start: {exc}")
self._terminate(self._xpra)
self._xpra = None
self.port = 0
virtual_desktop.unregister_session(self.token)
return self._unavailable(str(exc))
virtual_desktop.register_session(
token=self.token,
host="127.0.0.1",
port=self.port,
owner="browser",
title="Browser",
resize=self.resize,
)
return {
"available": True,
"token": self.token,
"url": virtual_desktop.session_url(
self.token,
title="Browser",
encoding="",
quality=90,
speed=90,
file_transfer=False,
printing=False,
),
"width": self.width,
"height": self.height,
}
def resize(self, width: int, height: int) -> dict[str, Any]:
with self._lock:
target_width, target_height = virtual_desktop.normalize_size(width, height)
self.width = target_width
self.height = target_height
if self.display is None or not self._running(self._xvfb):
return {
"ok": False,
"error": "Browser display is unavailable.",
"width": target_width,
"height": target_height,
}
result = virtual_desktop.resize_display(
display=self.display,
width=target_width,
height=target_height,
settle_seconds=0,
)
return result
def close(self) -> None:
with self._lock:
self._stop_locked()
shutil.rmtree(self.state_dir, ignore_errors=True)
def _start_xpra(self, xpra: str) -> None:
self.port = self._free_port()
runtime_dir = self.state_dir / "runtime"
socket_dir = self.state_dir / "sockets"
runtime_dir.mkdir(parents=True, exist_ok=True)
socket_dir.mkdir(parents=True, exist_ok=True)
runtime_dir.chmod(0o700)
env = {
**os.environ,
"DISPLAY": self.display_name,
"XDG_RUNTIME_DIR": str(runtime_dir),
}
self._xpra = subprocess.Popen(
[
xpra,
"shadow",
self.display_name,
"--daemon=no",
"--mdns=no",
"--html=on",
"--tray=no",
"--system-tray=no",
"--notifications=no",
"--clipboard=yes",
"--clipboard-direction=both",
"--file-transfer=no",
"--open-files=no",
"--open-url=no",
"--printing=no",
"--audio=no",
"--speaker=off",
"--microphone=off",
"--sharing=yes",
"--resize-display=yes",
"--encoding=auto",
"--quality=90",
"--speed=90",
f"--bind-tcp=127.0.0.1:{self.port}",
f"--socket-dir={socket_dir}",
f"--log-dir={self.state_dir}",
"--log-file=xpra.log",
],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
self._wait_for_port(self._xpra, self.port)
def _stop_locked(self) -> None:
virtual_desktop.unregister_session(self.token)
self._terminate(self._xpra)
self._terminate(self._xvfb)
self._xpra = None
self._xvfb = None
self.port = 0
self.display = None
def _unavailable(self, error: str) -> dict[str, Any]:
return {
"available": False,
"error": str(error or "Interactive Browser viewer is unavailable."),
}
@staticmethod
def _running(process: subprocess.Popen[Any] | None) -> bool:
return bool(process and process.poll() is None)
@staticmethod
def _terminate(process: subprocess.Popen[Any] | None) -> None:
if not process or process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
@staticmethod
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return int(probe.getsockname()[1])
@staticmethod
def _wait_for_port(
process: subprocess.Popen[Any],
port: int,
timeout: float = START_TIMEOUT_SECONDS,
) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError("Xpra exited before its Browser endpoint was ready.")
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
return
except OSError:
time.sleep(0.1)
raise TimeoutError("Timed out waiting for the interactive Browser endpoint.")

View file

@ -1,12 +1,16 @@
import json
import os import os
import re
import subprocess import subprocess
import sys
from importlib import resources
from pathlib import Path from pathlib import Path
from helpers import files from helpers import files
FULL_CHROMIUM_PATTERNS = ( FULL_CHROMIUM_PATTERNS = (
"chromium-*/chrome-linux/chrome", "chromium-*/chrome-linux*/chrome",
"chromium-*/chrome-win/chrome.exe", "chromium-*/chrome-win*/chrome.exe",
) )
PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR" PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR"
PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright") PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")
@ -52,16 +56,43 @@ def configure_playwright_env() -> str:
return cache_dir return cache_dir
def find_playwright_binary(cache_dir: Path) -> Path | None: def find_playwright_binary(cache_dir: Path, revision: str = "") -> Path | None:
for pattern in FULL_CHROMIUM_PATTERNS: prefix = f"chromium-{revision}" if revision.isdigit() else "chromium-*"
binary = next(cache_dir.glob(pattern), None) binaries = [
if binary and binary.exists(): binary
return binary for pattern in FULL_CHROMIUM_PATTERNS
return None for binary in cache_dir.glob(pattern.replace("chromium-*", prefix))
if binary.exists()
]
return max(binaries, key=_chromium_revision) if binaries else None
def _chromium_revision(binary: Path) -> int:
match = re.search(r"chromium-(\d+)", binary.as_posix())
return int(match.group(1)) if match else -1
def get_playwright_binary() -> Path | None: def get_playwright_binary() -> Path | None:
return find_playwright_binary(_primary_cache_dir()) cache_dir = _primary_cache_dir()
binary = find_playwright_binary(_primary_cache_dir())
revision = get_playwright_chromium_revision()
if revision and (not binary or _chromium_revision(binary) != int(revision)):
return find_playwright_binary(cache_dir, revision=revision)
return binary
def get_playwright_chromium_revision() -> str:
try:
manifest = resources.files("patchright").joinpath("driver/package/browsers.json")
browsers = json.loads(manifest.read_text(encoding="utf-8"))["browsers"]
revision = next(
str(browser.get("revision", ""))
for browser in browsers
if browser.get("name") == "chromium"
)
except (ImportError, FileNotFoundError, KeyError, StopIteration, TypeError, ValueError):
return ""
return revision if revision.isdigit() else ""
def ensure_playwright_binary() -> Path: def ensure_playwright_binary() -> Path:
@ -72,13 +103,12 @@ def ensure_playwright_binary() -> Path:
cache_dir = configure_playwright_env() cache_dir = configure_playwright_env()
env = os.environ.copy() env = os.environ.copy()
env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
install_command = ["playwright", "install", "chromium"]
subprocess.check_call( subprocess.check_call(
install_command, [sys.executable, "-m", "patchright", "install", "chromium", "--no-shell"],
env=env, env=env,
) )
binary = get_playwright_binary() binary = get_playwright_binary()
if not binary: if not binary:
raise RuntimeError("Playwright Chromium binary not found after installation") raise RuntimeError("Patchright Chromium binary not found after installation")
return binary return binary

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,12 @@
from __future__ import annotations from __future__ import annotations
import importlib
import importlib.metadata
import importlib.util
import shutil import shutil
import subprocess
import sys
import threading
from pathlib import Path from pathlib import Path
from helpers import files, plugins, yaml as yaml_helper from helpers import files, plugins, yaml as yaml_helper
@ -10,6 +16,7 @@ from plugins._browser.helpers.config import (
normalize_browser_config, normalize_browser_config,
) )
from plugins._browser.helpers.playwright import ( from plugins._browser.helpers.playwright import (
ensure_playwright_binary,
find_playwright_binary, find_playwright_binary,
get_playwright_cache_dir, get_playwright_cache_dir,
get_retired_playwright_cache_dirs, get_retired_playwright_cache_dirs,
@ -17,6 +24,11 @@ from plugins._browser.helpers.playwright import (
from plugins._browser.helpers.runtime import close_all_runtimes_sync from plugins._browser.helpers.runtime import close_all_runtimes_sync
_SETUP_LOCK = threading.Lock()
_PLUGIN_DIR = Path(__file__).resolve().parent
_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt"
def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict: def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict:
entries = plugins.find_plugin_assets( entries = plugins.find_plugin_assets(
plugins.CONFIG_FILE_NAME, plugins.CONFIG_FILE_NAME,
@ -95,6 +107,59 @@ def cleanup_playwright_cache() -> dict:
return result return result
def prepare_playwright_cache() -> dict:
with _SETUP_LOCK:
_ensure_patchright_dependency()
result = cleanup_playwright_cache()
if result["errors"]:
return result
result["binary"] = str(ensure_playwright_binary())
return result
def install() -> dict:
return prepare_playwright_cache()
def _ensure_patchright_dependency() -> None:
requirement = _patchright_requirement()
if _patchright_is_current(requirement):
return
uv = shutil.which("uv")
if not uv:
raise RuntimeError("Browser plugin requires 'uv' to install Patchright automatically")
subprocess.check_call(
[uv, "pip", "install", "--python", sys.executable, requirement],
cwd=str(_PLUGIN_DIR),
)
importlib.invalidate_caches()
if not _patchright_is_current(requirement):
raise RuntimeError(
f"Browser dependency {requirement!r} is unavailable after installation"
)
def _patchright_requirement() -> str:
if _ROOT_REQUIREMENTS_FILE.is_file():
for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
requirement = line.strip()
if requirement.startswith("patchright=="):
return requirement
raise RuntimeError(f"Browser Patchright requirement not found in {_ROOT_REQUIREMENTS_FILE}")
def _patchright_is_current(requirement: str) -> bool:
expected_version = requirement.partition("==")[2]
if not expected_version or importlib.util.find_spec("patchright") is None:
return False
try:
return importlib.metadata.version("patchright") == expected_version
except importlib.metadata.PackageNotFoundError:
return False
def _best_playwright_cache(candidates: list[Path]) -> Path | None: def _best_playwright_cache(candidates: list[Path]) -> Path | None:
valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)] valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)]
if not valid: if not valid:

View file

@ -10,6 +10,7 @@ from typing import Any
from helpers import files from helpers import files
from helpers.print_style import PrintStyle from helpers.print_style import PrintStyle
from helpers.tool import Response, Tool from helpers.tool import Response, Tool
from plugins._browser.helpers.config import activate_browser_model
from plugins._browser.helpers.selector import get_tool_runtime from plugins._browser.helpers.selector import get_tool_runtime
@ -74,6 +75,10 @@ class Browser(Tool):
action = "clipboard" action = "clipboard"
else: else:
action = str(action or self.method or "state").strip().lower().replace("-", "_") action = str(action or self.method or "state").strip().lower().replace("-", "_")
try:
activate_browser_model(self.agent)
except Exception as exc:
PrintStyle.warning(f"Browser model preset could not be activated: {exc}")
try: try:
runtime = await get_runtime(self.agent.context.id, agent=self.agent) runtime = await get_runtime(self.agent.context.id, agent=self.agent)
except Exception as exc: except Exception as exc:

View file

@ -20,11 +20,15 @@
<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()"
:aria-busy="$store.browserPage.isBrowserLoading(browser).toString()"
:title="$store.browserPage.browserTabTooltip(browser)" :title="$store.browserPage.browserTabTooltip(browser)"
@click="$store.browserPage.selectBrowser(browser.id, browser.context_id)"> @click="$store.browserPage.selectBrowser(browser.id, browser.context_id)">
<x-icon class="browser-tab-icon" aria-hidden="true" name="language"></x-icon> <x-icon class="browser-tab-icon" aria-hidden="true" name="language"></x-icon>
<span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span> <span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span>
</button> </button>
<x-icon class="browser-tab-loading" aria-hidden="true"
:class="{ 'is-visible': $store.browserPage.isBrowserLoading(browser), 'spinning': $store.browserPage.isBrowserLoading(browser) }"
name="progress_activity"></x-icon>
<button type="button" class="browser-tab-close" <button type="button" class="browser-tab-close"
:title="'Close ' + $store.browserPage.browserTabLabel(browser)" :title="'Close ' + $store.browserPage.browserTabLabel(browser)"
:aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)" :aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)"
@ -185,15 +189,22 @@
</form> </form>
</div> </div>
<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" <template x-if="$store.browserPage.isInteractiveSurface($el.parentElement)">
x-show="$store.browserPage.frameCanvasReady" <iframe class="browser-interactive-frame"
x-init="$store.browserPage.attachFrameCanvas($el)" :src="$store.browserPage.interactiveViewUrl"
@click="$store.browserPage.sendMouse('click', $event)" aria-label="Browser viewport"
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"></canvas> allow="clipboard-read; clipboard-write"
<template x-if="$store.browserPage.frameSrc"> @load="$store.browserPage.onInteractiveViewLoad()"></iframe>
</template>
<canvas class="browser-frame browser-frame-canvas"
x-show="!$store.browserPage.usesInteractiveTransport() && $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.usesInteractiveTransport() && $store.browserPage.frameSrc">
<img class="browser-frame browser-frame-image" :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" />
@ -204,7 +215,15 @@
@pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)" @pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
@pointermove.stop.prevent="$store.browserPage.moveAnnotationSelection($event)" @pointermove.stop.prevent="$store.browserPage.moveAnnotationSelection($event)"
@pointerup.stop.prevent="$store.browserPage.finishAnnotationSelection($event)" @pointerup.stop.prevent="$store.browserPage.finishAnnotationSelection($event)"
@pointercancel.stop.prevent="$store.browserPage.cancelAnnotationSelection($event)"> @pointercancel.stop.prevent="$store.browserPage.cancelAnnotationSelection($event)"
@pointerleave="$store.browserPage.clearAnnotationHover()">
<template x-if="$store.browserPage.annotationHover && !$store.browserPage.annotationDraft && !$store.browserPage.annotationDragRect">
<div class="browser-annotation-box is-hover"
:style="$store.browserPage.annotationBoxStyle($store.browserPage.annotationHover.rect)">
<span class="browser-annotation-hover-label"
x-text="$store.browserPage.annotationHoverLabel()"></span>
</div>
</template>
<template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id"> <template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
<div class="browser-annotation-box is-saved" <div class="browser-annotation-box is-saved"
:style="$store.browserPage.annotationBoxStyle(annotation.rect)"> :style="$store.browserPage.annotationBoxStyle(annotation.rect)">
@ -223,22 +242,39 @@
:style="$store.browserPage.annotationPopoverStyle()" :style="$store.browserPage.annotationPopoverStyle()"
@click.stop @pointerdown.stop @keydown.stop> @click.stop @pointerdown.stop @keydown.stop>
<div class="browser-annotation-popover-title"> <div class="browser-annotation-popover-title">
<span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span> <span class="browser-annotation-popover-heading">
<span x-text="$store.browserPage.annotationDraftTitle()"></span> <span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span>
<span x-text="$store.browserPage.annotationDraftTitle()"></span>
</span>
<button type="button" class="browser-annotation-popover-close"
title="Cancel annotation" aria-label="Cancel annotation"
@click="$store.browserPage.cancelAnnotationDraft()">
<x-icon name="close"></x-icon>
</button>
</div> </div>
<textarea x-model="$store.browserPage.annotationDraftText" placeholder="Comment" <textarea x-model="$store.browserPage.annotationDraftText" placeholder="Comment"
maxlength="1200"></textarea> maxlength="1200" x-init="$nextTick(() => $el.focus())"></textarea>
<div class="browser-annotation-actions"> <div class="browser-annotation-actions">
<button type="button" class="btn btn-field" <button type="button" class="browser-annotation-mic mic-inactive"
@click="$store.browserPage.cancelAnnotationDraft()">Cancel</button> data-whisper-microphone title="Record annotation comment"
<button type="button" class="btn btn-ok" aria-label="Record annotation comment"
x-init="$nextTick(() => $store.browserPage.syncAnnotationMicrophoneUI())"
@click="$store.browserPage.startAnnotationVoice(true)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 18" fill="currentColor" aria-hidden="true">
<path d="m8,12c1.66,0,3-1.34,3-3V3c0-1.66-1.34-3-3-3s-3,1.34-3,3v6c0,1.66,1.34,3,3,3Zm-1,1.9c-2.7-.4-4.8-2.6-5-5.4H0c.2,3.8,3.1,6.9,7,7.5v2h2v-2c3.9-.6,6.8-3.7,7-7.5h-2c-.2,2.8-2.3,5-5,5.4h-2Z" />
</svg>
</button>
<button type="button" class="browser-annotation-send"
title="Add annotation" aria-label="Add annotation"
:disabled="!String($store.browserPage.annotationDraftText || '').trim()" :disabled="!String($store.browserPage.annotationDraftText || '').trim()"
@click="$store.browserPage.addAnnotationComment()">Add</button> @click="$store.browserPage.addAnnotationComment()">
<x-icon name="arrow_forward"></x-icon>
</button>
</div> </div>
</div> </div>
</template> </template>
<div class="browser-annotation-tray" <div class="browser-annotation-tray"
x-show="$store.browserPage.visibleAnnotations().length" x-show="$store.browserPage.pendingAnnotations().length"
:class="{ 'is-floating': $store.browserPage.annotationTrayPosition, 'is-dragging': $store.browserPage.annotationTrayDragging }" :class="{ 'is-floating': $store.browserPage.annotationTrayPosition, 'is-dragging': $store.browserPage.annotationTrayDragging }"
:style="$store.browserPage.annotationTrayStyle()" :style="$store.browserPage.annotationTrayStyle()"
x-transition style="display: none;" x-transition style="display: none;"
@ -248,15 +284,15 @@
@pointercancel.window="$store.browserPage.finishAnnotationTrayDrag($event)"> @pointercancel.window="$store.browserPage.finishAnnotationTrayDrag($event)">
<div class="browser-annotation-tray-header" <div class="browser-annotation-tray-header"
@pointerdown.stop="$store.browserPage.startAnnotationTrayDrag($event)"> @pointerdown.stop="$store.browserPage.startAnnotationTrayDrag($event)">
<span>Annotations</span> <span x-text="$store.browserPage.annotationBatchLabel()"></span>
<button type="button" class="browser-annotation-clear" title="Clear annotations" <button type="button" class="browser-annotation-clear" title="Clear annotations"
aria-label="Clear annotations" @click="$store.browserPage.clearVisibleAnnotations()"> aria-label="Clear annotations" @click="$store.browserPage.clearPendingAnnotations()">
<x-icon name="delete"></x-icon> <x-icon name="delete"></x-icon>
</button> </button>
</div> </div>
<div class="browser-annotation-chips"> <div class="browser-annotation-chips">
<template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id"> <template x-for="annotation in $store.browserPage.pendingAnnotations()" :key="annotation.id">
<div class="browser-annotation-chip"> <div class="browser-annotation-chip" :title="annotation.title + ' — ' + annotation.url">
<span class="browser-annotation-number" x-text="annotation.index"></span> <span class="browser-annotation-number" x-text="annotation.index"></span>
<span class="browser-annotation-chip-text" x-text="annotation.comment"></span> <span class="browser-annotation-chip-text" x-text="annotation.comment"></span>
<button type="button" title="Remove annotation" aria-label="Remove annotation" <button type="button" title="Remove annotation" aria-label="Remove annotation"
@ -267,10 +303,20 @@
</template> </template>
</div> </div>
<div class="browser-annotation-tray-actions"> <div class="browser-annotation-tray-actions">
<button type="button" class="btn btn-field" <button type="button" class="browser-annotation-mic mic-inactive"
@click="$store.browserPage.draftAnnotationsToChat()">Draft to chat</button> data-whisper-microphone title="Record annotation instruction"
<button type="button" class="btn btn-ok" aria-label="Record annotation instruction"
@click="$store.browserPage.sendAnnotationsToChat()">Send now</button> x-init="$nextTick(() => $store.browserPage.syncAnnotationMicrophoneUI())"
@click="$store.browserPage.startAnnotationVoice()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 18" fill="currentColor" aria-hidden="true">
<path d="m8,12c1.66,0,3-1.34,3-3V3c0-1.66-1.34-3-3-3s-3,1.34-3,3v6c0,1.66,1.34,3,3,3Zm-1,1.9c-2.7-.4-4.8-2.6-5-5.4H0c.2,3.8,3.1,6.9,7,7.5v2h2v-2c3.9-.6,6.8-3.7,7-7.5h-2c-.2,2.8-2.3,5-5,5.4h-2Z" />
</svg>
</button>
<button type="button" class="browser-annotation-send"
title="Send annotations" aria-label="Send annotations"
@click="$store.browserPage.sendAnnotationsToChat()">
<x-icon name="arrow_forward"></x-icon>
</button>
</div> </div>
</div> </div>
<template x-if="!$store.browserPage.hasFrame() && !$store.browserPage.isBusy()"> <template x-if="!$store.browserPage.hasFrame() && !$store.browserPage.isBusy()">
@ -280,20 +326,19 @@
Browser</button> Browser</button>
</div> </div>
</template> </template>
<template x-if="!$store.browserPage.hasFrame() && $store.browserPage.isBusy()">
<div class="browser-empty browser-starting" role="status" aria-live="polite">
<x-icon class="spinning" name="progress_activity"></x-icon>
<span x-text="$store.browserPage.browserLoadingLabel()"></span>
</div>
</template>
</div> </div>
<div class="browser-bottom-status" <template x-if="$store.browserPage.error">
:class="{ 'is-active': $store.browserPage.isBusy() || $store.browserPage.error, 'has-error': $store.browserPage.error && !$store.browserPage.isBusy() }"> <div class="browser-bottom-status has-error">
<template x-if="$store.browserPage.isBusy()">
<div class="browser-status" :title="$store.browserPage.loadingMessage()">
<x-icon class="spinning" name="progress_activity"></x-icon>
<span x-text="$store.browserPage.loadingMessage()">Loading</span>
</div>
</template>
<template x-if="!$store.browserPage.isBusy() && $store.browserPage.error">
<div class="browser-error" x-text="$store.browserPage.error" :title="$store.browserPage.error"></div> <div class="browser-error" x-text="$store.browserPage.error" :title="$store.browserPage.error"></div>
</template> </div>
</div> </template>
</div> </div>
</template> </template>
</div> </div>
@ -528,7 +573,7 @@
flex: 0 1 210px; flex: 0 1 210px;
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) var(--browser-tab-close-size); grid-template-columns: minmax(0, 1fr) 18px var(--browser-tab-close-size);
align-items: center; align-items: center;
gap: 3px; gap: 3px;
min-width: 128px; min-width: 128px;
@ -603,6 +648,20 @@
font-weight: 600; font-weight: 600;
} }
.browser-tab-loading {
justify-self: center;
color: color-mix(in srgb, var(--color-text) 66%, var(--color-primary) 34%);
font-size: 0.96rem;
line-height: 1;
opacity: 0;
visibility: hidden;
}
.browser-tab-loading.is-visible {
opacity: 1;
visibility: visible;
}
.browser-tab-close { .browser-tab-close {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -997,19 +1056,31 @@
cursor: crosshair; cursor: crosshair;
} }
.browser-frame { .browser-frame {
display: block; display: block;
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
object-fit: contain; object-fit: contain;
image-rendering: auto; image-rendering: auto;
user-select: none; user-select: none;
background: #fff; background: #fff;
} }
.browser-interactive-frame {
display: block;
position: absolute;
inset: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.browser-annotation-layer { .browser-annotation-layer {
position: absolute; position: absolute;
@ -1040,6 +1111,29 @@
background: rgba(51, 153, 255, 0.1); background: rgba(51, 153, 255, 0.1);
} }
.browser-annotation-box.is-hover {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.12);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.58);
}
.browser-annotation-hover-label {
position: absolute;
top: 2px;
left: 2px;
max-width: min(320px, calc(100vw - 24px));
overflow: hidden;
padding: 2px 5px;
border-radius: 4px;
background: rgba(6, 78, 59, 0.92);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.browser-annotation-number { .browser-annotation-number {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -1091,6 +1185,17 @@
font-weight: 750; font-weight: 750;
} }
.browser-annotation-popover-title {
justify-content: space-between;
}
.browser-annotation-popover-heading {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.browser-annotation-popover textarea { .browser-annotation-popover textarea {
width: 100%; width: 100%;
min-height: 82px; min-height: 82px;
@ -1112,7 +1217,6 @@
gap: 7px; gap: 7px;
} }
.browser-annotation-actions .btn,
.browser-annotation-tray-actions .btn { .browser-annotation-tray-actions .btn {
min-height: 30px; min-height: 30px;
padding: 0 10px; padding: 0 10px;
@ -1145,6 +1249,7 @@
cursor: grabbing; cursor: grabbing;
} }
.browser-annotation-popover-close,
.browser-annotation-clear, .browser-annotation-clear,
.browser-annotation-chip button { .browser-annotation-chip button {
display: inline-flex; display: inline-flex;
@ -1162,12 +1267,14 @@
cursor: pointer; cursor: pointer;
} }
.browser-annotation-popover-close:hover,
.browser-annotation-clear:hover, .browser-annotation-clear:hover,
.browser-annotation-chip button:hover { .browser-annotation-chip button:hover {
background: color-mix(in srgb, var(--color-panel) 82%, transparent); background: color-mix(in srgb, var(--color-panel) 82%, transparent);
color: var(--color-text); color: var(--color-text);
} }
.browser-annotation-popover-close .material-symbols-outlined,
.browser-annotation-clear .material-symbols-outlined, .browser-annotation-clear .material-symbols-outlined,
.browser-annotation-chip button .material-symbols-outlined { .browser-annotation-chip button .material-symbols-outlined {
font-size: 16px; font-size: 16px;
@ -1210,6 +1317,87 @@
line-height: 1.25; line-height: 1.25;
} }
.browser-annotation-actions,
.browser-annotation-tray-actions {
align-items: center;
gap: 0.58rem;
}
.browser-annotation-mic,
.browser-annotation-send {
display: inline-flex;
flex: 0 0 3.15rem;
align-items: center;
justify-content: center;
width: 3.15rem;
min-width: 3.15rem;
height: 3.15rem;
margin: 0;
padding: 0;
border: 1px solid transparent;
color: #fff;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.12s ease;
}
.browser-annotation-mic {
border-radius: 12px;
background: transparent;
color: grey;
}
.browser-annotation-mic svg {
transform-origin: center;
transition: color 0.2s ease, transform 0.12s ease-in-out;
}
.browser-annotation-mic.mic-disabled {
color: #5f6368;
cursor: not-allowed;
opacity: 0.58;
}
.browser-annotation-mic.mic-activating,
.browser-annotation-mic.mic-processing {
animation: browser-mic-pulse 0.8s infinite;
}
.browser-annotation-mic.mic-activating { color: silver; }
.browser-annotation-mic.mic-listening { color: red; }
.browser-annotation-mic.mic-recording { color: green; }
.browser-annotation-mic.mic-waiting { color: teal; }
.browser-annotation-mic.mic-processing { color: darkcyan; }
@media (hover: hover) {
.browser-annotation-mic:not(.mic-disabled):hover svg { transform: scale(1.08); }
}
.browser-annotation-mic:not(.mic-disabled):active svg { transform: scale(0.92); }
.browser-annotation-send {
border-radius: 12px;
background: #4248f1;
}
.browser-annotation-send:hover { background: #353bc5; }
.browser-annotation-send:active { background: #2b309c; transform: translateY(1px) scale(0.98); }
.browser-annotation-send:disabled {
background: #4248f1;
cursor: not-allowed;
opacity: 0.5;
}
.browser-annotation-mic svg,
.browser-annotation-send .material-symbols-outlined {
width: 1.5rem;
height: 1.5rem;
font-size: 1.78rem;
}
@keyframes browser-mic-pulse {
50% { transform: scale(1.1); }
}
.browser-empty { .browser-empty {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1239,7 +1427,6 @@
background: color-mix(in srgb, #7f1d1d 22%, var(--color-background)); background: color-mix(in srgb, #7f1d1d 22%, var(--color-background));
} }
.browser-status,
.browser-error { .browser-error {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -1250,17 +1437,12 @@
white-space: nowrap; white-space: nowrap;
} }
.browser-status span:not(.material-symbols-outlined),
.browser-error { .browser-error {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.browser-status .material-symbols-outlined {
font-size: 15px;
}
.browser-modal .spinning, .browser-modal .spinning,
.browser-panel .spinning { .browser-panel .spinning {
display: inline-block; display: inline-block;
@ -1286,6 +1468,10 @@
color: var(--color-text); color: var(--color-text);
} }
.browser-starting .material-symbols-outlined {
font-size: 1.45rem;
}
@container (max-width: 460px) { @container (max-width: 460px) {
.browser-toolbar { .browser-toolbar {
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;

View file

@ -21,11 +21,13 @@ const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000; const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
const BROWSER_COMMAND_TIMEOUT_MS = 45000; const BROWSER_COMMAND_TIMEOUT_MS = 45000;
const BROWSER_CONFIG_REFRESH_MS = 15000; const BROWSER_CONFIG_REFRESH_MS = 15000;
const BROWSER_VIEWER_TRANSPORT_INTERACTIVE = "interactive";
const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot"; const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";
const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast"; const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";
const VIEWPORT_SYNC_DEBOUNCE_MS = 220; const VIEWPORT_SYNC_INTERVAL_MS = 50;
const VIEWPORT_SYNC_SIZE_TOLERANCE = 4; const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
const CANVAS_VIEWPORT_SETTLE_MS = 520; const CANVAS_VIEWPORT_SETTLE_MS = 520;
const INTERACTIVE_VIEWPORT_SETTLE_MS = 320;
const SURFACE_VIEWPORT_STABLE_FRAMES = 4; const SURFACE_VIEWPORT_STABLE_FRAMES = 4;
const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200; const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;
const FRAME_REJECT_SYNC_COOLDOWN_MS = 600; const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
@ -168,11 +170,13 @@ const model = {
frameSrc: "", frameSrc: "",
frameCanvasReady: false, frameCanvasReady: false,
frameState: null, frameState: null,
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST, viewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
interactiveViewUrl: "",
viewerFallbackReason: "",
tabScope: "per_context", tabScope: "per_context",
liveScreencastEnabled: true,
annotating: false, annotating: false,
annotationComments: [], annotationComments: [],
annotationHover: null,
annotationDraft: null, annotationDraft: null,
annotationDraftText: "", annotationDraftText: "",
annotationDragRect: null, annotationDragRect: null,
@ -205,6 +209,8 @@ const model = {
_annotationPointer: null, _annotationPointer: null,
_annotationTrayDrag: null, _annotationTrayDrag: null,
_annotationSequence: 0, _annotationSequence: 0,
_annotationHoverSequence: 0,
_annotationHoverAt: 0,
_mode: "", _mode: "",
_surfaceMounted: false, _surfaceMounted: false,
_surfaceSwitching: false, _surfaceSwitching: false,
@ -216,6 +222,7 @@ const model = {
_openSignature: "", _openSignature: "",
_connectSequence: 0, _connectSequence: 0,
_viewerToken: "", _viewerToken: "",
_subscribedViewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
_contextCreatePromise: null, _contextCreatePromise: null,
_lastSelectedContextId: "", _lastSelectedContextId: "",
_sessionRefreshPromise: null, _sessionRefreshPromise: null,
@ -435,6 +442,11 @@ const model = {
}, },
async contextIdForNewBrowser() { async contextIdForNewBrowser() {
const selectedContextId = this.normalizeContextId(chatsStore.selected);
if (selectedContextId) {
this.contextId = selectedContextId;
return selectedContextId;
}
return await this.ensureContextId(); return await this.ensureContextId();
}, },
@ -879,6 +891,7 @@ const model = {
resetRenderedFrame() { resetRenderedFrame() {
this.cancelFrameRender(); this.cancelFrameRender();
this.interactiveViewUrl = "";
this.clearFrameSrc(); this.clearFrameSrc();
this.clearFrameCanvas(); this.clearFrameCanvas();
this._lastFrameDimensions = null; this._lastFrameDimensions = null;
@ -932,6 +945,7 @@ const model = {
async syncViewportAfterSurfaceOpen(sequence = this._surfaceOpenSequence) { async syncViewportAfterSurfaceOpen(sequence = this._surfaceOpenSequence) {
if (!this.connected || !this.activeBrowserId) return; if (!this.connected || !this.activeBrowserId) return;
const surfaceMode = this._mode;
await this.waitForSurfaceViewport({ sequence }); await this.waitForSurfaceViewport({ sequence });
if (!this.isCurrentSurfaceOpen(sequence)) { if (!this.isCurrentSurfaceOpen(sequence)) {
return; return;
@ -939,19 +953,24 @@ const model = {
await this.syncViewport(true, { await this.syncViewport(true, {
restartStream: this._mode === "canvas" && this.usesScreencastTransport(), restartStream: this._mode === "canvas" && this.usesScreencastTransport(),
}); });
if (this._mode !== "canvas") return; if (surfaceMode === "modal" && this.usesInteractiveTransport()) {
this.scheduleViewportSyncForSurface(sequence, 240); this.scheduleViewportSyncForSurface(sequence, INTERACTIVE_VIEWPORT_SETTLE_MS, surfaceMode);
this.scheduleViewportSyncForSurface(sequence, 520); return;
}
if (surfaceMode !== "canvas") return;
this.scheduleViewportSyncForSurface(sequence, 240, surfaceMode);
this.scheduleViewportSyncForSurface(sequence, 520, surfaceMode);
}, },
requestedViewerTransport() { requestedViewerTransport() {
return this.liveScreencastEnabled return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
? BROWSER_VIEWER_TRANSPORT_SCREENCAST
: BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
}, },
normalizeViewerTransport(value = "") { normalizeViewerTransport(value = "") {
const normalized = String(value || "").trim().toLowerCase().replace("-", "_"); const normalized = String(value || "").trim().toLowerCase().replace("-", "_");
if (normalized === BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
}
if (normalized === BROWSER_VIEWER_TRANSPORT_SCREENCAST) { if (normalized === BROWSER_VIEWER_TRANSPORT_SCREENCAST) {
return BROWSER_VIEWER_TRANSPORT_SCREENCAST; return BROWSER_VIEWER_TRANSPORT_SCREENCAST;
} }
@ -974,6 +993,101 @@ const model = {
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST; return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
}, },
usesInteractiveTransport() {
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_INTERACTIVE
&& Boolean(this.interactiveViewUrl);
},
isInteractiveSurface(stage = null) {
return this.usesInteractiveTransport() && stage === this._stageElement;
},
prepareInteractiveViewFrame(frame = null) {
const target = frame || this._stageElement?.querySelector?.(".browser-interactive-frame");
const remoteWindow = target?.contentWindow;
if (!remoteWindow) return false;
try {
const remoteDocument = target.contentDocument || remoteWindow.document;
if (!remoteDocument) return false;
if (!remoteDocument.getElementById("a0-xpra-browser-frame-css")) {
const style = remoteDocument.createElement("style");
style.id = "a0-xpra-browser-frame-css";
style.textContent = `
#shadow_pointer {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
.window canvas,
.undecorated canvas {
display: block !important;
margin: 0 !important;
}
`;
remoteDocument.head?.appendChild(style);
}
const normalizeWindows = () => {
const windows = Object.values(remoteWindow.client?.id_to_window || {});
for (const xpraWindow of windows) {
xpraWindow.resizable = false;
xpraWindow.decorations = false;
xpraWindow.decorated = false;
xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
xpraWindow._set_decorated?.(false);
xpraWindow.configure_border_class?.();
xpraWindow.leftoffset = 0;
xpraWindow.rightoffset = 0;
xpraWindow.topoffset = 0;
xpraWindow.bottomoffset = 0;
xpraWindow.updateCSSGeometry?.();
}
return windows.length > 0;
};
const screen = remoteDocument.querySelector?.("#screen");
if (screen && !remoteWindow.__a0BrowserFrameObserver && remoteWindow.MutationObserver) {
const observer = new remoteWindow.MutationObserver(normalizeWindows);
observer.observe(screen, { childList: true });
remoteWindow.__a0BrowserFrameObserver = observer;
}
return normalizeWindows();
} catch {
return false;
}
},
syncInteractiveViewSize() {
if (!this.usesInteractiveTransport()) return;
const frame = this._stageElement?.querySelector?.(".browser-interactive-frame");
try {
this.prepareInteractiveViewFrame(frame);
frame?.contentWindow?.client?._screen_resized?.();
} catch {}
},
applyViewer(data = {}) {
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
if (Object.prototype.hasOwnProperty.call(data || {}, "interactive_view")) {
const viewer = data.interactive_view;
this.interactiveViewUrl = viewer?.available && viewer?.url ? String(viewer.url) : "";
this.viewerFallbackReason = String(data.viewer_fallback_reason || viewer?.error || "");
}
if (this.viewerTransport !== BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
this.interactiveViewUrl = "";
}
},
onInteractiveViewLoad() {
if (!this.usesInteractiveTransport()) return;
this.prepareInteractiveViewFrame();
this.switchingBrowserId = null;
this._surfaceSwitching = false;
this.queueViewportSync(true);
},
supportsBinaryFrames() { supportsBinaryFrames() {
return BROWSER_BINARY_FRAME_REQUESTS_ENABLED && BROWSER_BINARY_PAYLOADS_SUPPORTED; return BROWSER_BINARY_FRAME_REQUESTS_ENABLED && BROWSER_BINARY_PAYLOADS_SUPPORTED;
}, },
@ -1003,9 +1117,9 @@ const model = {
return { width, height }; return { width, height };
}, },
scheduleViewportSyncForSurface(sequence, delayMs = 0) { scheduleViewportSyncForSurface(sequence, delayMs = 0, mode = this._mode) {
globalThis.setTimeout?.(() => { globalThis.setTimeout?.(() => {
if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== "canvas") { if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== mode) {
return; return;
} }
this.queueViewportSync(true); this.queueViewportSync(true);
@ -1093,7 +1207,8 @@ const model = {
replaceAll: Boolean(data.all_browsers), replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers, replaceContext: !data.all_browsers,
}); });
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport); this.applyViewer(data);
this._subscribedViewerTransport = this.viewerTransport;
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,
@ -1108,9 +1223,7 @@ const model = {
const frameHandler = ({ data }) => { const frameHandler = ({ 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) { this.applyViewer(data);
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
this.applyTabScope(data); 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);
@ -1180,9 +1293,7 @@ const model = {
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) { this.applyViewer(data);
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
this.applyTabScope(data); 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);
if (Array.isArray(data.browsers)) { if (Array.isArray(data.browsers)) {
@ -1384,7 +1495,7 @@ const model = {
}, },
hasFrame() { hasFrame() {
return Boolean(this.frameSrc || this.frameCanvasReady); return Boolean(this.interactiveViewUrl || this.frameSrc || this.frameCanvasReady);
}, },
paintFrameBitmap(bitmap) { paintFrameBitmap(bitmap) {
@ -1419,6 +1530,10 @@ const model = {
}, },
frameElement() { frameElement() {
if (this.usesInteractiveTransport()) {
const iframe = this._stageElement?.querySelector?.(".browser-interactive-frame");
if (iframe) return iframe;
}
if (this.frameCanvasReady) { if (this.frameCanvasReady) {
const canvas = this.currentFrameCanvas(); const canvas = this.currentFrameCanvas();
if (canvas) return canvas; if (canvas) return canvas;
@ -1467,7 +1582,7 @@ const model = {
replaceAll: Boolean(data.all_browsers), replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers, replaceContext: !data.all_browsers,
}); });
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport); this.applyViewer(data);
const result = data.result || {}; const result = data.result || {};
const resultContextId = this.normalizeContextId( const resultContextId = this.normalizeContextId(
result.context_id result.context_id
@ -1500,8 +1615,8 @@ const model = {
} }
this.applySnapshot(data.snapshot); this.applySnapshot(data.snapshot);
if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) { if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
this.clearAnnotationsForBrowser(previousActiveBrowserId, null, previousActiveContextId);
this.cancelAnnotationDraft(); this.cancelAnnotationDraft();
this.clearAnnotationHover();
} }
const activeChanged = this.activeBrowserId const activeChanged = this.activeBrowserId
&& !this.sameBrowserTab( && !this.sameBrowserTab(
@ -1510,7 +1625,12 @@ const model = {
previousActiveBrowserId, previousActiveBrowserId,
previousActiveContextId, previousActiveContextId,
); );
if ((commandName === "open" || commandName === "close" || activeChanged) && this.contextId && this.activeBrowserId) { const viewerTransportChanged = this._subscribedViewerTransport !== this.viewerTransport;
if (
(commandName === "open" || commandName === "close" || activeChanged || viewerTransportChanged)
&& this.contextId
&& this.activeBrowserId
) {
await this.connectViewer({ await this.connectViewer({
browserId: this.activeBrowserId, browserId: this.activeBrowserId,
contextId: this.activeBrowserContextId, contextId: this.activeBrowserContextId,
@ -1670,6 +1790,10 @@ const model = {
return this.sameBrowserTab(browser?.id, browser?.context_id, this.activeBrowserId, this.activeBrowserContextId); return this.sameBrowserTab(browser?.id, browser?.context_id, this.activeBrowserId, this.activeBrowserContextId);
}, },
isBrowserLoading(browser) {
return Boolean(browser?.loading || (this.isActiveBrowser(browser) && this.isBusy()));
},
browserTabTitle(browser) { browserTabTitle(browser) {
const title = String(browser?.title || "").trim(); const title = String(browser?.title || "").trim();
const url = String(browser?.currentUrl || "").trim(); const url = String(browser?.currentUrl || "").trim();
@ -1811,6 +1935,7 @@ const model = {
this.frameState = nextState; this.frameState = nextState;
if (previousUrl && nextUrl && previousUrl !== nextUrl) { if (previousUrl && nextUrl && previousUrl !== nextUrl) {
this.cancelAnnotationDraft(); this.cancelAnnotationDraft();
this.clearAnnotationHover();
} }
if (!this.addressFocused && nextState.currentUrl) { if (!this.addressFocused && nextState.currentUrl) {
this.address = nextState.currentUrl; this.address = nextState.currentUrl;
@ -1831,6 +1956,7 @@ const model = {
if (snapshot.state) { if (snapshot.state) {
this.applyActiveFrameState(snapshot.state); this.applyActiveFrameState(snapshot.state);
} }
if (this.usesInteractiveTransport()) return;
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,
@ -1859,6 +1985,12 @@ const model = {
return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser()); return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser());
}, },
browserLoadingLabel() {
if (this.commandInFlight && !this.activeBrowserId) return "Starting Browser…";
if (this.isSwitchingBrowser()) return "Switching Browser tab…";
return "Connecting to Browser…";
},
setActiveBrowserId(id, contextId = "") { setActiveBrowserId(id, contextId = "") {
const previous = this.activeBrowserId; const previous = this.activeBrowserId;
const previousContextId = this.activeBrowserContextId; const previousContextId = this.activeBrowserContextId;
@ -1877,6 +2009,7 @@ const model = {
this._lastViewportKey = ""; this._lastViewportKey = "";
this._lastViewport = null; this._lastViewport = null;
this.cancelAnnotationDraft(); this.cancelAnnotationDraft();
this.clearAnnotationHover();
} }
}, },
@ -2029,6 +2162,7 @@ const model = {
this.closeExtensionsMenu(); this.closeExtensionsMenu();
if (!nextValue) { if (!nextValue) {
this.cancelAnnotationDraft(); this.cancelAnnotationDraft();
this.clearAnnotationHover();
this.annotationDragRect = null; this.annotationDragRect = null;
this._annotationPointer = null; this._annotationPointer = null;
} else { } else {
@ -2054,8 +2188,25 @@ const model = {
)); ));
}, },
pendingAnnotations() {
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
if (!contextId) return [];
return this.annotationComments.filter(
(annotation) => this.normalizeContextId(annotation.contextId) === contextId,
);
},
nextAnnotationIndex() { nextAnnotationIndex() {
return this.visibleAnnotations().length + 1; return this.pendingAnnotations().length + 1;
},
annotationBatchLabel() {
const annotations = this.pendingAnnotations();
const pageCount = new Set(
annotations.map((annotation) => `${annotation.browserId}:${annotation.url}`),
).size;
if (pageCount <= 1) return `Annotations (${annotations.length})`;
return `Annotations (${annotations.length} across ${pageCount} pages)`;
}, },
annotationTrayStyle() { annotationTrayStyle() {
@ -2142,21 +2293,15 @@ const model = {
this.annotationTrayPosition = null; this.annotationTrayPosition = null;
}, },
clearVisibleAnnotations() { clearPendingAnnotations() {
this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl(), this.activeBrowserContextId); const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
if (!contextId) return;
this.annotationComments = this.annotationComments.filter(
(annotation) => this.normalizeContextId(annotation.contextId) !== contextId,
);
this.resetAnnotationTrayPosition(); this.resetAnnotationTrayPosition();
}, },
clearAnnotationsForBrowser(browserId, url = null, contextId = "") {
const numericBrowserId = this.normalizeBrowserId(browserId);
const normalizedContextId = this.normalizeContextId(contextId || this.activeBrowserContextId);
if (!numericBrowserId) return;
this.annotationComments = this.annotationComments.filter((annotation) => {
if (!this.sameBrowserTab(annotation.browserId, annotation.contextId, numericBrowserId, normalizedContextId)) return true;
return url ? String(annotation.url || "") !== String(url) : false;
});
},
annotationBoxStyle(rect = {}) { annotationBoxStyle(rect = {}) {
const viewport = this.currentViewportSize() || this._lastViewport || {}; const viewport = this.currentViewportSize() || this._lastViewport || {};
const width = Math.max(1, Number(viewport.width || rect.width || 1)); const width = Math.max(1, Number(viewport.width || rect.width || 1));
@ -2235,6 +2380,7 @@ const model = {
const point = this.stagePointForEvent(event); const point = this.stagePointForEvent(event);
if (!point) return; if (!point) return;
this.cancelAnnotationDraft(); this.cancelAnnotationDraft();
this.clearAnnotationHover();
this.annotationError = ""; this.annotationError = "";
this._annotationPointer = { this._annotationPointer = {
id: event.pointerId, id: event.pointerId,
@ -2251,7 +2397,11 @@ const model = {
}, },
moveAnnotationSelection(event) { moveAnnotationSelection(event) {
if (!this.annotating || !this._annotationPointer) return; if (!this.annotating) return;
if (!this._annotationPointer) {
void this.updateAnnotationHover(event);
return;
}
if (event.pointerId !== this._annotationPointer.id) return; if (event.pointerId !== this._annotationPointer.id) return;
const point = this.stagePointForEvent(event); const point = this.stagePointForEvent(event);
if (!point) return; if (!point) return;
@ -2297,6 +2447,63 @@ const model = {
this.annotationDragRect = null; this.annotationDragRect = null;
}, },
clearAnnotationHover() {
this._annotationHoverSequence += 1;
this.annotationHover = null;
},
async updateAnnotationHover(event) {
if (!this.annotating || this.annotationBusy || this.annotationDraft || this._annotationPointer) return;
const now = Date.now();
if (now - this._annotationHoverAt < 90) return;
const point = this.stagePointForEvent(event);
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
const browserId = this.activeBrowserId;
if (!point || !contextId || !browserId) return;
this._annotationHoverAt = now;
const url = this.activeAnnotationUrl();
const sequence = this._annotationHoverSequence + 1;
this._annotationHoverSequence = sequence;
try {
const response = await websocket.request(
"browser_viewer_annotation",
{
context_id: contextId,
browser_id: browserId,
viewer_id: this._viewerToken,
payload: {
kind: "element",
point: { x: Math.round(point.x), y: Math.round(point.y) },
viewport: this.currentViewportSize(),
url,
title: this.activeTitle,
},
},
{ timeoutMs: 10000 },
);
if (
sequence !== this._annotationHoverSequence
|| !this.sameBrowserTab(browserId, contextId, this.activeBrowserId, this.activeBrowserContextId)
|| url !== this.activeAnnotationUrl()
) return;
const metadata = firstOk(response).annotation || {};
const rect = metadata?.target?.rect || metadata?.rect;
this.annotationHover = rect
? { rect: this.clampAnnotationRect(rect), metadata }
: null;
} catch {
if (sequence === this._annotationHoverSequence) this.annotationHover = null;
}
},
annotationHoverLabel() {
const target = this.annotationHover?.metadata?.target || {};
const tag = String(target.tagName || "").toLowerCase();
const summary = String(target.summary || "").trim();
return [tag ? `<${tag}>` : "Element", summary].filter(Boolean).join(" ");
},
cancelAnnotationDraft() { cancelAnnotationDraft() {
this.annotationDraft = null; this.annotationDraft = null;
this.annotationDraftText = ""; this.annotationDraftText = "";
@ -2311,6 +2518,7 @@ const model = {
const url = this.activeAnnotationUrl(); const url = this.activeAnnotationUrl();
const title = this.activeTitle; const title = this.activeTitle;
this._annotationSequence = sequence; this._annotationSequence = sequence;
this.clearAnnotationHover();
this.annotationBusy = true; this.annotationBusy = true;
this.annotationError = ""; this.annotationError = "";
try { try {
@ -2357,7 +2565,7 @@ const model = {
addAnnotationComment() { addAnnotationComment() {
const comment = String(this.annotationDraftText || "").trim(); const comment = String(this.annotationDraftText || "").trim();
if (!this.annotationDraft || !comment) return; if (!this.annotationDraft || !comment) return;
if (this.visibleAnnotations().length >= ANNOTATION_MAX_COMMENTS) { if (this.pendingAnnotations().length >= ANNOTATION_MAX_COMMENTS) {
this.annotationError = `Keep each batch to ${ANNOTATION_MAX_COMMENTS} annotations or fewer.`; this.annotationError = `Keep each batch to ${ANNOTATION_MAX_COMMENTS} annotations or fewer.`;
this.error = this.annotationError; this.error = this.annotationError;
return; return;
@ -2375,18 +2583,18 @@ const model = {
removeAnnotationComment(annotationId) { removeAnnotationComment(annotationId) {
this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId); this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId);
if (!this.visibleAnnotations().length) { if (!this.pendingAnnotations().length) {
this.resetAnnotationTrayPosition(); this.resetAnnotationTrayPosition();
} }
}, },
annotationChipLabel(annotation) {
const prefix = annotation?.kind === "area" ? "Area" : "Element";
return `${prefix} ${annotation?.index || ""}`.trim();
},
formatAnnotationRect(rect = {}) { formatAnnotationRect(rect = {}) {
const normalized = this.clampAnnotationRect(rect); const normalized = {
x: Math.round(Number(rect.x || 0)),
y: Math.round(Number(rect.y || 0)),
width: Math.max(1, Math.round(Number(rect.width || 1))),
height: Math.max(1, Math.round(Number(rect.height || 1))),
};
return `x=${normalized.x}, y=${normalized.y}, width=${normalized.width}, height=${normalized.height}`; return `x=${normalized.x}, y=${normalized.y}, width=${normalized.width}, height=${normalized.height}`;
}, },
@ -2437,45 +2645,60 @@ const model = {
return lines.join("\n"); return lines.join("\n");
}, },
buildAnnotationsPrompt() { buildAnnotationsPrompt(instruction = "") {
const annotations = this.visibleAnnotations(); const annotations = this.pendingAnnotations();
if (!annotations.length) return ""; if (!annotations.length) return "";
const lines = [ const lines = ["Browser annotations"];
"Browser annotations", const spokenInstruction = String(instruction || "").trim();
`Page title: ${this.activeTitle}`, if (spokenInstruction) lines.push(`Instruction: ${spokenInstruction}`);
`Page URL: ${this.activeAnnotationUrl()}`, lines.push("");
`Browser id: ${this.activeBrowserId}`,
"", const pages = new Map();
]; for (const annotation of annotations) {
annotations.forEach((annotation, index) => { const key = `${annotation.contextId}:${annotation.browserId}:${annotation.url}`;
if (!pages.has(key)) pages.set(key, []);
pages.get(key).push(annotation);
}
let annotationNumber = 0;
Array.from(pages.values()).forEach((pageAnnotations, pageIndex) => {
const page = pageAnnotations[0];
lines.push( lines.push(
`Annotation ${index + 1}`, `Page ${pageIndex + 1}`,
`Comment: ${annotation.comment}`, `Page title: ${page.title || "Untitled"}`,
`Selection kind: ${annotation.kind}`, `Page URL: ${page.url || "about:blank"}`,
`Coordinates: ${this.formatAnnotationRect(annotation.rect)}`, `Browser id: ${page.browserId}`,
"",
); );
const metadata = this.formatAnnotationMetadata(annotation.metadata); pageAnnotations.forEach((annotation) => {
if (metadata) { annotationNumber += 1;
lines.push(metadata); lines.push(
} `Annotation ${annotationNumber}`,
lines.push(""); `Comment: ${annotation.comment}`,
`Selection kind: ${annotation.kind}`,
`Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
);
const metadata = this.formatAnnotationMetadata(annotation.metadata);
if (metadata) lines.push(metadata);
lines.push("");
});
}); });
return lines.join("\n").trim(); return lines.join("\n").trim();
}, },
draftAnnotationsToChat() { draftAnnotationsToChat(instruction = "") {
const prompt = this.buildAnnotationsPrompt(); const prompt = this.buildAnnotationsPrompt(instruction);
if (!prompt) return; if (!prompt) return;
const existingMessage = String(chatInputStore.message || "").trim(); const existingMessage = String(chatInputStore.message || "").trim();
chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt; chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt;
chatInputStore.adjustTextareaHeight?.(); chatInputStore.adjustTextareaHeight?.();
chatInputStore.focus?.(); chatInputStore.focus?.();
this.clearVisibleAnnotations(); this.clearPendingAnnotations();
this.toggleAnnotationMode(false); this.toggleAnnotationMode(false);
}, },
async sendAnnotationsToChat() { async sendAnnotationsToChat(instruction = "") {
const prompt = this.buildAnnotationsPrompt(); const prompt = this.buildAnnotationsPrompt(instruction);
if (!prompt) return; if (!prompt) return;
chatInputStore.message = prompt; chatInputStore.message = prompt;
chatInputStore.adjustTextareaHeight?.(); chatInputStore.adjustTextareaHeight?.();
@ -2488,13 +2711,52 @@ const model = {
chatInputStore.focus?.(); chatInputStore.focus?.();
return; return;
} }
this.clearVisibleAnnotations(); this.clearPendingAnnotations();
this.toggleAnnotationMode(false); this.toggleAnnotationMode(false);
} catch (error) { } catch (error) {
this.error = error instanceof Error ? error.message : String(error); this.error = error instanceof Error ? error.message : String(error);
} }
}, },
async startAnnotationVoice(draftComment = false) {
try {
const { store: whisperStore } = await import(
"/plugins/_whisper_stt/webui/whisper-stt-store.js"
);
await whisperStore.handleMicrophoneClick(async (text, options = {}) => {
if (draftComment) {
const transcript = String(text || "").trim();
if (transcript && this.annotationDraft) {
const existing = String(this.annotationDraftText || "").trim();
this.annotationDraftText = existing ? `${existing}\n${transcript}` : transcript;
if (options.sendImmediately) {
this.addAnnotationComment();
await this.sendAnnotationsToChat();
}
}
} else if (options.sendImmediately) {
await this.sendAnnotationsToChat(text);
} else {
this.draftAnnotationsToChat(text);
}
whisperStore.stop();
});
whisperStore.updateMicrophoneButtonUI();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
}
},
async syncAnnotationMicrophoneUI() {
try {
const { store: whisperStore } = await import(
"/plugins/_whisper_stt/webui/whisper-stt-store.js"
);
await whisperStore.ensureStatusLoaded({ suppressError: true });
whisperStore.updateMicrophoneButtonUI();
} catch {}
},
currentViewportSize() { currentViewportSize() {
const measurement = this.surfaceViewportMeasurement(); const measurement = this.surfaceViewportMeasurement();
if (!measurement) return null; if (!measurement) return null;
@ -2521,13 +2783,21 @@ const model = {
queueViewportSync(force = false) { queueViewportSync(force = false) {
this.clearRenderedFrameIfViewportChanged(); this.clearRenderedFrameIfViewportChanged();
if (force) {
if (this._viewportSyncTimer) {
globalThis.clearTimeout(this._viewportSyncTimer);
this._viewportSyncTimer = null;
}
void this.syncViewport(true);
return;
}
if (this._viewportSyncTimer) { if (this._viewportSyncTimer) {
globalThis.clearTimeout(this._viewportSyncTimer); return;
} }
this._viewportSyncTimer = globalThis.setTimeout(() => { this._viewportSyncTimer = globalThis.setTimeout(() => {
this._viewportSyncTimer = null; this._viewportSyncTimer = null;
void this.syncViewport(force); void this.syncViewport(false);
}, force ? 0 : VIEWPORT_SYNC_DEBOUNCE_MS); }, VIEWPORT_SYNC_INTERVAL_MS);
}, },
async syncViewport(force = false, options = {}) { async syncViewport(force = false, options = {}) {
@ -2542,7 +2812,7 @@ const model = {
} }
const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`; const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
if ( if (
(!restartStream && this._lastViewportKey === key) (!force && !restartStream && this._lastViewportKey === key)
|| ( || (
!force !force
&& !restartStream && !restartStream
@ -2555,11 +2825,13 @@ const model = {
return; return;
} }
try { try {
this.syncInteractiveViewSize();
await websocket.emit("browser_viewer_input", { await websocket.emit("browser_viewer_input", {
context_id: contextId, context_id: contextId,
browser_id: this.activeBrowserId, browser_id: this.activeBrowserId,
viewer_id: this._viewerToken, viewer_id: this._viewerToken,
input_type: "viewport", input_type: "viewport",
viewer_transport: this.viewerTransport,
width: viewport.width, width: viewport.width,
height: viewport.height, height: viewport.height,
restart_stream: restartStream && this.usesScreencastTransport(), restart_stream: restartStream && this.usesScreencastTransport(),
@ -2739,6 +3011,9 @@ const model = {
this._viewerToken = ""; this._viewerToken = "";
this.switchingBrowserId = null; this.switchingBrowserId = null;
this.viewerTransport = this.requestedViewerTransport(); this.viewerTransport = this.requestedViewerTransport();
this._subscribedViewerTransport = this.viewerTransport;
this.interactiveViewUrl = "";
this.viewerFallbackReason = "";
this.tabScope = "per_context"; this.tabScope = "per_context";
this._surfaceMounted = false; this._surfaceMounted = false;
this._surfaceSwitching = false; this._surfaceSwitching = false;
@ -2750,6 +3025,7 @@ const model = {
this.annotationError = ""; this.annotationError = "";
this.cancelAnnotationDraft(); this.cancelAnnotationDraft();
this.cancelAnnotationSelection(); this.cancelAnnotationSelection();
this.clearAnnotationHover();
this.resetAnnotationTrayPosition(); this.resetAnnotationTrayPosition();
if (this.contextId) { if (this.contextId) {
try { try {
@ -3010,13 +3286,6 @@ const model = {
return this.frameState?.currentUrl || this.address || "about:blank"; return this.frameState?.currentUrl || this.address || "about:blank";
}, },
loadingMessage() {
if (this.browserInstallExpected) {
const cacheDir = this.status?.playwright?.cache_dir || "/a0/tmp/playwright";
return `Installing Chromium for the first Browser run. This can take a few minutes; future starts reuse ${cacheDir}.`;
}
return "Loading";
},
}; };
export const store = createStore("browserPage", model); export const store = createStore("browserPage", model);

View file

@ -218,13 +218,13 @@
class="browser-config-field-help" class="browser-config-field-help"
x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'" x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'"
> >
Each chat shows only its own Browser tabs. Each chat shows only its own tabs. Browser sign-ins are shared across chats.
</span> </span>
<span <span
class="browser-config-field-help" class="browser-config-field-help"
x-show="$store.browserConfig.config.browser_tab_scope === 'shared'" x-show="$store.browserConfig.config.browser_tab_scope === 'shared'"
> >
The Browser tab strip shows tabs from every active chat. The tab strip shows tabs from every active chat. Browser sign-ins are shared across chats.
</span> </span>
</label> </label>

View file

@ -2,7 +2,7 @@
## Purpose ## Purpose
- Own the built-in slash command manager and chat composer slash picker. - Own the built-in slash command manager and chat composer slash/reference picker.
- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes. - Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
## Ownership ## Ownership
@ -12,7 +12,7 @@
- `api/commands.py` owns the Commands API actions used by the WebUI. - `api/commands.py` owns the Commands API actions used by the WebUI.
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset. - `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`, including `/stop` agent-run control. - `commands/` owns bundled read-only slash command definitions shipped by `_commands`, including `/stop` agent-run control.
- `extensions/` owns the chat composer slash picker and incoming-message command resolution. - `extensions/` owns the chat composer slash and `@` reference picker plus incoming-message command resolution.
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace. - `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. - `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. - `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
@ -31,6 +31,9 @@
- 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 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. - Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
- Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations. The composer picker opens only for prefix syntax, while postfix commands resolve when sent. - Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations. The composer picker opens only for prefix syntax, while postfix commands resolve when sent.
- Composer `@` selections insert plain references only: `@[./path]`, `@[./folder/]`, `@[agent/profile]`, `@[skill/name]`, or `@[mcp/server]`. They never load content, activate skills, call MCP, or delegate by themselves.
- Selected reference icons may use the composer highlight color while their labels keep the normal text color; serialized prompt text remains unchanged.
- File and folder references stay inside the active chat workdir and list one directory at a time through the existing file-browser and chat-path APIs. Profile and effective MCP server references reuse their scoped catalogs; skill references use only entries visible in the active chat scope.
- WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent. - WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
- `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging. - `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
- `/profile` opens Manage agents without arguments, keeps existing profile - `/profile` opens Manage agents without arguments, keeps existing profile

View file

@ -12,46 +12,54 @@
<template x-if="$store.commandsSlash.loading"> <template x-if="$store.commandsSlash.loading">
<div class="commands-slash-loading"> <div class="commands-slash-loading">
<x-icon class="spinning" name="progress_activity"></x-icon> <x-icon class="spinning" name="progress_activity"></x-icon>
<span>Loading slash commands...</span> <span x-text="$store.commandsSlash.loadingLabel"></span>
</div> </div>
</template> </template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0"> <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length > 0">
<div class="commands-slash-results"> <div class="commands-slash-results">
<template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path"> <template x-for="(item, index) in $store.commandsSlash.filteredItems" :key="item.id || item.path">
<button type="button" <button type="button"
class="commands-slash-item" class="commands-slash-item"
:class="{ active: index === $store.commandsSlash.selectedIndex }" :class="{ active: index === $store.commandsSlash.selectedIndex }"
@mouseenter="$store.commandsSlash.selectedIndex = index" @mouseenter="$store.commandsSlash.selectedIndex = index"
@mousedown.prevent @mousedown.prevent
@click.prevent="$store.commandsSlash.applySelection(command)"> @click.prevent="$store.commandsSlash.applySelectedItem(item)">
<div class="commands-slash-item-header"> <div class="commands-slash-item-header">
<div class="commands-slash-item-name"> <div class="commands-slash-item-name"
<span class="commands-slash-prefix">/</span><span x-text="command.name"></span> :class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''">
<template x-if="$store.commandsSlash.mode === 'reference'">
<x-icon class="commands-reference-icon" :name="item.icon"></x-icon>
</template>
<span class="commands-slash-prefix" x-text="$store.commandsSlash.mode === 'reference' ? '@' : '/'"></span><span x-text="$store.commandsSlash.mode === 'reference' ? item.label : item.name"></span>
</div> </div>
<span class="commands-slash-scope" x-text="command.source_scope_label"></span> <span class="commands-slash-scope"
:class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''"
x-text="$store.commandsSlash.mode === 'reference' ? item.kind : item.source_scope_label"></span>
</div> </div>
<div class="commands-slash-item-description" x-text="command.description"></div> <div class="commands-slash-item-description" x-text="item.description"></div>
<template x-if="command.argument_hint"> <template x-if="$store.commandsSlash.mode !== 'reference' && item.argument_hint">
<div class="commands-slash-item-hint" x-text="command.argument_hint"></div> <div class="commands-slash-item-hint" x-text="item.argument_hint"></div>
</template> </template>
</button> </button>
</template> </template>
</div> </div>
</template> </template>
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0"> <template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length === 0">
<div class="commands-slash-empty"> <div class="commands-slash-empty">
<div class="commands-slash-empty-copy"> <div class="commands-slash-empty-copy">
No matching slash commands. <span x-text="$store.commandsSlash.emptyLabel"></span>
</div> </div>
<button type="button" <template x-if="$store.commandsSlash.mode !== 'reference'">
class="commands-slash-create" <button type="button"
@mousedown.prevent class="commands-slash-create"
@click.prevent="$store.commandsSlash.openCreateCommand()"> @mousedown.prevent
<x-icon name="add"></x-icon> @click.prevent="$store.commandsSlash.openCreateCommand()">
<span x-text="$store.commandsSlash.emptyStateLabel"></span> <x-icon name="add"></x-icon>
</button> <span x-text="$store.commandsSlash.emptyStateLabel"></span>
</button>
</template>
</div> </div>
</template> </template>
</div> </div>
@ -113,10 +121,26 @@
font-weight: 600; font-weight: 600;
} }
.commands-slash-item-name.is-reference {
display: inline-flex;
align-items: center;
}
.commands-reference-icon {
margin-right: 0.38rem;
font-size: 1.08rem;
color: var(--color-highlight);
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
}
.commands-slash-prefix { .commands-slash-prefix {
color: var(--color-highlight); color: var(--color-highlight);
} }
.commands-slash-item-name.is-reference .commands-slash-prefix {
color: inherit;
}
.commands-slash-scope { .commands-slash-scope {
padding: 0.18rem 0.45rem; padding: 0.18rem 0.45rem;
border-radius: 999px; border-radius: 999px;
@ -126,6 +150,55 @@
white-space: nowrap; white-space: nowrap;
} }
.commands-slash-scope.is-reference {
padding: 0;
background: transparent;
}
.commands-slash-item-name.is-reference,
#chat-input .composer-reference {
color: var(--color-text);
}
#chat-input .composer-reference {
display: inline;
font-size: 0;
font-weight: 600;
line-height: inherit;
white-space: nowrap;
vertical-align: baseline;
}
#chat-input .composer-reference::before {
display: inline-block;
color: var(--color-highlight);
font-family: 'Material Symbols Outlined';
margin-right: 0.22rem;
font-size: 1rem;
font-weight: normal;
line-height: 1;
vertical-align: -0.1em;
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
}
#chat-input .composer-reference::after {
content: attr(data-label);
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
font-size: 1rem;
line-height: inherit;
vertical-align: baseline;
}
#chat-input .composer-reference.is-folder::before { content: 'folder'; }
#chat-input .composer-reference.is-file::before { content: 'draft'; }
#chat-input .composer-reference.is-agent::before { content: 'person'; }
#chat-input .composer-reference.is-skill::before { content: 'auto_awesome'; }
#chat-input .composer-reference.is-mcp::before { content: 'hub'; }
html:not(.material-icons-ready) #chat-input .composer-reference::before {
visibility: hidden;
}
.commands-slash-item-description { .commands-slash-item-description {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.86rem; font-size: 0.86rem;

View file

@ -90,10 +90,68 @@ if (path.active) throw new Error("path opened the picker");
const resolvable = parseSlashInput("objective /goal"); const resolvable = parseSlashInput("objective /goal");
if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke"); if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke");
const reference = parseReferenceInput("Compare @src/app", 16);
if (!reference.active || reference.query !== "src/app" || reference.start !== 8 || reference.end !== 16) throw new Error("reference token not found");
const middle = parseReferenceInput("Use @src/app then", 12);
if (!middle.active || middle.query !== "src/app") throw new Error("caret-local reference not found");
if (parseReferenceInput("mail@example.test").active) throw new Error("email opened reference picker");
if (parseReferenceInput("Use @[./src/app.py]").active) throw new Error("completed reference reopened picker");
if (fileQueryDirectory("../secret") !== null) throw new Error("parent traversal accepted");
if (fileQueryDirectory("mcp/server") !== null) throw new Error("MCP reference opened file browser");
const mcp = getMcpReferences({{
tools: {{
effective_policy: {{ mode: "custom", mcp_default: "block", allowed: ["mcp:allowed:read"], blocked: ["mcp:blocked:read"] }},
catalog: [
{{ id: "mcp:allowed:read", available: true }},
{{ id: "mcp:blocked:read", available: true }},
{{ id: "mcp:default-blocked:read", available: true }},
{{ id: "mcp:missing:read", available: false }},
],
}},
}});
if (JSON.stringify(mcp) !== JSON.stringify([{{ name: "allowed", toolCount: 1 }}])) throw new Error("MCP policy scope leaked");
""" """
subprocess.run(["node", "-e", script], check=True, text=True) subprocess.run(["node", "-e", script], check=True, text=True)
def test_composer_reference_picker_uses_plain_reference_tokens() -> None:
plugin_root = Path(__file__).resolve().parents[1]
store = (plugin_root / "webui" / "commands-slash-store.js").read_text(encoding="utf-8")
menu = (
plugin_root / "extensions" / "webui" / "chat-input-box-start" / "commands-menu.html"
).read_text(encoding="utf-8")
assert "@[agent/${key}]" in store
assert "@[skill/${name}]" in store
assert "value: `@[${displayPath}]`" in store
assert 'icon: isDirectory ? "folder" : "draft"' in store
assert 'icon: "person"' in store
assert 'icon: "auto_awesome"' in store
assert "skills.filter((skill) => !skill?.hidden)" in store
assert 'icon: "hub"' in store
assert "@[mcp/${name}]" in store
assert 'const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor"' in store
assert 'action: "list", context_id: contextId' in store
assert 'action: "load",' in store
assert "getMcpReferences(mcpResult?.state)" in store
assert 'mcp_servers_status' not in store
assert "composer-reference" in store
assert "node.dataset.label = reference.label" in store
assert 'callJsonApi("/chat_files_path_get"' in store
assert 'callJsonApi("/agents"' not in store
assert "filteredItems" in menu
assert "#chat-input .composer-reference" in menu
assert "content: attr(data-label)" in menu
assert "color: var(--color-highlight)" in menu
assert "color: var(--color-text)" in menu
assert "composer-reference.is-mcp" in menu
assert "background: transparent" in menu
@pytest.fixture @pytest.fixture
def scope_fixture() -> ScopeFixture: def scope_fixture() -> ScopeFixture:
suffix = uuid.uuid4().hex[:8] suffix = uuid.uuid4().hex[:8]

View file

@ -1,5 +1,5 @@
import { createStore } from "/js/AlpineStore.js"; import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js"; import { callJsonApi, fetchApi } from "/js/api.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.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 chatInputStore } from "/components/chat/input/input-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js"; import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
@ -11,6 +11,8 @@ import {
import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js"; import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
const COMMANDS_API_PATH = "/plugins/_commands/commands"; const COMMANDS_API_PATH = "/plugins/_commands/commands";
const SKILLS_API_PATH = "/plugins/_skills/skills_catalog";
const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor";
function sanitizeCommandName(rawName) { function sanitizeCommandName(rawName) {
return (rawName || "") return (rawName || "")
@ -45,6 +47,58 @@ function parseSlashInput(message, allowPostfix = true) {
}; };
} }
function parseReferenceInput(message, caretOffset = undefined) {
const text = String(message || "");
if (caretOffset === null) return { active: false, query: "", start: 0, end: 0 };
const caret = Math.max(0, Math.min(text.length, caretOffset ?? text.length));
const match = text.slice(0, caret).match(/(?:^|\s)@([^\s@]*)$/);
if (!match) return { active: false, query: "", start: caret, end: caret };
if (match[1].startsWith("[") && match[1].endsWith("]")) {
return { active: false, query: "", start: caret, end: caret };
}
const token = `@${match[1]}`;
return {
active: true,
query: match[1].toLowerCase(),
start: caret - token.length,
end: caret,
};
}
function normalizePath(value) {
return String(value || "").replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/\/$/, "");
}
function fileQueryDirectory(query) {
const value = String(query || "").replace(/^\.\//, "");
if (value.startsWith("agent/") || value.startsWith("skill/") || value.startsWith("mcp/") || value.split("/").includes("..")) {
return null;
}
const slash = value.lastIndexOf("/");
return slash < 0 ? "" : value.slice(0, slash);
}
function mcpPolicyAllows(policy, id) {
if (!policy || policy.mode !== "custom") return true;
if (policy.blocked?.includes(id)) return false;
if (policy.allowed?.includes(id)) return true;
return policy.mcp_default === "allow";
}
function getMcpReferences(state) {
const servers = new Map();
const policy = state?.tools?.effective_policy;
for (const tool of state?.tools?.catalog || []) {
const id = String(tool?.id || "");
const match = id.match(/^mcp:([^:]+):/);
if (!match || tool?.available === false || !mcpPolicyAllows(policy, id)) continue;
const name = match[1];
servers.set(name, (servers.get(name) || 0) + 1);
}
return [...servers].map(([name, toolCount]) => ({ name, toolCount }));
}
function notifyError(message) { function notifyError(message) {
void toastFrontendError(message, "Commands"); void toastFrontendError(message, "Commands");
} }
@ -74,6 +128,13 @@ const model = {
loading: false, loading: false,
applying: false, applying: false,
commands: [], commands: [],
references: [],
referenceContextId: null,
referenceDirectoryKey: "",
referenceRoot: "",
referenceCatalog: [],
referenceFiles: [],
referenceLoadGeneration: 0,
contextScope: { project_name: "" }, contextScope: { project_name: "" },
lastContextId: "", lastContextId: "",
active: false, active: false,
@ -81,6 +142,10 @@ const model = {
query: "", query: "",
rawArguments: "", rawArguments: "",
rawMessage: "", rawMessage: "",
mode: "",
referenceStart: 0,
referenceEnd: 0,
referenceRange: null,
selectedIndex: 0, selectedIndex: 0,
boundInput: null, boundInput: null,
keydownHandler: null, keydownHandler: null,
@ -104,12 +169,37 @@ const model = {
}); });
}, },
get filteredReferences() {
const needle = (this.query || "").trim().toLowerCase().replace(/^\.\//, "");
const references = Array.isArray(this.references) ? this.references : [];
if (!needle) return references;
return references.filter((reference) => reference.search.includes(needle));
},
get filteredItems() {
return this.mode === "reference" ? this.filteredReferences : this.filteredCommands;
},
get selectedCommand() { get selectedCommand() {
const commands = this.filteredCommands; const commands = this.filteredCommands;
if (!commands.length) return null; if (!commands.length) return null;
return commands[this.selectedIndex] || commands[0] || null; return commands[this.selectedIndex] || commands[0] || null;
}, },
get selectedItem() {
const items = this.filteredItems;
if (!items.length) return null;
return items[this.selectedIndex] || items[0] || null;
},
get loadingLabel() {
return this.mode === "reference" ? "Loading references..." : "Loading slash commands...";
},
get emptyLabel() {
return this.mode === "reference" ? "No matching references." : "No matching slash commands.";
},
get emptyStateLabel() { get emptyStateLabel() {
const name = sanitizeCommandName(this.query || ""); const name = sanitizeCommandName(this.query || "");
return name ? `Create /${name}` : "Create slash command"; return name ? `Create /${name}` : "Create slash command";
@ -146,6 +236,15 @@ const model = {
this.query = ""; this.query = "";
this.rawArguments = ""; this.rawArguments = "";
this.rawMessage = ""; this.rawMessage = "";
this.mode = "";
this.referenceRange = null;
this.references = [];
this.referenceContextId = null;
this.referenceDirectoryKey = "";
this.referenceRoot = "";
this.referenceCatalog = [];
this.referenceFiles = [];
this.referenceLoadGeneration += 1;
this.selectedIndex = 0; this.selectedIndex = 0;
this.applying = false; this.applying = false;
}, },
@ -233,13 +332,197 @@ const model = {
} }
}, },
getCaretOffset() {
const input = this.getInputElement();
const selection = document.getSelection?.();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (range && chatInputStore?._isInCodeBlock?.(range.startContainer?.parentElement)) return null;
const offsets = chatInputStore?._selectionOffsets?.(input);
return offsets && offsets.start === offsets.end ? offsets.end : null;
},
captureReferenceRange(length) {
const input = this.getInputElement();
const selection = document.getSelection?.();
if (!input || !selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (
!range.collapsed ||
range.startContainer?.nodeType !== Node.TEXT_NODE ||
range.startOffset < length ||
!input.contains(range.startContainer)
) return null;
const triggerRange = range.cloneRange();
triggerRange.setStart(range.startContainer, range.startOffset - length);
return triggerRange;
},
async loadReferences(force = false) {
const contextId = this.getContextId();
const directory = fileQueryDirectory(this.query);
const generation = ++this.referenceLoadGeneration;
this.loading = true;
try {
if (force || contextId !== this.referenceContextId) {
const [rootResult, settingsResult, skillsResult, profilesResult] = await Promise.allSettled([
contextId ? callJsonApi("/chat_files_path_get", { ctxid: contextId }) : Promise.resolve(null),
callJsonApi("settings_get", null),
callJsonApi(SKILLS_API_PATH, { action: "list", context_id: contextId }),
callJsonApi(AGENT_EDITOR_API_PATH, { action: "list", context_id: contextId }),
]);
if (generation !== this.referenceLoadGeneration) return;
this.referenceRoot = normalizePath(
rootResult.value?.path || settingsResult.value?.settings?.workdir_path || "",
);
const skills = skillsResult.value?.ok && Array.isArray(skillsResult.value.skills)
? skillsResult.value.skills
: [];
const profiles = profilesResult.value?.ok && Array.isArray(profilesResult.value.profiles)
? profilesResult.value.profiles
: [];
const activeProfile = String(
chatsStore.selectedContext?.agent_profile
|| settingsResult.value?.settings?.agent_profile
|| "",
).trim();
const activeProfileAvailable = profiles.some((profile) => (
profile?.id === activeProfile && profile?.enabled && profile?.available
));
const mcpResult = activeProfileAvailable
? await callJsonApi(AGENT_EDITOR_API_PATH, {
action: "load",
profile_id: activeProfile,
context_id: contextId,
}).catch((error) => {
console.error("Failed to load scoped MCP references:", error);
return null;
})
: null;
if (generation !== this.referenceLoadGeneration) return;
const mcpServers = getMcpReferences(mcpResult?.state);
this.referenceCatalog = [
...profiles.filter((profile) => (
profile?.id !== "default" && profile?.enabled && profile?.available
)).map((profile) => {
const key = String(profile?.id || "").trim();
const label = String(profile?.title || key).trim();
return {
id: `agent:${key}`,
kind: "Agent",
icon: "person",
tone: "agent",
label,
value: `@[agent/${key}]`,
description: key === label ? "Agent profile" : `Agent profile · ${key}`,
search: `agent/${key} ${label}`.toLowerCase(),
};
}).filter((item) => item.id !== "agent:"),
...skills.filter((skill) => !skill?.hidden).map((skill) => {
const name = String(skill?.name || "").trim();
return {
id: `skill:${String(skill?.path || name)}`,
kind: "Skill",
icon: "auto_awesome",
tone: "skill",
label: name,
value: `@[skill/${name}]`,
description: String(skill?.description || "Skill").trim(),
search: `skill/${name} ${skill?.description || ""} ${skill?.path || ""}`.toLowerCase(),
};
}).filter((item) => item.label),
...mcpServers.map((server) => {
const name = String(server?.name || "").trim();
const description = `${Number(server?.toolCount || 0)} available MCP tools`;
return {
id: `mcp:${name}`,
kind: "MCP",
icon: "hub",
tone: "mcp",
label: name,
value: `@[mcp/${name}]`,
description,
search: `mcp/${name} ${name} ${description}`.toLowerCase(),
};
}).filter((item) => item.label),
];
this.referenceFiles = [];
this.referenceContextId = contextId;
this.referenceDirectoryKey = "";
}
const directoryKey = directory === null || !this.referenceRoot
? ""
: `${this.referenceRoot}/${directory}`.replace(/\/$/, "");
if (directory !== null && directoryKey && directoryKey !== this.referenceDirectoryKey) {
const response = await fetchApi(`/get_work_dir_files?path=${encodeURIComponent(directoryKey)}`);
const payload = await response.json().catch(() => ({}));
if (generation !== this.referenceLoadGeneration) return;
const entries = response.ok && Array.isArray(payload?.data?.entries) ? payload.data.entries : [];
const root = this.referenceRoot.replace(/^\//, "");
this.referenceFiles = entries.flatMap((entry) => {
const path = normalizePath(entry?.path).replace(/^\//, "");
if (!path || (path !== root && !path.startsWith(`${root}/`))) return [];
const relative = path === root ? "" : path.slice(root.length + 1);
if (!relative) return [];
const isDirectory = Boolean(entry?.is_dir);
const displayPath = `./${relative}${isDirectory ? "/" : ""}`;
return [{
id: `${isDirectory ? "folder" : "file"}:${path}`,
kind: isDirectory ? "Folder" : "File",
icon: isDirectory ? "folder" : "draft",
tone: isDirectory ? "folder" : "file",
label: displayPath,
value: `@[${displayPath}]`,
description: isDirectory ? "Folder in active workspace" : "File in active workspace",
search: displayPath.toLowerCase(),
}];
});
this.referenceDirectoryKey = directoryKey;
} else if (directory === null) {
this.referenceFiles = [];
this.referenceDirectoryKey = "";
}
if (generation === this.referenceLoadGeneration) {
this.references = [...this.referenceFiles, ...this.referenceCatalog];
this.ensureSelection();
}
} catch (error) {
console.error("Failed to load composer references:", error);
if (generation === this.referenceLoadGeneration) {
this.references = [...this.referenceCatalog];
}
} finally {
if (generation === this.referenceLoadGeneration) this.loading = false;
}
},
handleInput(event = null) { handleInput(event = null) {
this.ensureBindings(); this.ensureBindings();
this.dismissed = false; this.dismissed = false;
const message = this.getInputMessage(event); const message = this.getInputMessage(event);
const reference = parseReferenceInput(message, this.getCaretOffset());
if (reference.active) {
const newReferenceSession = this.mode !== "reference";
this.mode = "reference";
this.active = true;
this.query = reference.query;
this.rawMessage = message;
this.referenceStart = reference.start;
this.referenceEnd = reference.end;
this.referenceRange = this.captureReferenceRange(reference.end - reference.start);
this.ensureSelection();
void this.loadReferences(newReferenceSession);
return;
}
const parsed = parseSlashInput(message, false); const parsed = parseSlashInput(message, false);
this.referenceRange = null;
this.mode = parsed.active ? "slash" : "";
this.active = parsed.active; this.active = parsed.active;
this.query = parsed.query; this.query = parsed.query;
this.rawArguments = parsed.rawArguments; this.rawArguments = parsed.rawArguments;
@ -297,33 +580,79 @@ const model = {
return; return;
} }
if (event.key === "Enter" && this.selectedCommand) { if (event.key === "Enter" && this.selectedItem) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
void this.applySelection(this.selectedCommand); void this.applySelectedItem(this.selectedItem);
} }
}, },
ensureSelection() { ensureSelection() {
const commands = this.filteredCommands; const items = this.filteredItems;
if (!commands.length) { if (!items.length) {
this.selectedIndex = 0; this.selectedIndex = 0;
return; return;
} }
if (this.selectedIndex >= commands.length) { if (this.selectedIndex >= items.length) {
this.selectedIndex = 0; this.selectedIndex = 0;
} }
}, },
moveSelection(delta) { moveSelection(delta) {
const commands = this.filteredCommands; const items = this.filteredItems;
if (!commands.length) return; if (!items.length) return;
const nextIndex = const nextIndex =
(this.selectedIndex + delta + commands.length) % commands.length; (this.selectedIndex + delta + items.length) % items.length;
this.selectedIndex = nextIndex; this.selectedIndex = nextIndex;
this.scrollSelectedIntoView(); this.scrollSelectedIntoView();
}, },
applySelectedItem(item) {
return this.mode === "reference" ? this.applyReference(item) : this.applySelection(item);
},
applyReference(reference) {
const input = this.getInputElement();
if (!reference?.value || !input) return;
const current = this.getInputMessage();
const suffix = current.slice(this.referenceEnd);
const separator = suffix && /^\s/.test(suffix) ? "" : " ";
const nextText = `${current.slice(0, this.referenceStart)}${reference.value}${separator}${suffix}`;
const caret = this.referenceStart + reference.value.length + separator.length;
const range = this.referenceRange;
this.referenceRange = null;
if (range && input.contains(range.startContainer)) {
range.deleteContents();
const node = document.createElement("span");
node.className = `composer-reference is-${reference.tone}`;
node.dataset.reference = reference.value;
node.dataset.label = reference.label;
node.contentEditable = "false";
node.textContent = reference.value;
node.setAttribute("aria-label", `${reference.kind}: ${reference.label}`);
range.insertNode(node);
const space = separator ? document.createTextNode(separator) : null;
if (space) node.after(space);
range.setStartAfter(space || node);
range.collapse(true);
const selection = document.getSelection?.();
selection?.removeAllRanges();
selection?.addRange(range);
chatInputStore?._syncMessageFromEditor?.();
} else {
chatInputStore.message = nextText;
chatInputStore?._setEditorCaret?.(caret);
}
input.dispatchEvent(new Event("input", { bubbles: true }));
chatInputStore.adjustTextareaHeight();
this.active = false;
this.dismissed = false;
this.mode = "";
this.query = "";
this.selectedIndex = 0;
},
scrollSelectedIntoView() { scrollSelectedIntoView() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
document document

View file

@ -14,6 +14,7 @@
## Local Contracts ## Local Contracts
- Preserve session startup, cleanup, and route protection for desktop access. - Preserve session startup, cleanup, and route protection for desktop access.
- Keep the Xpra server, client modules, and GTK introspection runtime present as one compatible stack; Xpra shadow sessions import all three even when users connect only through HTML5.
- 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.

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
import subprocess import subprocess
import tempfile
import threading import threading
import time import time
import urllib.request import urllib.request
@ -22,8 +23,22 @@ LIBREOFFICE_RUNTIME_PACKAGES = (
XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources") XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc") XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
XPRA_KEY_URL = "https://xpra.org/xpra.asc" XPRA_KEY_URL = "https://xpra.org/xpra.asc"
XPRA_VERSION = "6.5.2-r0-1"
GTK_RUNTIME_PACKAGE = "gir1.2-gtk-3.0"
KALI_ROLLING_SOURCE = "deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware\n"
XPRA_VERSIONED_RUNTIME_PACKAGES = frozenset(
{
"xpra-common",
"xpra-server",
"xpra-client",
"xpra-client-gtk3",
"xpra-x11",
}
)
RUNTIME_PACKAGES = ( RUNTIME_PACKAGES = (
*LIBREOFFICE_RUNTIME_PACKAGES, *LIBREOFFICE_RUNTIME_PACKAGES,
GTK_RUNTIME_PACKAGE,
"xpra-common",
"xpra-server", "xpra-server",
"xpra-client", "xpra-client",
"xpra-client-gtk3", "xpra-client-gtk3",
@ -54,10 +69,6 @@ RUNTIME_PACKAGES = (
"fonts-noto-cjk", "fonts-noto-cjk",
"fonts-noto-color-emoji", "fonts-noto-color-emoji",
) )
OPTIONAL_RUNTIME_PACKAGES = (
"xpra-client",
"xpra-client-gtk3",
)
RETIRED_RUNTIME_PACKAGES = ( RETIRED_RUNTIME_PACKAGES = (
"firefox-esr", "firefox-esr",
) )
@ -202,6 +213,17 @@ def _package_installed(package: str) -> bool:
return result.returncode == 0 and "install ok installed" in result.stdout return result.returncode == 0 and "install ok installed" in result.stdout
def _package_version(package: str) -> str:
result = subprocess.run(
["dpkg-query", "-W", "-f=${Version}", package],
check=False,
text=True,
capture_output=True,
timeout=8,
)
return result.stdout.strip() if result.returncode == 0 else ""
def _purge_packages( def _purge_packages(
removed: list[str], removed: list[str],
errors: list[str], errors: list[str],
@ -223,6 +245,8 @@ def _purge_packages(
def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None: def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"): if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
return return
if not _ensure_kali_gtk_runtime(installed, errors):
return
missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)] missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
if not missing: if not missing:
return return
@ -230,8 +254,7 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
if not _apt_update(errors): if not _apt_update(errors):
return return
required_missing, optional_missing = _split_runtime_packages(missing) required_xpra_missing = [package for package in missing if package.startswith("xpra")]
required_xpra_missing = [package for package in required_missing if package.startswith("xpra")]
if required_xpra_missing and not _package_candidates_available(required_xpra_missing): if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
previous_error_count = len(errors) previous_error_count = len(errors)
_ensure_xpra_repository(installed, errors) _ensure_xpra_repository(installed, errors)
@ -240,47 +263,72 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)] missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
if not missing: if not missing:
return return
required_missing, optional_missing = _split_runtime_packages(missing)
if required_missing and not _install_runtime_packages(required_missing, installed, errors): if missing:
return _install_runtime_packages(missing, installed, errors)
if optional_missing:
optional_xpra_missing = [package for package in optional_missing if package.startswith("xpra")]
if optional_xpra_missing and not _package_candidates_available(optional_xpra_missing):
return
_install_runtime_packages(optional_missing, installed, errors, optional=True)
def _split_runtime_packages(packages: list[str]) -> tuple[list[str], list[str]]: def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool:
optional = [package for package in packages if package in OPTIONAL_RUNTIME_PACKAGES] if (
required = [package for package in packages if package not in OPTIONAL_RUNTIME_PACKAGES] GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES
return required, optional or _package_installed(GTK_RUNTIME_PACKAGE)
or _read_os_release().get("ID") != "kali"
):
return True
with tempfile.TemporaryDirectory(prefix="a0-desktop-apt-") as directory:
source = Path(directory) / "kali-rolling.list"
source.write_text(KALI_ROLLING_SOURCE, encoding="utf-8")
options = [
"-o",
f"Dir::Etc::sourcelist={source}",
"-o",
"Dir::Etc::sourceparts=-",
]
result = _run_apt_command(["apt-get", *options, "update"], timeout=300)
if result.returncode == 0:
result = _run_apt_command(
[
"apt-get",
*options,
"install",
"-y",
"--no-install-recommends",
GTK_RUNTIME_PACKAGE,
],
timeout=900,
)
if result.returncode == 0:
installed.append(GTK_RUNTIME_PACKAGE)
return True
errors.append((result.stderr or result.stdout or "GTK runtime install failed").strip())
return False
def _install_runtime_packages( def _install_runtime_packages(
packages: list[str], packages: list[str],
installed: list[str], installed: list[str],
errors: list[str], errors: list[str],
*,
optional: bool = False,
) -> bool: ) -> bool:
result = _run_apt_command(["apt-get", "install", "-y", "--no-install-recommends", *packages], timeout=900) xpra_version = ""
if any(package in XPRA_VERSIONED_RUNTIME_PACKAGES for package in packages):
xpra_version = _package_version("xpra-common") or XPRA_VERSION
package_specs = [
f"{package}={xpra_version}" if package in XPRA_VERSIONED_RUNTIME_PACKAGES else package
for package in packages
]
result = _run_apt_command(
["apt-get", "install", "-y", "--no-install-recommends", *package_specs],
timeout=900,
)
if result.returncode == 0: if result.returncode == 0:
installed.extend(packages) installed.extend(packages)
return True return True
output = (result.stderr or result.stdout or "apt-get install failed").strip() output = (result.stderr or result.stdout or "apt-get install failed").strip()
if optional and _is_xpra_codec_dependency_gap(output):
return False
errors.append(output) errors.append(output)
return False return False
def _is_xpra_codec_dependency_gap(output: str) -> bool:
normalized = output.lower()
return "xpra-codecs" in normalized and "libvpx9" in normalized
def _apt_update(errors: list[str]) -> bool: def _apt_update(errors: list[str]) -> bool:
result = _run_apt_command(["apt-get", "update"], timeout=300) result = _run_apt_command(["apt-get", "update"], timeout=300)
if result.returncode == 0: if result.returncode == 0:
@ -359,21 +407,8 @@ def _xpra_repository_source() -> str:
codename = os_release.get("VERSION_CODENAME", "") codename = os_release.get("VERSION_CODENAME", "")
arch = _dpkg_architecture() arch = _dpkg_architecture()
if os_id == "kali" and arch == "amd64": uri = "https://xpra.org"
uri = "https://xpra.org/beta" suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie"
suite = "sid"
elif os_id == "kali":
uri = "https://xpra.org"
suite = "trixie"
elif codename in {"sid", "forky"} and arch == "amd64":
uri = "https://xpra.org/beta"
suite = codename
elif codename in {"sid", "forky"}:
uri = "https://xpra.org"
suite = "trixie"
else:
uri = "https://xpra.org"
suite = codename or "trixie"
return ( return (
f"Types: deb\n" f"Types: deb\n"

View file

@ -11,7 +11,7 @@
- `tools/goal.py` owns the single agent-facing goal tool, file-backed state under `usr/plugins/_goal/goals/`, and goal status normalization. - `tools/goal.py` owns the single agent-facing goal tool, file-backed state 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. - `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`. - `commands/` owns the `/goal` slash command contributed to `_commands`.
- `webui/` and `extensions/webui/` own the composer goal strip and inline controls. - `webui/` and `extensions/webui/` own the composer goal strip, Goal mode shortcut, and inline controls.
- `tools/goal.py` and `prompts/agent.system.tool.goal.md` own agent-facing goal inspection, creation, and status updates. - `tools/goal.py` and `prompts/agent.system.tool.goal.md` own agent-facing goal inspection, creation, and status updates.
- `tools/response.py` overrides the core response tool so an active goal continues the current monologue. - `tools/response.py` overrides the core response tool so an active goal continues the current monologue.
- `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context. - `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
@ -24,6 +24,7 @@
- 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. - 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.
- Saving an edit that reactivates a complete or blocked goal resends the edited objective so agent processing resumes. - Saving an edit that reactivates a complete or blocked goal resends the edited objective so agent processing resumes.
- `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately. - `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately.
- The composer Goal mode shortcut only prefills `/goal ` and focuses the input; it never sends the command.
- `/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 auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
- While a goal is active, response-tool calls are intermediate updates; only completing or blocking the goal restores normal loop termination. - While a goal is active, response-tool calls are intermediate updates; only completing or blocking the goal restores normal loop termination.
- Goal UI feedback uses toast notifications and inline controls, not modal dialogs. - Goal UI feedback uses toast notifications and inline controls, not modal dialogs.

View file

@ -0,0 +1,61 @@
import { store as chatInputStore } from "/components/chat/input/input-store.js";
const MENU_SELECTOR = ".chat-bottom-actions-menu";
const BUTTON_ID = "goal-chat-more-item";
function buildButton() {
const button = document.createElement("button");
button.type = "button";
button.className = "chat-bottom-menu-item";
button.id = BUTTON_ID;
button.innerHTML = `
<x-icon aria-hidden="true" name="track_changes"></x-icon>
<span>Goal mode</span>
`;
button.addEventListener("click", () => {
chatInputStore.closeChatMoreMenu();
chatInputStore.message = "/goal ";
chatInputStore.adjustTextareaHeight();
chatInputStore.focus();
chatInputStore._setEditorCaret?.(chatInputStore.message.length);
});
return button;
}
function injectButton(menu) {
if (!(menu instanceof HTMLElement)) return;
if (menu.querySelector(`#${BUTTON_ID}`)) return;
menu.appendChild(buildButton());
}
function scan(root = document) {
for (const menu of root.querySelectorAll(MENU_SELECTOR)) {
injectButton(menu);
}
}
export default async function initGoalMenuInjector() {
scan();
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches?.(MENU_SELECTOR)) {
injectButton(node);
continue;
}
if (node.querySelectorAll) scan(node);
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}

View file

@ -111,6 +111,21 @@ def test_goal_webui_uses_state_revisions_instead_of_polling():
assert "goalStore.refresh(true)" in refresh assert "goalStore.refresh(true)" in refresh
def test_goal_composer_menu_prefills_without_sending():
plugin_root = Path(__file__).resolve().parents[1]
injector = (
plugin_root
/ "extensions"
/ "webui"
/ "initFw_end"
/ "goal-menu-injector.js"
).read_text()
assert 'chatInputStore.message = "/goal ";' in injector
assert "chatInputStore.focus();" in injector
assert "sendMessage" not in injector
@pytest.mark.skipif(not shutil.which("node"), reason="node is required") @pytest.mark.skipif(not shutil.which("node"), reason="node is required")
def test_goal_webui_uses_shared_hour_aware_duration_formatter(): def test_goal_webui_uses_shared_hour_aware_duration_formatter():
project_root = Path(__file__).resolve().parents[3] project_root = Path(__file__).resolve().parents[3]

View file

@ -46,6 +46,8 @@
- Codex Responses proxy requests must include Codex client metadata and compatibility headers such as `client_metadata`, `x-codex-installation-id`, `originator`, `session-id`, and `thread-id`, and must forward `input` as a list for upstream Codex compatibility. - Codex Responses proxy requests must include Codex client metadata and compatibility headers such as `client_metadata`, `x-codex-installation-id`, `originator`, `session-id`, and `thread-id`, and must forward `input` as a list for upstream Codex compatibility.
- Codex Responses proxy requests must translate the legacy top-level `reasoning_effort` field to `reasoning.effort`; an explicit native `reasoning` field takes precedence. - Codex Responses proxy requests must translate the legacy top-level `reasoning_effort` field to `reasoning.effort`; an explicit native `reasoning` field takes precedence.
- Codex Responses proxy defaults for reasoning effort, reasoning summary, and text verbosity come from the `codex` plugin config; explicit native request values take precedence. - Codex Responses proxy defaults for reasoning effort, reasoning summary, and text verbosity come from the `codex` plugin config; explicit native request values take precedence.
- Codex request shaping tightens an already-advertised native `response` tool to a strict required `text` schema; it must not add tools omitted by the framework tool policy.
- Non-streaming Codex proxy responses must retain completed SSE output items when the final `response.completed` envelope omits them.
- OAuth providers without upstream Responses support must set `a0_api_mode: chat`; native Responses providers rely on the default, since a local proxy route alone does not prove upstream support. - OAuth providers without upstream Responses support must set `a0_api_mode: chat`; native Responses providers rely on the default, since a local proxy route alone does not prove upstream support.
## Work Guidance ## Work Guidance

View file

@ -670,6 +670,25 @@ def fetch_models() -> list[str]:
def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]: def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]:
normalized = dict(body) normalized = dict(body)
settings = codex_config() settings = codex_config()
tools = normalized.get("tools")
if isinstance(tools, list):
normalized["tools"] = [
{
**tool,
"strict": True,
"parameters": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
"additionalProperties": False,
},
}
if isinstance(tool, dict)
and tool.get("type") == "function"
and tool.get("name") == "response"
else tool
for tool in tools
]
reasoning_effort = normalized.pop("reasoning_effort", None) reasoning_effort = normalized.pop("reasoning_effort", None)
reasoning = normalized.get("reasoning") reasoning = normalized.get("reasoning")
if isinstance(reasoning, dict): if isinstance(reasoning, dict):
@ -775,6 +794,7 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
latest_error: Any = None latest_error: Any = None
text_pieces: list[str] = [] text_pieces: list[str] = []
latest_usage: dict[str, Any] | None = None latest_usage: dict[str, Any] | None = None
completed_items: dict[int, dict[str, Any]] = {}
for event in iter_sse_events(response): for event in iter_sse_events(response):
data = event.get("data") data = event.get("data")
if not data: if not data:
@ -789,6 +809,11 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
latest_error = parsed latest_error = parsed
continue continue
text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", ""))) text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", "")))
if (parsed.get("type") or event.get("event")) == "response.output_item.done":
output_index = parsed.get("output_index")
item = parsed.get("item")
if isinstance(output_index, int) and isinstance(item, dict):
completed_items[output_index] = item
usage = parsed.get("usage") usage = parsed.get("usage")
if isinstance(usage, dict): if isinstance(usage, dict):
latest_usage = usage latest_usage = usage
@ -796,6 +821,16 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
if isinstance(candidate, dict): if isinstance(candidate, dict):
latest_response = candidate latest_response = candidate
if (
latest_response is not None
and completed_items
and not latest_response.get("output")
):
latest_response = dict(latest_response)
latest_response["output"] = [
completed_items[index] for index in sorted(completed_items)
]
if text_pieces: if text_pieces:
text = "".join(text_pieces) text = "".join(text_pieces)
if latest_response is not None: if latest_response is not None:

View file

@ -16,6 +16,8 @@
- Keep dependency installation and model bootstrap on Docker/bootstrap paths. - Keep dependency installation and model bootstrap on Docker/bootstrap paths.
- Preserve configured message mode, language, silence threshold, silence duration, and waiting timeout behavior. - Preserve configured message mode, language, silence threshold, silence duration, and waiting timeout behavior.
- Keep all `data-whisper-microphone` controls synchronized with the same microphone status.
- Optional final-transcript handlers must receive the configured draft/send mode without changing the main chat microphone's delivery behavior.
- Do not expose raw audio or transcriptions beyond intended UI/API paths. - Do not expose raw audio or transcriptions beyond intended UI/API paths.
## Work Guidance ## Work Guidance

View file

@ -89,6 +89,7 @@
<button <button
class="chat-button mic-inactive" class="chat-button mic-inactive"
id="microphone-button" id="microphone-button"
data-whisper-microphone
aria-label="Start/Stop recording" aria-label="Start/Stop recording"
@click="$store.whisperStt.handleMicrophoneClick()" @click="$store.whisperStt.handleMicrophoneClick()"
x-init="$store.whisperStt.updateMicrophoneButtonUI()" x-init="$store.whisperStt.updateMicrophoneButtonUI()"

View file

@ -66,6 +66,7 @@ const model = {
packageVersion: "", packageVersion: "",
providerCleanup: null, providerCleanup: null,
microphoneInput: null, microphoneInput: null,
finalTranscriptHandler: null,
isProcessingClick: false, isProcessingClick: false,
devices: [], devices: [],
selectedDevice: "", selectedDevice: "",
@ -176,24 +177,26 @@ const model = {
}, },
updateMicrophoneButtonUI() { updateMicrophoneButtonUI() {
const microphoneButton = document.getElementById("microphone-button");
if (!microphoneButton) return;
const status = this.enabled ? this.micStatus : "disabled"; const status = this.enabled ? this.micStatus : "disabled";
const label = MicStatusLabels[status] || "Microphone"; const label = MicStatusLabels[status] || "Microphone";
clearMicrophoneTooltip(microphoneButton); const microphoneButtons = document.querySelectorAll(
microphoneButton.classList.remove(...MicButtonClasses); "[data-whisper-microphone], #microphone-button",
microphoneButton.classList.add(`mic-${status}`);
microphoneButton.setAttribute("data-status", status);
microphoneButton.setAttribute("aria-label", label);
microphoneButton.setAttribute(
"aria-pressed",
String(
status !== "disabled" &&
status !== Status.INACTIVE &&
status !== Status.ACTIVATING,
),
); );
for (const microphoneButton of microphoneButtons) {
clearMicrophoneTooltip(microphoneButton);
microphoneButton.classList.remove(...MicButtonClasses);
microphoneButton.classList.add(`mic-${status}`);
microphoneButton.setAttribute("data-status", status);
microphoneButton.setAttribute("aria-label", label);
microphoneButton.setAttribute(
"aria-pressed",
String(
status !== "disabled" &&
status !== Status.INACTIVE &&
status !== Status.ACTIVATING,
),
);
}
}, },
async loadDevices() { async loadDevices() {
@ -268,13 +271,16 @@ const model = {
} }
}, },
async handleMicrophoneClick() { async handleMicrophoneClick(finalTranscriptHandler = null) {
if (this.isProcessingClick) return; if (this.isProcessingClick) return;
this.finalTranscriptHandler =
typeof finalTranscriptHandler === "function" ? finalTranscriptHandler : null;
this.isProcessingClick = true; this.isProcessingClick = true;
try { try {
await this.ensureStatusLoaded({ force: true, suppressError: false }); await this.ensureStatusLoaded({ force: true, suppressError: false });
if (!this.enabled) { if (!this.enabled) {
this.finalTranscriptHandler = null;
globalThis.justToast?.("Whisper STT is disabled.", "info"); globalThis.justToast?.("Whisper STT is disabled.", "info");
return; return;
} }
@ -309,7 +315,7 @@ const model = {
const input = new MicrophoneInput(this, async (text, isFinal) => { const input = new MicrophoneInput(this, async (text, isFinal) => {
if (isFinal) { if (isFinal) {
await this.sendVoiceMessage(text); await this.deliverVoiceMessage(text);
} }
}); });
@ -318,6 +324,17 @@ const model = {
return this.microphoneInput; return this.microphoneInput;
}, },
async deliverVoiceMessage(text) {
if (this.finalTranscriptHandler) {
await this.finalTranscriptHandler(text, {
messageMode: this.config.message_mode,
sendImmediately: this.sendsImmediately,
});
return;
}
await this.sendVoiceMessage(text);
},
async sendVoiceMessage(text) { async sendVoiceMessage(text) {
const message = String(text || "").trim(); const message = String(text || "").trim();
if (!message) return; if (!message) return;
@ -347,6 +364,8 @@ const model = {
this.microphoneInput = null; this.microphoneInput = null;
} }
this.finalTranscriptHandler = null;
this.notifyStatusChange(); this.notifyStatusChange();
}, },

View file

@ -1,9 +1,11 @@
### call_subordinate ### call_subordinate
delegate research or complex subtasks to a specialized agent. delegate research or complex subtasks to a specialized agent.
args: `message`, optional `profile`, `reset` args: `message`, optional `profile`, `reset`, `context_id`
- `profile`: optional prompt profile key for the subordinate; when provided, it must exactly match an available profile; leave empty for the default profile - `profile`: optional prompt profile key for the subordinate; when provided, it must exactly match an available profile; leave empty for the default profile
- `reset`: use json boolean `true` for the first message or when changing profile; use `false` to continue - `reset`: use json boolean `true` to create a fresh child; use `false` to continue the default child or the supplied `context_id`
- `context_id`: stable child ID returned by an earlier direct or parallel call; use it with `reset: false` to continue that exact child
- `message`: define role, goal, and the concrete task - `message`: define role, goal, and the concrete task
each caller creates its next agent level: A0 creates A1 children, A1 creates A2 children, and so on
after the subordinate returns, answer from its result directly when it satisfies the user request after the subordinate returns, answer from its result directly when it satisfies the user request
do not repeat the same solving work or call extra tools after a sufficient subordinate result do not repeat the same solving work or call extra tools after a sufficient subordinate result
example: example:

View file

@ -10,7 +10,7 @@ Rules:
- never nest `parallel` - never nest `parallel`
- Never include `document_query` in `tool_calls`; it is too heavy for parallel workers, so call it sequentially. - Never include `document_query` in `tool_calls`; it is too heavy for parallel workers, so call it sequentially.
- Call `response` only as a top-level tool so it ends the message loop; never wrap it inside `parallel.tool_calls`. - Call `response` only as a top-level tool so it ends the message loop; never wrap it inside `parallel.tool_calls`.
- `call_subordinate` inside `parallel` starts an isolated child chat under the parent chat, not a scheduler task - `call_subordinate` uses the same child lifecycle here as it does top-level; fresh siblings are next-level agents, and each job's `context_id` can be continued later with `reset: false`
- use `wait: false` only when you will collect results later with `job_ids` - use `wait: false` only when you will collect results later with `job_ids`
- if extras list running or ready parallel jobs, collect them before final synthesis - if extras list running or ready parallel jobs, collect them before final synthesis
- `timeout` only limits how long this call waits; running jobs continue and can be awaited again by `job_ids` - `timeout` only limits how long this call waits; running jobs continue and can be awaited again by `job_ids`

View file

@ -26,7 +26,7 @@ markdown==3.7
mcp==1.27.0 mcp==1.27.0
newspaper3k==0.2.8 newspaper3k==0.2.8
paramiko==3.5.0 paramiko==3.5.0
playwright==1.52.0 patchright==1.61.2
pypdf==6.0.0 pypdf==6.0.0
python-dotenv==1.1.0 python-dotenv==1.1.0
pytz==2024.2 pytz==2024.2

View file

@ -4,7 +4,9 @@ import zipfile
from pathlib import Path from pathlib import Path
import pytest import pytest
from dotenv import dotenv_values
from helpers import dotenv
from helpers.backup import BackupService from helpers.backup import BackupService
@ -38,6 +40,73 @@ async def test_default_backup_patterns_exclude_time_travel_history(tmp_path):
assert f"{root}/usr/.time_travel/**" in metadata["exclude_patterns"] assert f"{root}/usr/.time_travel/**" in metadata["exclude_patterns"]
@pytest.mark.parametrize(
("backup_credentials", "expected_credentials"),
[
(
"AUTH_LOGIN=backup\nAUTH_PASSWORD=backup-password\n",
{"AUTH_LOGIN": "backup", "AUTH_PASSWORD": "backup-password"},
),
(
"AUTH_LOGIN=\nAUTH_PASSWORD=\n",
{"AUTH_LOGIN": "", "AUTH_PASSWORD": ""},
),
("", {}),
],
ids=("credentials", "blank-credentials", "missing-credentials"),
)
@pytest.mark.asyncio
async def test_restore_preserves_destination_origin_and_restores_backup_credentials(
tmp_path, monkeypatch, backup_credentials, expected_credentials
):
old_root = "/old-a0"
destination_root = tmp_path / "a0"
destination_env = destination_root / "usr" / ".env"
destination_env.parent.mkdir(parents=True)
destination_env.write_text(
"AUTH_LOGIN=current\n"
"AUTH_PASSWORD=current-password\n"
"ALLOWED_ORIGINS=http://current.example\n",
encoding="utf-8",
)
monkeypatch.setenv("AUTH_LOGIN", "current")
monkeypatch.setenv("AUTH_PASSWORD", "current-password")
monkeypatch.setenv("ALLOWED_ORIGINS", "http://current.example")
monkeypatch.setattr(dotenv, "get_dotenv_file_path", lambda: str(destination_env))
monkeypatch.setattr(
dotenv,
"load_dotenv",
lambda: pytest.fail("restore must not reload unrelated environment values"),
)
zip_path = tmp_path / "backup.zip"
metadata = {
"environment_info": {"agent_zero_root": old_root},
"include_patterns": [f"{old_root}/usr/**"],
"exclude_patterns": [],
"include_hidden": True,
}
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr("metadata.json", json.dumps(metadata))
archive.writestr(
"old-a0/usr/.env",
f"{backup_credentials}ALLOWED_ORIGINS=http://backup.example\n"
"PORTABLE_SETTING=restored\n",
)
service = BackupService()
service.agent_zero_root = str(destination_root)
result = await service.restore_backup(UploadedBackup(zip_path))
assert dotenv_values(destination_env) == {
**expected_credentials,
"ALLOWED_ORIGINS": "http://current.example",
"PORTABLE_SETTING": "restored",
}
assert len(result["restored_files"]) == 1
assert result["errors"] == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pattern_scan_can_run_without_file_limit(tmp_path): async def test_pattern_scan_can_run_without_file_limit(tmp_path):
root = tmp_path / "a0" root = tmp_path / "a0"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
import base64
from pathlib import Path
import re
import shutil
import subprocess
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
INPUT_STORE = PROJECT_ROOT / "webui/components/chat/input/input-store.js"
INDEX_JS = PROJECT_ROOT / "webui/index.js"
@pytest.mark.skipif(not shutil.which("node"), reason="Node.js is required")
def test_chat_input_keeps_separate_session_drafts() -> None:
index_source = INDEX_JS.read_text(encoding="utf-8")
set_context = index_source[index_source.index("export const setContext"):]
assert set_context.index("inputStore.setDraftContext(id);") < set_context.index("context = id;")
source = INPUT_STORE.read_text(encoding="utf-8")
source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
source = source[: source.index('const store = createStore("chatInput", model);')]
module_source = r"""
const shortcuts = {
getCurrentContextId: () => globalThis.__context,
callJsonApi: async () => ({}),
frontendNotification: () => {},
NotificationType: {},
NotificationPriority: {},
};
const fileBrowserStore = {};
const messageQueueStore = { hasQueue: false };
const attachmentsStore = {
attachments: [],
clearAttachments() { this.attachments = []; },
};
const chatsStore = { selected: "", selectedContext: null };
""" + source + "\nexport { model, chatsStore };\n"
module_url = "data:text/javascript;base64," + base64.b64encode(
module_source.encode("utf-8")
).decode("ascii")
script = f"""
const makeStorage = () => ({{
values: new Map(),
getItem(key) {{ return this.values.get(key) ?? null; }},
setItem(key, value) {{ this.values.set(key, String(value)); }},
removeItem(key) {{ this.values.delete(key); }},
}});
globalThis.sessionStorage = makeStorage();
globalThis.localStorage = makeStorage();
globalThis.document = {{ activeElement: null, getElementById: () => null, querySelectorAll: () => [] }};
globalThis.__context = null;
const {{ model, chatsStore }} = await import({module_url!r});
const assert = (condition, message) => {{ if (!condition) throw new Error(message); }};
globalThis.__context = "chat-a";
model.setDraftContext("chat-a");
model.message = "alpha draft";
assert(sessionStorage.getItem("a0:chat-draft:chat-a") === "alpha draft", "chat A was not saved");
globalThis.__context = "chat-b";
model.setDraftContext("chat-b");
assert(model.message === "", "a new chat inherited another chat's draft");
model.message = "beta draft";
globalThis.__context = "chat-a";
model.setDraftContext("chat-a");
assert(model.message === "alpha draft", "chat A was not restored");
model.message = "";
assert(sessionStorage.getItem("a0:chat-draft:chat-a") === null, "cleared draft remained stored");
globalThis.__context = null;
model.setDraftContext("");
model.message = "welcome prompt";
chatsStore.newChat = async () => {{
globalThis.__context = "chat-new";
chatsStore.selected = "chat-new";
model.setDraftContext("chat-new");
return "chat-new";
}};
let sent = "";
globalThis.sendMessage = async () => {{ sent = model.message; }};
await model.sendMessage();
assert(sent === "welcome prompt", "creating a chat erased the Welcome prompt");
assert(sessionStorage.getItem("a0:chat-draft:chat-new") === "welcome prompt", "first prompt did not follow its new chat");
"""
subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)

View file

@ -208,6 +208,44 @@ def test_prepare_responses_body_adds_codex_client_metadata(monkeypatch):
assert body["include"] == ["output_text", "reasoning.encrypted_content"] assert body["include"] == ["output_text", "reasoning.encrypted_content"]
def test_prepare_responses_body_tightens_existing_response_tool_only(monkeypatch):
monkeypatch.setattr(codex, "build_client_metadata", lambda: {})
response_tool = {
"type": "function",
"name": "response",
"description": "final answer",
"parameters": {"type": "object", "additionalProperties": True},
}
other_tool = {
"type": "function",
"name": "search",
"parameters": {"type": "object", "additionalProperties": True},
}
body = codex.prepare_responses_body(
{"input": [], "tools": [response_tool, other_tool]},
force_stream=True,
)
assert body["tools"] == [
{
**response_tool,
"strict": True,
"parameters": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
"additionalProperties": False,
},
},
other_tool,
]
assert codex.prepare_responses_body(
{"input": [], "tools": [other_tool]},
force_stream=True,
)["tools"] == [other_tool]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("request_reasoning", "expected"), ("request_reasoning", "expected"),
[ [
@ -483,6 +521,42 @@ def test_extract_sse_text_deltas_ignores_final_done_text():
) )
def test_collect_completed_response_restores_native_output_items():
item = {
"id": "msg_1",
"type": "message",
"status": "completed",
"content": [
{
"type": "output_text",
"annotations": [],
"logprobs": [],
"text": "Hello",
}
],
"role": "assistant",
}
class FakeResponse:
encoding = "utf-8"
def iter_content(self, chunk_size=8192, decode_unicode=True):
del chunk_size, decode_unicode
yield (
'data: {"type":"response.output_item.done","output_index":0,'
f'"item":{json.dumps(item)}}}\n\n'
).encode()
yield (
b'data: {"type":"response.completed",'
b'"response":{"id":"resp_1","output":[]}}\n\n'
)
assert codex.collect_completed_response(FakeResponse()) == {
"id": "resp_1",
"output": [item],
}
def test_collect_completed_response_falls_back_to_text_deltas(): def test_collect_completed_response_falls_back_to_text_deltas():
class FakeResponse: class FakeResponse:
encoding = "utf-8" encoding = "utf-8"

View file

@ -1659,10 +1659,18 @@ def test_office_runtime_dependency_install_waits_out_apt_locks(monkeypatch):
def test_desktop_runtime_packages_include_libreoffice_for_desktop_status(): def test_desktop_runtime_packages_include_libreoffice_for_desktop_status():
install_additional = (
PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh"
).read_text(encoding="utf-8")
assert set(desktop_hooks.LIBREOFFICE_RUNTIME_PACKAGES).issubset(desktop_hooks.RUNTIME_PACKAGES) assert set(desktop_hooks.LIBREOFFICE_RUNTIME_PACKAGES).issubset(desktop_hooks.RUNTIME_PACKAGES)
assert "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES assert "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES
assert "libreoffice-calc" in desktop_hooks.RUNTIME_PACKAGES assert "libreoffice-calc" in desktop_hooks.RUNTIME_PACKAGES
assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES
assert desktop_hooks.GTK_RUNTIME_PACKAGE in desktop_hooks.RUNTIME_PACKAGES
assert "xpra-client" in desktop_hooks.RUNTIME_PACKAGES
assert "xpra-client-gtk3" in desktop_hooks.RUNTIME_PACKAGES
assert f'XPRA_VERSION="{desktop_hooks.XPRA_VERSION}"' in install_additional
def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch): def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch):
@ -1756,8 +1764,8 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
assert errors == [] assert errors == []
assert installed == ["xpra"] assert installed == ["xpra"]
assert keyring.read_bytes() == b"xpra-key" assert keyring.read_bytes() == b"xpra-key"
assert "URIs: https://xpra.org/beta" in source.read_text(encoding="utf-8") assert "URIs: https://xpra.org\n" in source.read_text(encoding="utf-8")
assert "Suites: sid" in source.read_text(encoding="utf-8") assert "Suites: trixie" in source.read_text(encoding="utf-8")
assert calls.count(["apt-get", "update"]) == 2 assert calls.count(["apt-get", "update"]) == 2
assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"] assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
@ -1803,24 +1811,19 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke
assert "URIs: https://xpra.org\n" in source_text assert "URIs: https://xpra.org\n" in source_text
assert "Suites: trixie" in source_text assert "Suites: trixie" in source_text
assert "xpra" not in calls[-1] assert "xpra" not in calls[-1]
assert calls[-1][-3:] == ["xpra-server", "xpra-x11", "xpra-html5"] assert calls[-1][-3:] == [
f"xpra-server={desktop_hooks.XPRA_VERSION}",
f"xpra-x11={desktop_hooks.XPRA_VERSION}",
"xpra-html5",
]
def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch): def test_cleanup_hook_installs_matching_xpra_client_stack(monkeypatch):
calls = [] calls = []
installed_state = { installed_state = {
"xpra-server": True,
"xpra-client": False, "xpra-client": False,
"xpra-client-gtk3": False, "xpra-client-gtk3": False,
"xpra-x11": True,
"xpra-html5": True,
} }
codec_error = (
"E: Unable to satisfy dependencies. Reached two conflicting assignments:\n"
" 1. xpra-codecs:arm64=6.4.3-r0-1 is selected for install\n"
" 2. xpra-codecs:arm64 Depends libvpx9 (>= 1.12.0)\n"
" but none of the choices are installable: [no choices]"
)
monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
monkeypatch.setattr( monkeypatch.setattr(
@ -1831,16 +1834,18 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
desktop_hooks, desktop_hooks,
"RUNTIME_PACKAGES", "RUNTIME_PACKAGES",
("xpra-server", "xpra-client", "xpra-client-gtk3", "xpra-x11", "xpra-html5"), ("xpra-client", "xpra-client-gtk3"),
) )
monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
monkeypatch.setattr(desktop_hooks, "_package_version", lambda package: "6.5.2-r0-1")
def fake_run(command, **kwargs): def fake_run(command, **kwargs):
calls.append(command) calls.append(command)
if command[:2] == ["apt-cache", "policy"]: if command[:2] == ["apt-cache", "policy"]:
return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.4.3-r0-1\n", stderr="") return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.5.3-r0-1\n", stderr="")
if command[:2] == ["apt-get", "install"]: if command[:2] == ["apt-get", "install"]:
return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error) installed_state["xpra-client"] = True
installed_state["xpra-client-gtk3"] = True
return types.SimpleNamespace(returncode=0, stdout="", stderr="") return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
@ -1849,9 +1854,49 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
desktop_hooks._ensure_runtime_dependencies(installed, errors) desktop_hooks._ensure_runtime_dependencies(installed, errors)
assert installed == [] assert installed == ["xpra-client", "xpra-client-gtk3"]
assert errors == [] assert errors == []
assert calls[-1][-2:] == ["xpra-client", "xpra-client-gtk3"] assert calls[-1][-2:] == ["xpra-client=6.5.2-r0-1", "xpra-client-gtk3=6.5.2-r0-1"]
def test_cleanup_hook_repairs_kali_gtk_from_rolling_source(monkeypatch):
calls = []
source_text = []
installed_state = {desktop_hooks.GTK_RUNTIME_PACKAGE: False}
monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
monkeypatch.setattr(
desktop_hooks.shutil,
"which",
lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "",
)
monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", (desktop_hooks.GTK_RUNTIME_PACKAGE,))
monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali"})
monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
def fake_run(command, **kwargs):
calls.append(command)
source_option = next(
(item for item in command if item.startswith("Dir::Etc::sourcelist=")),
"",
)
if source_option:
source_text.append(Path(source_option.split("=", 1)[1]).read_text(encoding="utf-8"))
if "install" in command:
installed_state[desktop_hooks.GTK_RUNTIME_PACKAGE] = True
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
installed = []
errors = []
desktop_hooks._ensure_runtime_dependencies(installed, errors)
assert installed == [desktop_hooks.GTK_RUNTIME_PACKAGE]
assert errors == []
assert source_text == [desktop_hooks.KALI_ROLLING_SOURCE, desktop_hooks.KALI_ROLLING_SOURCE]
assert calls[0][-1] == "update"
assert calls[1][-2:] == ["--no-install-recommends", desktop_hooks.GTK_RUNTIME_PACKAGE]
def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch): def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch):

View file

@ -48,6 +48,7 @@ class _FakeContext:
self.id = "ctx" self.id = "ctx"
self.data = {} self.data = {}
self.log = _FakeLog() self.log = _FakeLog()
self.task = None
def get_data(self, key: str, recursive: bool = True): def get_data(self, key: str, recursive: bool = True):
return self.data.get(key) return self.data.get(key)
@ -60,14 +61,29 @@ class _FakeAgent:
def __init__(self) -> None: def __init__(self) -> None:
self.context = _FakeContext() self.context = _FakeContext()
self.agent_name = "A0" self.agent_name = "A0"
self.number = 0
class _FakeDeferredTask: class _FakeDeferredTask:
def __init__(self, *, ready: bool = False, alive: bool = True, result=None) -> None: def __init__(
self,
*,
ready: bool = False,
alive: bool = True,
result=None,
thread_name=None,
) -> None:
self.ready = ready self.ready = ready
self.alive = alive self.alive = alive
self._result = result self._result = result
self.killed = 0 self.killed = 0
self.thread_name = thread_name
self.started = None
self.children = []
def start_task(self, func, *args):
self.started = (func, args)
return self
def is_ready(self): def is_ready(self):
return self.ready return self.ready
@ -81,6 +97,12 @@ class _FakeDeferredTask:
def kill(self): def kill(self):
self.killed += 1 self.killed += 1
self.alive = False self.alive = False
for child in self.children:
child.kill()
self.children = []
def add_child_task(self, task, terminate_thread=False):
self.children.append(task)
def test_normalize_parallel_tool_calls_accepts_normal_tool_request_shapes() -> None: def test_normalize_parallel_tool_calls_accepts_normal_tool_request_shapes() -> None:
@ -138,6 +160,20 @@ def test_normalize_parallel_tool_calls_accepts_json_string_array() -> None:
assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian." assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
def test_subordinate_prompts_share_reusable_tree_contract() -> None:
call_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.call_sub.md").read_text(
encoding="utf-8"
)
parallel_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.parallel.md").read_text(
encoding="utf-8"
)
assert "A0 creates A1 children, A1 creates A2 children" in call_prompt
assert "stable child ID" in call_prompt
assert "same child lifecycle here as it does top-level" in parallel_prompt
assert "each job's `context_id`" in parallel_prompt
def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None: def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None:
with pytest.raises(ValueError, match="cannot be nested"): with pytest.raises(ValueError, match="cannot be nested"):
parallel_tools.normalize_parallel_tool_calls( parallel_tools.normalize_parallel_tool_calls(
@ -475,6 +511,246 @@ async def test_parallel_subordinate_reuses_profile_validation(monkeypatch) -> No
await parallel_tools._run_subordinate_context_job("ctx", job) await parallel_tools._run_subordinate_context_job("ctx", job)
@pytest.mark.asyncio
async def test_parallel_subordinates_are_distinct_reusable_a1_children(monkeypatch) -> None:
from agent import Agent, AgentConfig, AgentContext
from helpers import message_queue, persist_chat, tool_policy
parent_id = "ctx-parallel-a1-tree"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
async def fake_monologue(agent):
return agent.agent_name
monkeypatch.setattr(Agent, "monologue", fake_monologue)
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
child_ids = []
try:
jobs = await parallel_tools.start_parallel_jobs(
parent.agent0,
[
parallel_tools.NormalizedToolCall(
index=0,
tool_name="call_subordinate",
tool_args={"message": "left branch", "reset": True},
),
parallel_tools.NormalizedToolCall(
index=1,
tool_name="call_subordinate",
tool_args={"message": "right branch", "reset": True},
),
],
)
results = await parallel_tools.await_parallel_jobs(
parent.agent0,
[job.id for job in jobs],
timeout=10,
)
child_ids = [result["context_id"] for result in results]
assert [result["state"] for result in results] == ["success", "success"]
assert [result["result"] for result in results] == ["A1", "A1"]
assert len(set(child_ids)) == 2
assert set(parent.agent0.get_data("_subordinates")) == set(child_ids)
for child_id in child_ids:
child = AgentContext.get(child_id)
assert child is not None
assert child.agent0.number == 1
assert child.get_output_data("parent_context_id") == parent.id
assert child.get_output_data("parent_agent_number") == 0
assert child.get_output_data("parent_context_kind") == "subordinate"
finally:
for child_id in child_ids:
AgentContext.remove(child_id)
AgentContext.remove(parent_id)
@pytest.mark.asyncio
async def test_failed_parallel_subordinate_continues_directly_or_in_parallel(
monkeypatch,
) -> None:
from agent import Agent, AgentConfig, AgentContext
from helpers import message_queue, persist_chat, tool_policy
from tools.call_subordinate import Delegation
parent_id = "ctx-parallel-resume-tree"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
calls = {}
async def flaky_monologue(agent):
count = calls.get(agent.context.id, 0) + 1
calls[agent.context.id] = count
if count == 1:
raise RuntimeError("simulated API failure")
return f"{agent.agent_name} continuation {count}"
monkeypatch.setattr(Agent, "monologue", flaky_monologue)
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
child_id = ""
try:
failed = parallel_tools.ParallelJob(
id="callsubordin-failed",
parent_context_id=parent.id,
index=0,
tool_name="call_subordinate",
tool_args={"message": "remember ALPHA", "reset": True},
kind="subordinate",
parent_agent=parent.agent0,
)
parallel_tools._jobs_for_context(parent)[failed.id] = failed
await parallel_tools._run_parallel_job(parent.id, failed.id)
child_id = failed.worker_context_id or ""
assert failed.state == "error"
assert failed.error == "simulated API failure"
assert child_id
assert AgentContext.get(child_id).agent0.number == 1 # type: ignore[union-attr]
direct = Delegation(
parent.agent0,
"call_subordinate",
None,
{},
"",
None,
)
direct_result = await direct.execute(
message="continue after the API failure",
context_id=child_id,
reset=False,
)
assert direct_result.message == "A1 continuation 2"
assert direct_result.additional == {"context_id": child_id}
continued = parallel_tools.ParallelJob(
id="callsubordin-continued",
parent_context_id=parent.id,
index=0,
tool_name="call_subordinate",
tool_args={
"message": "continue once more",
"context_id": child_id,
"reset": False,
},
kind="subordinate",
parent_agent=parent.agent0,
)
parallel_tools._jobs_for_context(parent)[continued.id] = continued
await parallel_tools._run_parallel_job(parent.id, continued.id)
assert continued.state == "success"
assert continued.worker_context_id == child_id
assert continued.result == "A1 continuation 3"
assert calls == {child_id: 3}
finally:
if child_id:
AgentContext.remove(child_id)
AgentContext.remove(parent_id)
@pytest.mark.asyncio
async def test_parallel_a1_spawns_a2_with_same_lifecycle(monkeypatch) -> None:
from agent import Agent, AgentConfig, AgentContext
from helpers import message_queue, persist_chat, tool_policy
parent_id = "ctx-parallel-a2-tree"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
async def fake_monologue(agent):
return agent.agent_name
monkeypatch.setattr(Agent, "monologue", fake_monologue)
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
child_ids = []
try:
a1_job = parallel_tools.ParallelJob(
id="callsubordin-a1",
parent_context_id=parent.id,
index=0,
tool_name="call_subordinate",
tool_args={"message": "be A1", "reset": True},
kind="subordinate",
parent_agent=parent.agent0,
)
a1_result = await parallel_tools._run_subordinate_context_job(parent.id, a1_job)
a1 = AgentContext.get(a1_job.worker_context_id or "").agent0 # type: ignore[union-attr]
child_ids.append(a1.context.id)
a2_job = parallel_tools.ParallelJob(
id="callsubordin-a2",
parent_context_id=a1.context.id,
index=0,
tool_name="call_subordinate",
tool_args={"message": "be A2", "reset": True},
kind="subordinate",
parent_agent=a1,
)
a2_result = await parallel_tools._run_subordinate_context_job(
a1.context.id, a2_job
)
a2_context = AgentContext.get(a2_job.worker_context_id or "")
child_ids.append(a2_context.id) # type: ignore[union-attr]
assert a1_result == "A1"
assert a1.number == 1
assert a2_result == "A2"
assert a2_context.agent0.number == 2 # type: ignore[union-attr]
assert a2_context.get_output_data("parent_context_id") == a1.context.id # type: ignore[union-attr]
assert a2_context.get_output_data("parent_agent_number") == 1 # type: ignore[union-attr]
finally:
for child_id in reversed(child_ids):
AgentContext.remove(child_id)
AgentContext.remove(parent_id)
@pytest.mark.asyncio
async def test_parallel_subordinate_owns_nested_parallel_tasks(monkeypatch) -> None:
monkeypatch.setattr(parallel_tools, "DeferredTask", _FakeDeferredTask)
agent = _FakeAgent()
parent_task = _FakeDeferredTask()
agent.context.task = parent_task
agent.context.set_data(parallel_tools.PARALLEL_WORKER_KIND_KEY, "subordinate")
jobs = await parallel_tools.start_parallel_jobs(
agent, # type: ignore[arg-type]
[
parallel_tools.NormalizedToolCall(
index=0,
tool_name="call_subordinate",
tool_args={"message": "nested", "reset": True},
)
],
)
assert parent_task.children == [jobs[0].deferred_task]
parent_task.kill()
assert jobs[0].deferred_task.killed == 1 # type: ignore[union-attr]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None: async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None:
class FakeDeferredTask: class FakeDeferredTask:

View file

@ -147,9 +147,44 @@ def test_scoped_plugin_without_settings_form_exposes_configuration_index():
assert "context.pluginMeta?.has_config_screen" in settings_html assert "context.pluginMeta?.has_config_screen" in settings_html
def test_scoped_plugin_watchdogs_skip_frontend_reload(monkeypatch):
handlers = {}
changes = []
monkeypatch.setattr(
plugins.watchdog,
"add_watchdog",
lambda **kwargs: handlers.setdefault(kwargs["id"], kwargs["handler"]),
)
monkeypatch.setattr(
plugins,
"after_plugin_change",
lambda names=None, python_change=False, frontend_reload=True: changes.append(
(names, python_change, frontend_reload)
),
)
plugins.register_watchdogs()
handlers["plugins_agents"](
[["/tmp/usr/agents/custom/plugins/_code_execution/.toggle-0", "delete"]]
)
handlers["plugins_roots"](
[["/tmp/plugins/_code_execution/.toggle-0", "delete"]]
)
assert changes == [
(["_code_execution"], False, False),
(["_code_execution"], False, True),
]
def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypatch): def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypatch):
monkeypatch.setattr(files, "_base_dir", str(tmp_path)) monkeypatch.setattr(files, "_base_dir", str(tmp_path))
monkeypatch.setattr(plugins, "after_plugin_change", lambda *_args, **_kwargs: None) changes = []
monkeypatch.setattr(
plugins,
"after_plugin_change",
lambda names, **kwargs: changes.append((names, kwargs)),
)
monkeypatch.setitem( monkeypatch.setitem(
sys.modules, sys.modules,
"helpers.projects", "helpers.projects",
@ -175,3 +210,7 @@ def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypat
assert (scoped_plugin_dir / ".toggle-1").exists() assert (scoped_plugin_dir / ".toggle-1").exists()
assert not (scoped_plugin_dir / ".toggle-0").exists() assert not (scoped_plugin_dir / ".toggle-0").exists()
assert changes == [
(["example"], {"frontend_reload": False}),
(["example"], {"frontend_reload": False}),
]

View file

@ -249,7 +249,9 @@ async def test_transport_downgrades_unsupported_builtin_tools(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unified_turn_captures_response_id_without_stop_request(monkeypatch): async def test_unified_turn_keeps_streamed_call_when_completion_omits_output(
monkeypatch,
):
stream = _AsyncEventStream( stream = _AsyncEventStream(
[ [
{ {
@ -274,15 +276,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
"type": "response.completed", "type": "response.completed",
"response": { "response": {
"id": "resp_1", "id": "resp_1",
"output": [ "output": [],
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "lookup",
"arguments": '{"q":"a0"}',
}
],
}, },
}, },
] ]
@ -315,6 +309,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
assert stream.closed is False assert stream.closed is False
assert result.response_id == "resp_1" assert result.response_id == "resp_1"
assert result.function_calls[0].call_id == "call_1" assert result.function_calls[0].call_id == "call_1"
assert result.function_calls[0].arguments == {"q": "a0"}
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -168,20 +168,35 @@ def test_responses_function_tools_add_empty_properties_to_mcp_schemas(
] ]
def test_response_tool_native_contract_omits_wrapper_and_exposes_text(): def test_response_tool_native_contract_stays_provider_neutral(monkeypatch):
prompt = (PROJECT_ROOT / "prompts" / "agent.system.tool.response.md").read_text( prompt_root = PROJECT_ROOT / "agents" / "agent0" / "prompts"
encoding="utf-8" prompt = (prompt_root / "agent.system.tool.response.md").read_text(encoding="utf-8")
)
description = tool_policy.tool_prompt_description( description = tool_policy.tool_prompt_description(
prompt, prompt,
"response", "response",
fallback="response", fallback="response",
) )
schema = responses_tools._schema_from_prompt(prompt) monkeypatch.setattr(
responses_tools.subagents,
"get_paths",
lambda *args, **kwargs: [str(prompt_root)],
)
monkeypatch.setattr(
responses_tools,
"_include_local_tool_prompt",
lambda agent, tool_name: True,
)
monkeypatch.setattr(responses_tools, "_vision_tool_prompt", lambda agent: "")
monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
tools, _name_map = responses_tools.build_responses_function_tools(
FakeAgent(prompt_root)
)
response_tool = next(tool for tool in tools if tool["name"] == "response")
assert description == "final answer to user" assert description == "final answer to user"
assert schema["properties"] == {"text": {"type": "string"}} assert response_tool["parameters"] == responses_tools._schema_from_prompt(prompt)
assert "strict" not in response_tool
def test_complex_prompt_args_are_not_guessed_as_string_schemas(): def test_complex_prompt_args_are_not_guessed_as_string_schemas():

View file

@ -183,11 +183,12 @@ def test_chat_bar_keeps_existing_send_and_mic_icon_contract() -> None:
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
assert 'id="send-button"' in chat_bar assert 'id="send-button"' in chat_bar
assert 'x-text="$store.chatInput.sendButtonIcon"' in chat_bar assert ':name="$store.chatInput.sendButtonIcon"' in chat_bar
assert ':class="$store.chatInput.sendButtonClass"' in chat_bar assert ':class="$store.chatInput.sendButtonClass"' in chat_bar
assert ':title="$store.chatInput.sendButtonTitle"' in chat_bar assert ':title="$store.chatInput.sendButtonTitle"' in chat_bar
assert 'id="microphone-button"' in mic_extension assert 'id="microphone-button"' in mic_extension
assert "data-whisper-microphone" in mic_extension
assert "<svg" in mic_extension assert "<svg" in mic_extension
assert "material-symbols-outlined" not in mic_extension assert "material-symbols-outlined" not in mic_extension
assert "buttonIcon" not in mic_extension assert "buttonIcon" not in mic_extension

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@ -10,10 +11,28 @@ from helpers.errors import RepairableException
class _FakeContext: class _FakeContext:
id = "ctx" def __init__(self, id: str = "ctx") -> None:
self.id = id
self.name = None
self.data = {}
self.output_data = {}
self.created_at = datetime.now(timezone.utc)
self.agent0 = None
def get_data(self, key: str, recursive: bool = True): def get_data(self, key: str, recursive: bool = True):
return None return self.data.get(key)
def set_data(self, key: str, value, recursive: bool = True):
self.data[key] = value
def get_output_data(self, key: str, recursive: bool = True):
return self.output_data.get(key)
def set_output_data(self, key: str, value, recursive: bool = True):
self.output_data[key] = value
def is_running(self) -> bool:
return False
class _FakeParentAgent: class _FakeParentAgent:
@ -45,10 +64,17 @@ class _FakeSubAgent:
DATA_NAME_SUPERIOR = "_superior" DATA_NAME_SUPERIOR = "_superior"
DATA_NAME_SUBORDINATE = "_subordinate" DATA_NAME_SUBORDINATE = "_subordinate"
def __init__(self, number: int, config: AgentConfig, context) -> None: _counter = 0
def __init__(self, number: int, config: AgentConfig, context=None) -> None:
if context is None:
self.__class__._counter += 1
context = _FakeContext(f"child-{self.__class__._counter}")
self.number = number self.number = number
self.agent_name = f"A{number}"
self.config = config self.config = config
self.context = context self.context = context
self.context.agent0 = self
self.data = {} self.data = {}
self.history = SimpleNamespace(new_topic=lambda: None) self.history = SimpleNamespace(new_topic=lambda: None)
self.messages = [] self.messages = []
@ -56,6 +82,9 @@ class _FakeSubAgent:
def set_data(self, key: str, value): def set_data(self, key: str, value):
self.data[key] = value self.data[key] = value
def get_data(self, key: str):
return self.data.get(key)
def hist_add_user_message(self, message): def hist_add_user_message(self, message):
self.messages.append(message) self.messages.append(message)
@ -106,6 +135,12 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
profile=(override_settings or {}).get("agent_profile", "agent0"), profile=(override_settings or {}).get("agent_profile", "agent0"),
), ),
) )
monkeypatch.setattr(
call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(
call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
)
parent = _FakeParentAgent() parent = _FakeParentAgent()
tool = call_subordinate.Delegation( tool = call_subordinate.Delegation(
@ -118,13 +153,122 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
) )
response = await tool.execute(message="work", profile="developer", reset=True) response = await tool.execute(message="work", profile="developer", reset=True)
child = parent.get_data(_FakeSubAgent.DATA_NAME_SUBORDINATE) children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
child = next(iter(children.values()))
assert response.message == "delegated" assert response.message == "delegated"
assert response.additional == {"context_id": child.context.id}
assert child.number == 1
assert child.config.profile == "developer" assert child.config.profile == "developer"
assert child.messages[0].message == "work" assert child.messages[0].message == "work"
@pytest.mark.asyncio
async def test_call_subordinate_reset_false_reuses_numbered_child(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
monkeypatch.setattr(
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
)
monkeypatch.setattr(
call_subordinate,
"initialize_agent",
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
)
monkeypatch.setattr(
call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(
call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
)
parent = _FakeParentAgent()
tool = call_subordinate.Delegation(
parent, # type: ignore[arg-type]
"call_subordinate",
None,
{},
"",
None,
)
first = await tool.execute(message="first", reset=True)
second = await tool.execute(
message="continue",
context_id=first.additional["context_id"], # type: ignore[index]
reset=False,
)
children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
child = next(iter(children.values()))
assert len(children) == 1
assert child.number == 1
assert [message.message for message in child.messages] == ["first", "continue"]
assert second.additional == first.additional
def test_subordinate_tree_numbers_each_generation(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
monkeypatch.setattr(
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
)
monkeypatch.setattr(
call_subordinate,
"initialize_agent",
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
)
parent = _FakeParentAgent()
child = call_subordinate.get_or_create_subordinate(
parent, # type: ignore[arg-type]
reset=True,
message="A1 work",
)
grandchild = call_subordinate.get_or_create_subordinate(
child, # type: ignore[arg-type]
reset=True,
message="A2 work",
)
assert child.number == 1
assert grandchild.number == 2
assert child.context.get_output_data("parent_context_id") == parent.context.id
assert grandchild.context.get_output_data("parent_context_id") == child.context.id
assert grandchild.get_data(Agent.DATA_NAME_SUPERIOR) is child
def test_subordinate_context_id_is_scoped_to_its_parent(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
monkeypatch.setattr(
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
)
monkeypatch.setattr(
call_subordinate,
"initialize_agent",
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
)
owner = _FakeParentAgent()
other = _FakeParentAgent()
other.context = _FakeContext("other-parent")
child = call_subordinate.get_or_create_subordinate(
owner, # type: ignore[arg-type]
reset=True,
message="private branch",
)
with pytest.raises(RepairableException, match="was not found under A0"):
call_subordinate.get_or_create_subordinate(
other, # type: ignore[arg-type]
context_id=child.context.id,
reset=False,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None: async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate import tools.call_subordinate as call_subordinate
@ -189,6 +333,56 @@ def test_persist_chat_roundtrip_preserves_each_agent_profile(monkeypatch) -> Non
AgentContext.remove(context_id) AgentContext.remove(context_id)
def test_persisted_numbered_child_is_reusable_after_reload(monkeypatch) -> None:
import tools.call_subordinate as call_subordinate
config_factory = lambda override_settings=None: AgentConfig(
mcp_servers="",
profile=(override_settings or {}).get("agent_profile", "agent0"),
)
monkeypatch.setattr(
persist_chat,
"initialize_agent",
config_factory,
)
monkeypatch.setattr(call_subordinate, "initialize_agent", config_factory)
parent_id = "ctx-persisted-agent-tree-parent"
AgentContext.remove(parent_id)
parent = AgentContext(
AgentConfig(mcp_servers="", profile="agent0"),
id=parent_id,
set_current=False,
)
child = call_subordinate.get_or_create_subordinate(
parent.agent0,
reset=True,
message="persist me",
)
context_id = child.context.id
try:
assert len(persist_chat._serialize_context(parent)["agents"]) == 1
serialized = persist_chat._serialize_context(child.context)
AgentContext.remove(context_id)
parent.agent0.data.pop(call_subordinate.SUBORDINATES_DATA_KEY, None)
restored = persist_chat._deserialize_context(serialized)
resumed = call_subordinate.get_or_create_subordinate(
parent.agent0,
context_id=context_id,
reset=False,
)
assert restored.agent0.number == 1
assert restored.agent0.agent_name == "A1"
assert restored.get_output_data("parent_context_id") == parent.id
assert restored.get_output_data("parent_agent_number") == 0
assert resumed is restored.agent0
assert resumed.get_data(Agent.DATA_NAME_SUPERIOR) is parent.agent0
finally:
AgentContext.remove(context_id)
AgentContext.remove(parent_id)
@pytest.mark.parametrize("project_name", [None, "demo"], ids=["global", "project"]) @pytest.mark.parametrize("project_name", [None, "demo"], ids=["global", "project"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_agent_profile_set_uses_scope_and_preserves_subagent_profile( async def test_agent_profile_set_uses_scope_and_preserves_subagent_profile(

165
tests/test_user_routes.py Normal file
View file

@ -0,0 +1,165 @@
import threading
from flask import Flask
from helpers import cache, files, login, plugins, subagents
from helpers.api import CACHE_AREA, register_api_route
from helpers.extension import get_webui_extension_manifest
from helpers.ui_server import UiServerRuntime
WEBUI_MANIFEST_CACHE_AREA = "webui_extension_manifest(extensions)(plugins)"
def _new_app(name: str) -> Flask:
app = Flask(name, static_folder=None)
app.secret_key = "test-secret"
return app
def _api_handler_source(source: str) -> str:
return f"""from helpers.api import ApiHandler
class Handler(ApiHandler):
@classmethod
def get_methods(cls):
return ["GET"]
async def process(self, input, request):
return {{"source": {source!r}}}
"""
def test_http_dispatches_contained_user_api_handler(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
user_api_dir = tmp_path / "usr" / "api"
user_api_dir.mkdir(parents=True)
handler_source = _api_handler_source("user")
(user_api_dir / "ping.py").write_text(handler_source, encoding="utf-8")
(tmp_path / "usr" / "escaped.py").write_text(
handler_source, encoding="utf-8"
)
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
cache.clear(CACHE_AREA)
try:
app = _new_app("test_user_api_route")
app.add_url_rule("/", "serve_index", lambda: "")
app.add_url_rule("/login", "login_handler", lambda: "")
register_api_route(app, threading.RLock())
client = app.test_client()
assert client.get("/api/ping").status_code == 302
with client.session_transaction() as session:
session["authentication"] = "credential-hash"
session["csrf_token"] = "csrf-token"
response = client.get("/api/ping", headers={"X-CSRF-Token": "csrf-token"})
assert response.status_code == 200
assert response.get_json() == {"source": "user"}
with app.test_request_context("/api/../escaped", method="GET"):
denied = app.ensure_sync(app.view_functions["api_dispatch"])("../escaped")
assert denied.status_code == 404
finally:
cache.clear(CACHE_AREA)
def test_existing_api_sources_keep_precedence(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
builtin_file = tmp_path / "api" / "shared.py"
builtin_file.parent.mkdir(parents=True)
builtin_file.write_text(_api_handler_source("builtin"), encoding="utf-8")
user_api_dir = tmp_path / "usr" / "api"
(user_api_dir / "plugins" / "demo").mkdir(parents=True)
(user_api_dir / "shared.py").write_text(
_api_handler_source("user"), encoding="utf-8"
)
(user_api_dir / "plugins" / "demo" / "ping.py").write_text(
_api_handler_source("user"), encoding="utf-8"
)
plugin_dir = tmp_path / "plugins" / "demo"
(plugin_dir / "api").mkdir(parents=True)
(plugin_dir / "api" / "ping.py").write_text(
_api_handler_source("plugin"), encoding="utf-8"
)
monkeypatch.setattr(
plugins,
"find_plugin_dir",
lambda name: str(plugin_dir) if name == "demo" else None,
)
cache.clear(CACHE_AREA)
try:
app = _new_app("test_existing_api_precedence")
app.add_url_rule("/", "serve_index", lambda: "")
app.add_url_rule("/login", "login_handler", lambda: "")
register_api_route(app, threading.RLock())
client = app.test_client()
with client.session_transaction() as session:
session["authentication"] = "credential-hash"
session["csrf_token"] = "csrf-token"
headers = {"X-CSRF-Token": "csrf-token"}
assert client.get("/api/shared", headers=headers).get_json() == {
"source": "builtin"
}
assert client.get("/api/plugins/demo/ping", headers=headers).get_json() == {
"source": "plugin"
}
finally:
cache.clear(CACHE_AREA)
def test_user_webui_manifest_asset_is_served_from_its_declared_url(
tmp_path, monkeypatch
) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
extension_root = tmp_path / "usr" / "extensions" / "webui"
extension_file = extension_root / "route-probe" / "probe.js"
extension_file.parent.mkdir(parents=True)
extension_file.write_text("export default true;", encoding="utf-8")
builtin_extension_file = (
tmp_path / "extensions" / "webui" / "route-probe" / "probe.js"
)
builtin_extension_file.parent.mkdir(parents=True)
builtin_extension_file.write_text("export default false;", encoding="utf-8")
(extension_root.parent / "escaped.js").write_text("secret", encoding="utf-8")
monkeypatch.setattr(subagents, "get_paths", lambda *_args, **_kwargs: [str(extension_root)])
cache.clear(WEBUI_MANIFEST_CACHE_AREA)
try:
manifest = get_webui_extension_manifest(agent=None)
asset_url = manifest["js"]["route-probe"][0]
assert asset_url == "/usr/extensions/webui/route-probe/probe.js"
app = _new_app("test_user_webui_extension_route")
runtime = UiServerRuntime(
app, None, None, threading.RLock(), {} # type: ignore[arg-type]
)
runtime.register_http_routes()
client = app.test_client()
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
assert client.get(asset_url).status_code == 302
monkeypatch.setattr(login, "get_credentials_hash", lambda: None)
builtin_response = client.get("/extensions/webui/route-probe/probe.js")
assert builtin_response.status_code == 200
assert builtin_response.get_data(as_text=True) == "export default false;"
response = client.get(asset_url)
assert response.status_code == 200
assert response.get_data(as_text=True) == "export default true;"
with app.test_request_context("/usr/extensions/webui/../escaped.js"):
denied = app.ensure_sync(
app.view_functions["serve_user_extension_asset"]
)("../escaped.js")
assert denied.status_code == 403
finally:
cache.clear(WEBUI_MANIFEST_CACHE_AREA)

View file

@ -1,11 +1,20 @@
from agent import Agent, UserMessage from agent import Agent, AgentContext, UserMessage
from helpers import projects, subagents from helpers import message_queue, persist_chat, projects, subagents
from helpers.errors import RepairableException from helpers.errors import RepairableException
from helpers.tool import Tool, Response from helpers.tool import Tool, Response
from initialize import initialize_agent from initialize import initialize_agent
from extensions.python.hist_add_tool_result import _90_save_tool_call_file as save_tool_call_file from extensions.python.hist_add_tool_result import _90_save_tool_call_file as save_tool_call_file
SUBORDINATES_DATA_KEY = "_subordinates"
CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
CHILD_SUBORDINATE_SLOT_KEY = "subordinate_slot"
DEFAULT_SUBORDINATE_SLOT = "default"
def _subordinate_profile_labels(agent: Agent) -> dict[str, str]: def _subordinate_profile_labels(agent: Agent) -> dict[str, str]:
project = projects.get_context_project_name(agent.context) if agent.context else None project = projects.get_context_project_name(agent.context) if agent.context else None
return { return {
@ -34,59 +43,196 @@ def _validate_subordinate_profile(agent: Agent, profile: str) -> str:
) )
def _register_subordinate(parent: Agent, subordinate: Agent, slot: str) -> None:
subordinates = parent.get_data(SUBORDINATES_DATA_KEY)
if not isinstance(subordinates, dict):
subordinates = {}
parent.set_data(SUBORDINATES_DATA_KEY, subordinates)
subordinates[subordinate.context.id] = subordinate
subordinate.set_data(Agent.DATA_NAME_SUPERIOR, parent)
if slot == DEFAULT_SUBORDINATE_SLOT and subordinate.context is parent.context:
parent.set_data(Agent.DATA_NAME_SUBORDINATE, subordinate)
def _is_child_context(context: AgentContext, parent: Agent, slot: str | None = None) -> bool:
if context.get_output_data(CHILD_PARENT_CONTEXT_ID_KEY) != parent.context.id:
return False
if context.get_output_data(CHILD_PARENT_AGENT_NUMBER_KEY) != parent.number:
return False
if context.agent0.number != parent.number + 1:
return False
return slot is None or context.get_output_data(CHILD_SUBORDINATE_SLOT_KEY) == slot
def _is_live_context(context: AgentContext) -> bool:
return not isinstance(context, AgentContext) or AgentContext.get(context.id) is context
def _find_subordinate(parent: Agent, context_id: str, slot: str) -> Agent | None:
registered = parent.get_data(SUBORDINATES_DATA_KEY)
registered = registered if isinstance(registered, dict) else {}
if context_id:
subordinate = registered.get(context_id)
if (
subordinate
and _is_live_context(subordinate.context)
and _is_child_context(subordinate.context, parent)
):
return subordinate
context = AgentContext.get(context_id)
if not context or not _is_child_context(context, parent):
raise RepairableException(
f"Subordinate context '{context_id}' was not found under {parent.agent_name}."
)
subordinate = context.agent0
_register_subordinate(parent, subordinate, slot)
return subordinate
existing = parent.get_data(Agent.DATA_NAME_SUBORDINATE)
if slot == DEFAULT_SUBORDINATE_SLOT and existing is not None:
return existing
registered_matches = [
subordinate
for subordinate in registered.values()
if _is_live_context(subordinate.context)
and _is_child_context(subordinate.context, parent, slot)
]
if registered_matches:
return max(
registered_matches,
key=lambda subordinate: subordinate.context.created_at,
)
matches = [
context
for context in AgentContext.all()
if _is_child_context(context, parent, slot)
]
if not matches:
return None
subordinate = max(matches, key=lambda context: context.created_at).agent0
_register_subordinate(parent, subordinate, slot)
return subordinate
def get_or_create_subordinate(
parent: Agent,
*,
profile: str = "",
reset: bool | str = False,
context_id: str = "",
name: str = "",
message: str = "",
slot: str = DEFAULT_SUBORDINATE_SLOT,
) -> Agent:
requested_profile = _validate_subordinate_profile(parent, profile)
target_context_id = str(context_id or "").strip()
reset_requested = str(reset).lower().strip() == "true"
if target_context_id and reset_requested:
raise RepairableException(
"`context_id` continues an existing subordinate and requires reset=false. "
"Omit `context_id` to create a fresh subordinate."
)
subordinate = (
None
if reset_requested
else _find_subordinate(parent, target_context_id, slot)
)
if subordinate:
current_profile = str(getattr(subordinate.config, "profile", "") or "")
if requested_profile and current_profile != requested_profile:
raise RepairableException(
f"Subordinate already uses profile '{current_profile or 'default'}'. "
f"Set reset=true and omit `context_id` to switch to '{requested_profile}'."
)
if subordinate.context is not parent.context and subordinate.context.is_running():
raise RepairableException(
f"Subordinate context '{subordinate.context.id}' is still running. "
"Await or cancel its parallel job before continuing it."
)
return subordinate
override_settings = {"agent_profile": requested_profile} if requested_profile else None
subordinate = Agent(parent.number + 1, initialize_agent(override_settings=override_settings))
context = subordinate.context
context.name = str(name or "").strip() or _short_label(message) or subordinate.agent_name
context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent.context.id)
context.set_output_data(CHILD_PARENT_AGENT_NUMBER_KEY, parent.number)
context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "subordinate")
context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, context.name)
context.set_output_data(CHILD_SUBORDINATE_SLOT_KEY, slot)
project = projects.get_context_project_name(parent.context)
if project:
projects.activate_project(context.id, project, mark_dirty=False)
model_override = parent.context.get_data("chat_model_override")
if model_override:
context.set_data("chat_model_override", model_override)
_register_subordinate(parent, subordinate, slot)
return subordinate
async def run_subordinate(
parent: Agent,
subordinate: Agent,
message: str,
attachments: list[str] | None = None,
) -> str:
assignment = str(message or "").strip()
if not assignment:
raise RepairableException("call_subordinate requires a non-empty `message`.")
attachment_paths = [str(item) for item in attachments or []]
if subordinate.context is not parent.context:
message_queue.log_user_message(
subordinate.context,
assignment,
attachment_paths,
source=" (subordinate)",
)
subordinate.hist_add_user_message(
UserMessage(message=assignment, attachments=attachment_paths)
)
if subordinate.context is not parent.context:
persist_chat.save_tmp_chat(subordinate.context)
try:
result = await subordinate.monologue()
subordinate.history.new_topic()
return result
finally:
if subordinate.context is not parent.context:
persist_chat.save_tmp_chat(subordinate.context)
def _short_label(text: str, limit: int = 80) -> str:
return " ".join(str(text or "").split())[:limit].rstrip()
class Delegation(Tool): class Delegation(Tool):
async def execute(self, message="", reset="", **kwargs): async def execute(self, message="", reset="", context_id="", **kwargs):
requested_profile = _validate_subordinate_profile( attachments = kwargs.get("attachments")
self.agent, kwargs.get("profile", kwargs.get("agent_profile", "")) attachments = attachments if isinstance(attachments, list) else []
subordinate = get_or_create_subordinate(
self.agent,
profile=kwargs.get("profile", kwargs.get("agent_profile", "")),
reset=reset,
context_id=context_id or kwargs.get("agent_id", ""),
name=kwargs.get("name", ""),
message=message,
) )
existing_subordinate = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) result = await run_subordinate(self.agent, subordinate, message, attachments)
reset_requested = str(reset).lower().strip() == "true"
if existing_subordinate and requested_profile and not reset_requested:
current_profile = str(
getattr(getattr(existing_subordinate, "config", None), "profile", "")
or ""
)
if current_profile != requested_profile:
raise RepairableException(
f"Subordinate already uses profile '{current_profile or 'default'}'. "
f"Set reset=true to switch to '{requested_profile}'."
)
# create subordinate agent using the data object on this agent and set superior agent to his data object
if (
existing_subordinate is None
or reset_requested
):
# set subordinate prompt profile if provided, otherwise use the default profile
override_settings = (
{"agent_profile": requested_profile} if requested_profile else None
)
config = initialize_agent(override_settings=override_settings)
# create agent
sub = Agent(self.agent.number + 1, config, self.agent.context)
# register superior/subordinate
sub.set_data(Agent.DATA_NAME_SUPERIOR, self.agent)
self.agent.set_data(Agent.DATA_NAME_SUBORDINATE, sub)
# add user message to subordinate agent
subordinate: Agent = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) # type: ignore
subordinate.hist_add_user_message(UserMessage(message=message, attachments=[]))
# run subordinate monologue
result = await subordinate.monologue()
# seal the subordinate's current topic so messages move to `topics` for compression
subordinate.history.new_topic()
# hint to use includes for long responses # hint to use includes for long responses
additional = None additional = {"context_id": subordinate.context.id}
if len(result) >= save_tool_call_file.LEN_MIN: if len(result) >= save_tool_call_file.LEN_MIN:
hint = self.agent.read_prompt("fw.hint.call_sub.md") hint = self.agent.read_prompt("fw.hint.call_sub.md")
if hint: if hint:
additional = {"hint": hint} additional["hint"] = hint
# result # result
return Response(message=result, break_loop=False, additional=additional) return Response(message=result, break_loop=False, additional=additional)

View file

@ -12,11 +12,13 @@
- `call_subordinate.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation. - `call_subordinate.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
- Classes: - Classes:
- `Delegation` (`Tool`) - `Delegation` (`Tool`)
- `async execute(self, message=..., reset=..., **kwargs)` - `async execute(self, message=..., reset=..., context_id=..., **kwargs)`
- `get_log_object(self)` - `get_log_object(self)`
- Top-level functions: - Top-level functions:
- `_subordinate_profile_labels(agent: Agent) -> dict[str, str]` - `_subordinate_profile_labels(agent: Agent) -> dict[str, str]`
- `_validate_subordinate_profile(agent: Agent, profile: str) -> str` - `_validate_subordinate_profile(agent: Agent, profile: str) -> str`
- `get_or_create_subordinate(...) -> Agent`
- `run_subordinate(...) -> str`
## Runtime Contracts ## Runtime Contracts
@ -26,12 +28,20 @@
- `Delegation` defines `execute(...)`. - `Delegation` defines `execute(...)`.
- Observed side-effect areas: filesystem writes, settings/state persistence. - Observed side-effect areas: filesystem writes, settings/state persistence.
- `profile`/`agent_profile` values are validated against available profile keys before use; unknown profiles raise `RepairableException` so the agent can retry with a real profile. - `profile`/`agent_profile` values are validated against available profile keys before use; unknown profiles raise `RepairableException` so the agent can retry with a real profile.
- Supplying a different profile for an existing subordinate without `reset=true` raises `RepairableException` instead of silently continuing the old subordinate. - Direct and parallel calls use the same creation, continuation, message, history, and persistence functions in this module.
- Every fresh child is `Agent(parent.number + 1, ...)` in its own persisted child-chat context, so sibling A1 agents can each create their own A2 descendants without sharing streaming state.
- `reset=true` creates a fresh child. `reset=false` continues the caller's default child or the exact child named by `context_id`.
- Child context IDs are accepted only when their persisted parent context, parent agent number, and child depth match the caller.
- Supplying a different profile for an existing child without creating a fresh child raises `RepairableException` instead of silently changing its profile.
- Active parallel children cannot be continued concurrently; await or cancel their job first.
- Child contexts inherit the caller's project and selected chat-model override, are saved before execution and again on exit, and remain reusable after model/API failures.
- The direct tool result includes `context_id`; parallel job snapshots expose the same stable child ID separately from their per-invocation job ID.
- Existing same-context linear subordinates remain reusable for saved-chat compatibility, but new children use child contexts and a private per-parent registry.
- Imported dependency areas include: `agent`, `extensions.python.hist_add_tool_result`, `helpers`, `helpers.errors`, `helpers.tool`. - Imported dependency areas include: `agent`, `extensions.python.hist_add_tool_result`, `helpers`, `helpers.errors`, `helpers.tool`.
## Key Concepts ## Key Concepts
- Important called helpers/classes observed in the source: `self.agent.get_data`, `projects.get_context_project_name`, `subagents.get_available_agents_dict`, `RepairableException`, `initialize_agent`, `subordinate.hist_add_user_message`, `subordinate.history.new_topic`, `Response`, `self.agent.context.log.log`, `Agent`, `sub.set_data`, `self.agent.set_data`, `UserMessage`, `subordinate.monologue`, `self.agent.read_prompt`, `str.lower.strip`, `str.lower`. - Important called helpers/classes observed in the source: `AgentContext.all`, `projects.get_context_project_name`, `projects.activate_project`, `subagents.get_available_agents_dict`, `RepairableException`, `initialize_agent`, `message_queue.log_user_message`, `persist_chat.save_tmp_chat`, `UserMessage`, `subordinate.monologue`, and `subordinate.history.new_topic`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes. - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance ## Work Guidance
@ -46,6 +56,7 @@
- Related tests observed by source search: - Related tests observed by source search:
- `tests/test_default_prompt_budget.py` - `tests/test_default_prompt_budget.py`
- `tests/test_subagent_profiles.py` - `tests/test_subagent_profiles.py`
- `tests/test_parallel_tool.py`
## Child DOX Index ## Child DOX Index

View file

@ -28,7 +28,8 @@
- `action="await"` waits for requested job IDs until completion or `timeout`; timeout returns running job handles without canceling them. - `action="await"` waits for requested job IDs until completion or `timeout`; timeout returns running job handles without canceling them.
- `action="collect"` returns completed job results without waiting. - `action="collect"` returns completed job results without waiting.
- `action="cancel"` requests cancellation for requested job IDs. - `action="cancel"` requests cancellation for requested job IDs.
- Recursive use of `parallel` from inside a direct background tool worker is blocked before execution; subordinate child chats started by `call_subordinate` can use normal child-chat tools, including `parallel`. - Recursive use of `parallel` from inside a direct background tool worker is blocked before execution; numbered subordinate child chats can use normal child-chat tools, including `parallel`, to create their next-level descendants.
- Wrapped `call_subordinate` uses the same lifecycle as a top-level call. `job_id` identifies one parallel invocation, while its returned `context_id` identifies the reusable child agent for later `reset=false` calls.
- The wrapper tool does not create its own visible process-step log; each wrapped child call owns the visible log row, and the wrapper result is recorded only in model history. - The wrapper tool does not create its own visible process-step log; each wrapped child call owns the visible log row, and the wrapper result is recorded only in model history.
## Key Concepts ## Key Concepts

View file

@ -19,6 +19,7 @@
- Use shared API, WebSocket, notification, and attachment helpers where available. - Use shared API, WebSocket, notification, and attachment helpers where available.
- Do not bypass CSRF or WebSocket state-sync expectations. - Do not bypass CSRF or WebSocket state-sync expectations.
- The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch. - The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch.
- Unsent composer text is kept as a separate browser-session draft for each selected chat and restored when switching contexts; a Welcome-screen prompt must follow the chat created for its first send.
- Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text. - Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
- Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured. - Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds. - While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.

View file

@ -9,6 +9,7 @@ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g; const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/; const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
const BLOCK_TAGS = new Set(["DIV", "P", "LI"]); const BLOCK_TAGS = new Set(["DIV", "P", "LI"]);
const DRAFT_STORAGE_PREFIX = "a0:chat-draft:";
function escapeHTML(value) { function escapeHTML(value) {
return String(value ?? "") return String(value ?? "")
@ -56,6 +57,7 @@ const model = {
_historyIndex: null, _historyIndex: null,
_draft: "", _draft: "",
_historyCtxid: null, _historyCtxid: null,
_draftCtxid: null,
/** Composer + menu (bottom actions moved into dropdown) */ /** Composer + menu (bottom actions moved into dropdown) */
chatMoreMenuOpen: false, chatMoreMenuOpen: false,
progressText: "", progressText: "",
@ -68,6 +70,7 @@ const model = {
set message(value) { set message(value) {
this._message = String(value ?? ""); this._message = String(value ?? "");
this._renderEditorFromText(this._message); this._renderEditorFromText(this._message);
this._saveDraft();
}, },
toggleChatMoreMenu() { toggleChatMoreMenu() {
@ -148,15 +151,17 @@ const model = {
async sendMessage() { async sendMessage() {
this._syncMessageFromEditor(); this._syncMessageFromEditor();
const pendingMessage = this.message;
// Capture sent prompt to per-chat history (bash-style)
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
if (!chatsStore.selected && (this.message.trim() || attachmentsStore?.attachments?.length > 0)) { if (!chatsStore.selected && (this.message.trim() || attachmentsStore?.attachments?.length > 0)) {
const ctxid = await chatsStore.newChat(); const ctxid = await chatsStore.newChat();
if (!ctxid && !chatsStore.selected) return; if (!ctxid && !chatsStore.selected) return;
this.message = pendingMessage;
} }
// Capture sent prompt to per-chat history (bash-style)
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
// Delegate to the global function // Delegate to the global function
if (globalThis.sendMessage) { if (globalThis.sendMessage) {
await globalThis.sendMessage(); await globalThis.sendMessage();
@ -174,6 +179,7 @@ const model = {
mountEditor(editor) { mountEditor(editor) {
this._editorEl = editor; this._editorEl = editor;
this.setDraftContext(shortcuts.getCurrentContextId());
this._renderEditorFromText(this._message); this._renderEditorFromText(this._message);
this.adjustTextareaHeight({ target: editor }); this.adjustTextareaHeight({ target: editor });
}, },
@ -255,6 +261,7 @@ const model = {
if (!this._editorEl) return; if (!this._editorEl) return;
this._message = this._editorToMarkdown(); this._message = this._editorToMarkdown();
this._setEditorEmptyState(); this._setEditorEmptyState();
this._saveDraft();
}, },
_isInCodeBlock(target) { _isInCodeBlock(target) {
@ -579,6 +586,33 @@ const model = {
} }
}, },
setDraftContext(ctxid) {
const nextCtxid = String(ctxid || "");
if (nextCtxid === this._draftCtxid) return;
if (this._draftCtxid !== null) this._syncMessageFromEditor();
this._draftCtxid = nextCtxid;
this._historyIndex = null;
this._draft = "";
let draft = "";
if (nextCtxid) {
try { draft = sessionStorage.getItem(DRAFT_STORAGE_PREFIX + nextCtxid) || ""; } catch (_e) { /* ignore */ }
}
this._message = draft;
this._renderEditorFromText(draft);
queueMicrotask(() => this.adjustTextareaHeight());
},
_saveDraft() {
if (!this._draftCtxid) return;
try {
const key = DRAFT_STORAGE_PREFIX + this._draftCtxid;
if (this._message) sessionStorage.setItem(key, this._message);
else sessionStorage.removeItem(key);
} catch (_e) { /* ignore unavailable storage */ }
},
_loadHistory() { _loadHistory() {
let ctxid = null; let ctxid = null;
try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; } try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }

View file

@ -608,6 +608,7 @@ globalThis.newContext = newContext;
export const setContext = function (id) { export const setContext = function (id) {
if (id == context) return; if (id == context) return;
inputStore.setDraftContext(id);
context = id; context = id;
if (id) beginChatLoading(id); if (id) beginChatLoading(id);
else beginChatLoading(null); else beginChatLoading(null);