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

View file

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

View file

@ -4,13 +4,10 @@ set -e
# activate venv
. "/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
export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright
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
playwright install chromium
patchright install chromium --no-shell

View file

@ -234,7 +234,7 @@ See [MCP Setup](mcp-setup.md) for MCP setup.
## 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 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.

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?**
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.
@ -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:
```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:

View file

@ -68,12 +68,12 @@ Now when you select one of the python files in the project, you should see prope
```bash
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 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
`/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()
# 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
# Check built-in python/api/<path>.py
@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
if classes:
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:
return Response(f"API endpoint not found: {path}", 404)

View file

@ -45,6 +45,7 @@
## 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`.
- 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.
## Work Guidance

View file

@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional
from pathspec import PathSpec
from helpers import files, runtime, git
from helpers import files, runtime, git, dotenv
from helpers.localization import Localization
from helpers.print_style import PrintStyle
@ -608,6 +608,9 @@ class BackupService:
) -> Dict[str, Any]:
"""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
temp_dir = tempfile.mkdtemp()
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:
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({
"archive_path": archive_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`.
- `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/**`.
- Restoring `usr/.env` preserves the destination instance's allowed origins while restoring authentication and other portable configuration from the archive.
## Key Concepts

View file

@ -21,7 +21,7 @@ def get_dotenv_value(key: str, default: Any = None):
# load_dotenv()
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:
value = ""
dotenv_path = get_dotenv_file_path()
@ -40,4 +40,5 @@ def save_dotenv_value(key: str, value: str):
f.seek(0)
f.writelines(lines)
f.truncate()
load_dotenv()
if reload_env:
load_dotenv()

View file

@ -14,7 +14,7 @@
- `load_dotenv()`
- `get_dotenv_file_path()`
- `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`.
## 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.
- Observed side-effect areas: filesystem reads, filesystem writes, secret handling.
- 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

View file

@ -428,7 +428,13 @@ class LiteLLMTransport:
) -> LLMResult | None:
if parser.completed_response is 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(
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 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 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.
- 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"
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_PARALLEL_JOB_ID_KEY = "parallel_job_id"
@ -53,6 +54,7 @@ class ParallelJob:
tool_name: str
tool_args: dict[str, Any]
kind: JobKind
parent_agent: "Agent | None" = field(default=None, repr=False)
state: JobState = "pending"
created_at: float = field(default_factory=time.time)
started_at: float | None = None
@ -208,6 +210,7 @@ async def start_parallel_jobs(
tool_name=call.tool_name,
tool_args=call.tool_args,
kind=kind,
parent_agent=agent,
)
job_store[job.id] = job
jobs.append(job)
@ -218,6 +221,8 @@ async def start_parallel_jobs(
job.started_at = time.time()
task = DeferredTask(thread_name=THREAD_BACKGROUND)
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)
except Exception as 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:
from agent import AgentContext, AgentContextType, UserMessage
from helpers import message_queue, persist_chat
from agent import AgentContext
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)
if not parent_context:
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
message = str(args.get("message") or "").strip()
if not message:
raise ValueError("call_subordinate requires `tool_args.message`.")
profile = _validate_subordinate_profile(
parent_context.agent0,
str(args.get("profile") or args.get("agent_profile") or ""),
context_id = str(args.get("context_id") or args.get("agent_id") or "").strip()
reset = args.get("reset", False)
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 = [str(item) for item in attachments]
child_name = _subordinate_context_name(job)
worker_context = AgentContext(
config=_clone_config(parent_context.config, profile=profile),
name=child_name,
type=AgentContextType.USER,
subordinate = get_or_create_subordinate(
parent_agent,
profile=str(args.get("profile") or args.get("agent_profile") or ""),
reset=reset,
context_id=context_id,
name=str(args.get("name") or ""),
message=message,
slot=slot,
)
worker_context = subordinate.context
job.worker_context_id = worker_context.id
if 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_JOB_KEY, job.id)
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_TOOL_NAME_KEY, job.tool_name)
_copy_project(parent_context, worker_context)
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)
return await run_subordinate(parent_agent, subordinate, message, attachments)
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
def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig":
def _clone_config(config: "AgentConfig") -> "AgentConfig":
try:
cloned = replace(
return replace(
config,
knowledge_subdirs=list(config.knowledge_subdirs),
additional=dict(config.additional),
)
if profile:
cloned.profile = profile
return cloned
except Exception:
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)
except Exception:
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.
- `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.
- `call_subordinate` jobs first enforce the parent profile's delegation policy
and validate the requested profile through the sequential delegation owner,
then run in isolated child chat contexts tagged with parent-chat metadata;
they must not be added to the scheduler task list and may use normal child-chat
tools, including `parallel`.
- `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.
- 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.
- Jobs retain their actual parent agent so parallel calls made by A1 create A2 rather than falling back to a context's A0.
- Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat 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 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.

View file

@ -121,7 +121,7 @@ class PluginUpdateInfo(BaseModel):
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] = []
for path, _event in events:
path = path.replace("\\", "/")
@ -132,7 +132,11 @@ def register_watchdogs():
plugin_names.append(plugin_name)
print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names)
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]
@ -162,7 +166,7 @@ def register_watchdogs():
*expand_patterns(f"*/{projects.PROJECT_META_DIR}/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
@ -173,16 +177,21 @@ def register_watchdogs():
files.get_abs_path(subagents.USER_AGENTS_DIR),
],
patterns=[*expand_patterns(f"*/plugins/*/")],
handler=on_plugin_change,
handler=lambda events: on_plugin_change(events, frontend_reload=False),
)
@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)
if python_change:
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):
@ -582,7 +591,9 @@ def toggle_plugin(
files.write_file(enabled_file, "")
else:
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

View file

@ -17,7 +17,7 @@
- `PluginUpdateInfo` (`BaseModel`)
- Top-level functions:
- `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=...)`
- `clear_plugin_cache(plugin_names: list[str] | None=...)`
- `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.
- Config hooks receive `hook_context={"caller": caller}` with one of `ui`,
`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.
- 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`.

View file

@ -17,6 +17,7 @@
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.
- 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.
- Native local-tool descriptions reuse the tool catalog's compact prompt
description; Responses retains native-name mapping, schema derivation, and

View file

@ -205,6 +205,12 @@ class UiServerRuntime:
handlers.serve_extension_asset,
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
def register_transport_handlers(self) -> None:
@ -403,9 +409,19 @@ class UiRouteHandlers:
@requires_auth
async def serve_extension_asset(self, asset_path):
exts = files.get_abs_path("extensions/webui")
path = files.get_abs_path(exts, asset_path)
if not files.is_in_dir(path, exts):
return self._serve_extension_asset(
files.get_abs_path("extensions/webui"), asset_path
)
@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 send_file(path)

View file

@ -28,6 +28,7 @@
- `async serve_builtin_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_user_extension_asset(self, asset_path)`
- Top-level functions:
- `_positive_int_env(name: str, default: int) -> int`
- `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`.
- `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 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.

View file

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

View file

@ -23,13 +23,13 @@
- `proxy_for_token(token: str) -> VirtualDesktopEndpoint | None`
- `resize_session(token: str, width: int, height: int) -> dict[str, Any]`
- `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]`
- `find_xpra_html_root() -> Path | None`
- `_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_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`
- `_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]]`
@ -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`.
- 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

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
- 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.
## Ownership
- `plugin.yaml` and `default_config.yaml` own metadata and browser settings defaults.
- `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.
- `assets/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own browser scripts, prompts, skill guidance, hook contributions, and UI.
## Local Contracts
- 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.
- 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.
- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`.
- Keep 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.
- 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.
@ -30,8 +43,14 @@
- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
- Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
- Do not hardcode user-specific browser paths or secrets.
- 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.
- 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
@ -44,7 +63,7 @@
## Verification
- Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes.
- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path.
- 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.
## Child DOX Index

View file

@ -1,5 +1,6 @@
from helpers.api import ApiHandler, Request
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 (
get_playwright_binary,
get_playwright_cache_dir,
@ -37,5 +38,6 @@ class Status(ApiHandler):
"requires_full_browser": launch_config["requires_full_browser"],
},
"host_browser": host_browser,
"interactive_view": collect_interactive_status(),
"contexts": known_context_ids(),
}

View file

@ -14,7 +14,11 @@ from plugins._browser.helpers.config import (
TAB_SCOPE_KEY,
get_browser_config,
)
from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
from plugins._browser.helpers.runtime import (
get_runtime,
has_restorable_browser_tabs,
list_runtime_sessions,
)
FRAME_READ_TIMEOUT_SECONDS = 0.5
@ -25,7 +29,12 @@ SCREENCAST_STREAM_QUALITY = 80
SCREENSHOT_QUALITY = 92
VIEWER_TRANSPORT_SCREENCAST = "screencast"
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):
@ -75,6 +84,8 @@ class WsBrowser(WsHandler):
create_browser = self._bool(data.get("create_browser", data.get("createBrowser")))
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}
browsers: list[dict[str, Any]] = []
if runtime:
@ -87,8 +98,19 @@ class WsBrowser(WsHandler):
if opened.get("id"):
listing["last_interacted_browser_id"] = opened.get("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)
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(
"set_viewport",
active_id,
@ -103,7 +125,6 @@ class WsBrowser(WsHandler):
if existing:
existing.cancel()
viewer_id = str(data.get("viewer_id") or "")
viewer_transport = self._viewer_transport(data)
binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames")))
slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames)))
capture_scale = self._capture_scale_from_data(data)
@ -120,9 +141,16 @@ class WsBrowser(WsHandler):
capture_scale=capture_scale,
)
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)
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)
@ -136,6 +164,14 @@ class WsBrowser(WsHandler):
"tab_scope": tab_scope,
"viewer_id": viewer_id,
"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,
"slim_frames": slim_frames,
}
@ -253,7 +289,20 @@ class WsBrowser(WsHandler):
listing = await runtime.call("list")
last_interacted_browser_id = listing.get("last_interacted_browser_id")
snapshot = await self._snapshot_for_result(runtime, result)
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(
context_id,
listing.get("browsers") or [],
@ -273,7 +322,8 @@ class WsBrowser(WsHandler):
"all_browsers": all_browsers,
"tab_scope": tab_scope,
"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"),
)
@ -288,7 +338,8 @@ class WsBrowser(WsHandler):
"command": command,
"browser_id": browser_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:
@ -326,12 +377,15 @@ class WsBrowser(WsHandler):
text=str(data.get("text") or ""),
)
elif input_type == "viewport":
viewer_transport = self._viewer_transport(data)
result = await runtime.call(
"set_viewport",
browser_id,
int(data.get("width") or 0),
int(data.get("height") or 0),
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":
result = await runtime.call(
@ -582,6 +636,8 @@ class WsBrowser(WsHandler):
context_id: str,
browser_id: int | str | None,
viewer_id: str = "",
*,
viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
) -> None:
last_signature = None
while True:
@ -594,7 +650,7 @@ class WsBrowser(WsHandler):
sid,
context_id,
viewer_id=viewer_id,
frame_source=VIEWER_TRANSPORT_SNAPSHOT,
frame_source=viewer_transport,
)
last_signature = signature
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
@ -625,7 +681,7 @@ class WsBrowser(WsHandler):
browsers=browsers,
viewer_id=viewer_id,
state=state,
viewer_transport=VIEWER_TRANSPORT_SNAPSHOT,
viewer_transport=viewer_transport,
)
last_signature = signature
await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
@ -653,6 +709,36 @@ class WsBrowser(WsHandler):
active_id = browsers[0].get("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
def _state_for_browser(
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.
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.
# - shared: show Browser tabs from all active chats.
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
from typing import Any
from helpers import virtual_desktop_routes
from helpers.extension import Extension
from helpers.print_style import PrintStyle
from plugins._browser import hooks
@ -13,6 +14,7 @@ _startup_migration_thread: threading.Thread | None = None
class BrowserPlaywrightCacheMigration(Extension):
def execute(self, **kwargs):
virtual_desktop_routes.install_route_hooks()
_start_background_cache_migration()
@ -33,7 +35,7 @@ def _start_background_cache_migration() -> threading.Thread:
def _migrate_cache_safely() -> None:
try:
_log_cache_migration_result(hooks.cleanup_playwright_cache())
_log_cache_migration_result(hooks.prepare_playwright_cache())
except Exception as exc:
PrintStyle.warning("Browser Playwright cache migration failed:", exc)

View file

@ -9,6 +9,7 @@ if TYPE_CHECKING:
PLUGIN_NAME = "_browser"
MODEL_PRESET_KEY = "model_preset"
BROWSER_MODEL_ACTIVE_KEY = "_browser_model_active"
DEFAULT_HOMEPAGE_KEY = "default_homepage"
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
TAB_SCOPE_KEY = "browser_tab_scope"
@ -30,11 +31,6 @@ DEFAULT_MAX_OPEN_TABS = 32
MIN_MAX_OPEN_TABS = 1
HARD_MAX_OPEN_TABS = 50
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]:
@ -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)
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
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]:
config = normalize_browser_config(settings)
extensions = describe_browser_extensions(config)
args = list(BASE_BROWSER_ARGS)
args = ["--hide-crash-restore-bubble"]
channel: str | None = None
browser_mode = "chromium"
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 re
import subprocess
import sys
from importlib import resources
from pathlib import Path
from helpers import files
FULL_CHROMIUM_PATTERNS = (
"chromium-*/chrome-linux/chrome",
"chromium-*/chrome-win/chrome.exe",
"chromium-*/chrome-linux*/chrome",
"chromium-*/chrome-win*/chrome.exe",
)
PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR"
PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")
@ -52,16 +56,43 @@ def configure_playwright_env() -> str:
return cache_dir
def find_playwright_binary(cache_dir: Path) -> Path | None:
for pattern in FULL_CHROMIUM_PATTERNS:
binary = next(cache_dir.glob(pattern), None)
if binary and binary.exists():
return binary
return None
def find_playwright_binary(cache_dir: Path, revision: str = "") -> Path | None:
prefix = f"chromium-{revision}" if revision.isdigit() else "chromium-*"
binaries = [
binary
for pattern in FULL_CHROMIUM_PATTERNS
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:
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:
@ -72,13 +103,12 @@ def ensure_playwright_binary() -> Path:
cache_dir = configure_playwright_env()
env = os.environ.copy()
env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
install_command = ["playwright", "install", "chromium"]
subprocess.check_call(
install_command,
[sys.executable, "-m", "patchright", "install", "chromium", "--no-shell"],
env=env,
)
binary = get_playwright_binary()
if not binary:
raise RuntimeError("Playwright Chromium binary not found after installation")
raise RuntimeError("Patchright Chromium binary not found after installation")
return binary

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,12 @@
from __future__ import annotations
import importlib
import importlib.metadata
import importlib.util
import shutil
import subprocess
import sys
import threading
from pathlib import Path
from helpers import files, plugins, yaml as yaml_helper
@ -10,6 +16,7 @@ from plugins._browser.helpers.config import (
normalize_browser_config,
)
from plugins._browser.helpers.playwright import (
ensure_playwright_binary,
find_playwright_binary,
get_playwright_cache_dir,
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
_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:
entries = plugins.find_plugin_assets(
plugins.CONFIG_FILE_NAME,
@ -95,6 +107,59 @@ def cleanup_playwright_cache() -> dict:
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:
valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)]
if not valid:

View file

@ -10,6 +10,7 @@ from typing import Any
from helpers import files
from helpers.print_style import PrintStyle
from helpers.tool import Response, Tool
from plugins._browser.helpers.config import activate_browser_model
from plugins._browser.helpers.selector import get_tool_runtime
@ -74,6 +75,10 @@ class Browser(Tool):
action = "clipboard"
else:
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:
runtime = await get_runtime(self.agent.context.id, agent=self.agent)
except Exception as exc:

View file

@ -20,11 +20,15 @@
<div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
<button type="button" class="browser-tab" role="tab"
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
:aria-busy="$store.browserPage.isBrowserLoading(browser).toString()"
:title="$store.browserPage.browserTabTooltip(browser)"
@click="$store.browserPage.selectBrowser(browser.id, browser.context_id)">
<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>
</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"
:title="'Close ' + $store.browserPage.browserTabLabel(browser)"
:aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)"
@ -185,15 +189,22 @@
</form>
</div>
<div class="browser-stage" tabindex="0" @click="$el.focus()"
:class="{ 'is-annotating': $store.browserPage.annotating }"
@wheel.prevent="$store.browserPage.handleStageWheel($event)">
<canvas class="browser-frame browser-frame-canvas"
x-show="$store.browserPage.frameCanvasReady"
x-init="$store.browserPage.attachFrameCanvas($el)"
@click="$store.browserPage.sendMouse('click', $event)"
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"></canvas>
<template x-if="$store.browserPage.frameSrc">
<div class="browser-stage" tabindex="0" @click="$el.focus()"
:class="{ 'is-annotating': $store.browserPage.annotating }"
@wheel.prevent="$store.browserPage.handleStageWheel($event)">
<template x-if="$store.browserPage.isInteractiveSurface($el.parentElement)">
<iframe class="browser-interactive-frame"
:src="$store.browserPage.interactiveViewUrl"
aria-label="Browser viewport"
allow="clipboard-read; clipboard-write"
@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"
@click="$store.browserPage.sendMouse('click', $event)"
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
@ -204,7 +215,15 @@
@pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
@pointermove.stop.prevent="$store.browserPage.moveAnnotationSelection($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">
<div class="browser-annotation-box is-saved"
:style="$store.browserPage.annotationBoxStyle(annotation.rect)">
@ -223,22 +242,39 @@
:style="$store.browserPage.annotationPopoverStyle()"
@click.stop @pointerdown.stop @keydown.stop>
<div class="browser-annotation-popover-title">
<span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span>
<span x-text="$store.browserPage.annotationDraftTitle()"></span>
<span class="browser-annotation-popover-heading">
<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>
<textarea x-model="$store.browserPage.annotationDraftText" placeholder="Comment"
maxlength="1200"></textarea>
maxlength="1200" x-init="$nextTick(() => $el.focus())"></textarea>
<div class="browser-annotation-actions">
<button type="button" class="btn btn-field"
@click="$store.browserPage.cancelAnnotationDraft()">Cancel</button>
<button type="button" class="btn btn-ok"
<button type="button" class="browser-annotation-mic mic-inactive"
data-whisper-microphone title="Record annotation comment"
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()"
@click="$store.browserPage.addAnnotationComment()">Add</button>
@click="$store.browserPage.addAnnotationComment()">
<x-icon name="arrow_forward"></x-icon>
</button>
</div>
</div>
</template>
<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 }"
:style="$store.browserPage.annotationTrayStyle()"
x-transition style="display: none;"
@ -248,15 +284,15 @@
@pointercancel.window="$store.browserPage.finishAnnotationTrayDrag($event)">
<div class="browser-annotation-tray-header"
@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"
aria-label="Clear annotations" @click="$store.browserPage.clearVisibleAnnotations()">
aria-label="Clear annotations" @click="$store.browserPage.clearPendingAnnotations()">
<x-icon name="delete"></x-icon>
</button>
</div>
<div class="browser-annotation-chips">
<template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
<div class="browser-annotation-chip">
<template x-for="annotation in $store.browserPage.pendingAnnotations()" :key="annotation.id">
<div class="browser-annotation-chip" :title="annotation.title + ' — ' + annotation.url">
<span class="browser-annotation-number" x-text="annotation.index"></span>
<span class="browser-annotation-chip-text" x-text="annotation.comment"></span>
<button type="button" title="Remove annotation" aria-label="Remove annotation"
@ -267,10 +303,20 @@
</template>
</div>
<div class="browser-annotation-tray-actions">
<button type="button" class="btn btn-field"
@click="$store.browserPage.draftAnnotationsToChat()">Draft to chat</button>
<button type="button" class="btn btn-ok"
@click="$store.browserPage.sendAnnotationsToChat()">Send now</button>
<button type="button" class="browser-annotation-mic mic-inactive"
data-whisper-microphone title="Record annotation instruction"
aria-label="Record annotation instruction"
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>
<template x-if="!$store.browserPage.hasFrame() && !$store.browserPage.isBusy()">
@ -280,20 +326,19 @@
Browser</button>
</div>
</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 class="browser-bottom-status"
:class="{ 'is-active': $store.browserPage.isBusy() || $store.browserPage.error, 'has-error': $store.browserPage.error && !$store.browserPage.isBusy() }">
<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">
<template x-if="$store.browserPage.error">
<div class="browser-bottom-status has-error">
<div class="browser-error" x-text="$store.browserPage.error" :title="$store.browserPage.error"></div>
</template>
</div>
</div>
</template>
</div>
</template>
</div>
@ -528,7 +573,7 @@
flex: 0 1 210px;
position: relative;
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;
gap: 3px;
min-width: 128px;
@ -603,6 +648,20 @@
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 {
display: inline-flex;
align-items: center;
@ -997,19 +1056,31 @@
cursor: crosshair;
}
.browser-frame {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
object-fit: contain;
image-rendering: auto;
user-select: none;
background: #fff;
}
.browser-frame {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
object-fit: contain;
image-rendering: auto;
user-select: none;
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 {
position: absolute;
@ -1040,6 +1111,29 @@
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 {
display: inline-flex;
align-items: center;
@ -1091,6 +1185,17 @@
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 {
width: 100%;
min-height: 82px;
@ -1112,7 +1217,6 @@
gap: 7px;
}
.browser-annotation-actions .btn,
.browser-annotation-tray-actions .btn {
min-height: 30px;
padding: 0 10px;
@ -1145,6 +1249,7 @@
cursor: grabbing;
}
.browser-annotation-popover-close,
.browser-annotation-clear,
.browser-annotation-chip button {
display: inline-flex;
@ -1162,12 +1267,14 @@
cursor: pointer;
}
.browser-annotation-popover-close:hover,
.browser-annotation-clear:hover,
.browser-annotation-chip button:hover {
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
color: var(--color-text);
}
.browser-annotation-popover-close .material-symbols-outlined,
.browser-annotation-clear .material-symbols-outlined,
.browser-annotation-chip button .material-symbols-outlined {
font-size: 16px;
@ -1210,6 +1317,87 @@
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 {
display: flex;
align-items: center;
@ -1239,7 +1427,6 @@
background: color-mix(in srgb, #7f1d1d 22%, var(--color-background));
}
.browser-status,
.browser-error {
display: inline-flex;
align-items: center;
@ -1250,17 +1437,12 @@
white-space: nowrap;
}
.browser-status span:not(.material-symbols-outlined),
.browser-error {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.browser-status .material-symbols-outlined {
font-size: 15px;
}
.browser-modal .spinning,
.browser-panel .spinning {
display: inline-block;
@ -1286,6 +1468,10 @@
color: var(--color-text);
}
.browser-starting .material-symbols-outlined {
font-size: 1.45rem;
}
@container (max-width: 460px) {
.browser-toolbar {
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_COMMAND_TIMEOUT_MS = 45000;
const BROWSER_CONFIG_REFRESH_MS = 15000;
const BROWSER_VIEWER_TRANSPORT_INTERACTIVE = "interactive";
const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";
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 CANVAS_VIEWPORT_SETTLE_MS = 520;
const INTERACTIVE_VIEWPORT_SETTLE_MS = 320;
const SURFACE_VIEWPORT_STABLE_FRAMES = 4;
const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;
const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
@ -168,11 +170,13 @@ const model = {
frameSrc: "",
frameCanvasReady: false,
frameState: null,
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
viewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
interactiveViewUrl: "",
viewerFallbackReason: "",
tabScope: "per_context",
liveScreencastEnabled: true,
annotating: false,
annotationComments: [],
annotationHover: null,
annotationDraft: null,
annotationDraftText: "",
annotationDragRect: null,
@ -205,6 +209,8 @@ const model = {
_annotationPointer: null,
_annotationTrayDrag: null,
_annotationSequence: 0,
_annotationHoverSequence: 0,
_annotationHoverAt: 0,
_mode: "",
_surfaceMounted: false,
_surfaceSwitching: false,
@ -216,6 +222,7 @@ const model = {
_openSignature: "",
_connectSequence: 0,
_viewerToken: "",
_subscribedViewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
_contextCreatePromise: null,
_lastSelectedContextId: "",
_sessionRefreshPromise: null,
@ -435,6 +442,11 @@ const model = {
},
async contextIdForNewBrowser() {
const selectedContextId = this.normalizeContextId(chatsStore.selected);
if (selectedContextId) {
this.contextId = selectedContextId;
return selectedContextId;
}
return await this.ensureContextId();
},
@ -879,6 +891,7 @@ const model = {
resetRenderedFrame() {
this.cancelFrameRender();
this.interactiveViewUrl = "";
this.clearFrameSrc();
this.clearFrameCanvas();
this._lastFrameDimensions = null;
@ -932,6 +945,7 @@ const model = {
async syncViewportAfterSurfaceOpen(sequence = this._surfaceOpenSequence) {
if (!this.connected || !this.activeBrowserId) return;
const surfaceMode = this._mode;
await this.waitForSurfaceViewport({ sequence });
if (!this.isCurrentSurfaceOpen(sequence)) {
return;
@ -939,19 +953,24 @@ const model = {
await this.syncViewport(true, {
restartStream: this._mode === "canvas" && this.usesScreencastTransport(),
});
if (this._mode !== "canvas") return;
this.scheduleViewportSyncForSurface(sequence, 240);
this.scheduleViewportSyncForSurface(sequence, 520);
if (surfaceMode === "modal" && this.usesInteractiveTransport()) {
this.scheduleViewportSyncForSurface(sequence, INTERACTIVE_VIEWPORT_SETTLE_MS, surfaceMode);
return;
}
if (surfaceMode !== "canvas") return;
this.scheduleViewportSyncForSurface(sequence, 240, surfaceMode);
this.scheduleViewportSyncForSurface(sequence, 520, surfaceMode);
},
requestedViewerTransport() {
return this.liveScreencastEnabled
? BROWSER_VIEWER_TRANSPORT_SCREENCAST
: BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
},
normalizeViewerTransport(value = "") {
const normalized = String(value || "").trim().toLowerCase().replace("-", "_");
if (normalized === BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
}
if (normalized === BROWSER_VIEWER_TRANSPORT_SCREENCAST) {
return BROWSER_VIEWER_TRANSPORT_SCREENCAST;
}
@ -974,6 +993,101 @@ const model = {
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() {
return BROWSER_BINARY_FRAME_REQUESTS_ENABLED && BROWSER_BINARY_PAYLOADS_SUPPORTED;
},
@ -1003,9 +1117,9 @@ const model = {
return { width, height };
},
scheduleViewportSyncForSurface(sequence, delayMs = 0) {
scheduleViewportSyncForSurface(sequence, delayMs = 0, mode = this._mode) {
globalThis.setTimeout?.(() => {
if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== "canvas") {
if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== mode) {
return;
}
this.queueViewportSync(true);
@ -1093,7 +1207,8 @@ const model = {
replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers,
});
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
this.applyViewer(data);
this._subscribedViewerTransport = this.viewerTransport;
this.setActiveBrowserId(
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
data.active_browser_context_id || contextId,
@ -1108,9 +1223,7 @@ const model = {
const frameHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
this.applyViewer(data);
this.applyTabScope(data);
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
@ -1180,9 +1293,7 @@ const model = {
const stateHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
this.applyViewer(data);
this.applyTabScope(data);
const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
if (Array.isArray(data.browsers)) {
@ -1384,7 +1495,7 @@ const model = {
},
hasFrame() {
return Boolean(this.frameSrc || this.frameCanvasReady);
return Boolean(this.interactiveViewUrl || this.frameSrc || this.frameCanvasReady);
},
paintFrameBitmap(bitmap) {
@ -1419,6 +1530,10 @@ const model = {
},
frameElement() {
if (this.usesInteractiveTransport()) {
const iframe = this._stageElement?.querySelector?.(".browser-interactive-frame");
if (iframe) return iframe;
}
if (this.frameCanvasReady) {
const canvas = this.currentFrameCanvas();
if (canvas) return canvas;
@ -1467,7 +1582,7 @@ const model = {
replaceAll: Boolean(data.all_browsers),
replaceContext: !data.all_browsers,
});
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
this.applyViewer(data);
const result = data.result || {};
const resultContextId = this.normalizeContextId(
result.context_id
@ -1500,8 +1615,8 @@ const model = {
}
this.applySnapshot(data.snapshot);
if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
this.clearAnnotationsForBrowser(previousActiveBrowserId, null, previousActiveContextId);
this.cancelAnnotationDraft();
this.clearAnnotationHover();
}
const activeChanged = this.activeBrowserId
&& !this.sameBrowserTab(
@ -1510,7 +1625,12 @@ const model = {
previousActiveBrowserId,
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({
browserId: this.activeBrowserId,
contextId: this.activeBrowserContextId,
@ -1670,6 +1790,10 @@ const model = {
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) {
const title = String(browser?.title || "").trim();
const url = String(browser?.currentUrl || "").trim();
@ -1811,6 +1935,7 @@ const model = {
this.frameState = nextState;
if (previousUrl && nextUrl && previousUrl !== nextUrl) {
this.cancelAnnotationDraft();
this.clearAnnotationHover();
}
if (!this.addressFocused && nextState.currentUrl) {
this.address = nextState.currentUrl;
@ -1831,6 +1956,7 @@ const model = {
if (snapshot.state) {
this.applyActiveFrameState(snapshot.state);
}
if (this.usesInteractiveTransport()) return;
const frameBrowserId = snapshotId || this.activeBrowserId;
this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
browserId: frameBrowserId,
@ -1859,6 +1985,12 @@ const model = {
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 = "") {
const previous = this.activeBrowserId;
const previousContextId = this.activeBrowserContextId;
@ -1877,6 +2009,7 @@ const model = {
this._lastViewportKey = "";
this._lastViewport = null;
this.cancelAnnotationDraft();
this.clearAnnotationHover();
}
},
@ -2029,6 +2162,7 @@ const model = {
this.closeExtensionsMenu();
if (!nextValue) {
this.cancelAnnotationDraft();
this.clearAnnotationHover();
this.annotationDragRect = null;
this._annotationPointer = null;
} 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() {
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() {
@ -2142,21 +2293,15 @@ const model = {
this.annotationTrayPosition = null;
},
clearVisibleAnnotations() {
this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl(), this.activeBrowserContextId);
clearPendingAnnotations() {
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
if (!contextId) return;
this.annotationComments = this.annotationComments.filter(
(annotation) => this.normalizeContextId(annotation.contextId) !== contextId,
);
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 = {}) {
const viewport = this.currentViewportSize() || this._lastViewport || {};
const width = Math.max(1, Number(viewport.width || rect.width || 1));
@ -2235,6 +2380,7 @@ const model = {
const point = this.stagePointForEvent(event);
if (!point) return;
this.cancelAnnotationDraft();
this.clearAnnotationHover();
this.annotationError = "";
this._annotationPointer = {
id: event.pointerId,
@ -2251,7 +2397,11 @@ const model = {
},
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;
const point = this.stagePointForEvent(event);
if (!point) return;
@ -2297,6 +2447,63 @@ const model = {
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() {
this.annotationDraft = null;
this.annotationDraftText = "";
@ -2311,6 +2518,7 @@ const model = {
const url = this.activeAnnotationUrl();
const title = this.activeTitle;
this._annotationSequence = sequence;
this.clearAnnotationHover();
this.annotationBusy = true;
this.annotationError = "";
try {
@ -2357,7 +2565,7 @@ const model = {
addAnnotationComment() {
const comment = String(this.annotationDraftText || "").trim();
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.error = this.annotationError;
return;
@ -2375,18 +2583,18 @@ const model = {
removeAnnotationComment(annotationId) {
this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId);
if (!this.visibleAnnotations().length) {
if (!this.pendingAnnotations().length) {
this.resetAnnotationTrayPosition();
}
},
annotationChipLabel(annotation) {
const prefix = annotation?.kind === "area" ? "Area" : "Element";
return `${prefix} ${annotation?.index || ""}`.trim();
},
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}`;
},
@ -2437,45 +2645,60 @@ const model = {
return lines.join("\n");
},
buildAnnotationsPrompt() {
const annotations = this.visibleAnnotations();
buildAnnotationsPrompt(instruction = "") {
const annotations = this.pendingAnnotations();
if (!annotations.length) return "";
const lines = [
"Browser annotations",
`Page title: ${this.activeTitle}`,
`Page URL: ${this.activeAnnotationUrl()}`,
`Browser id: ${this.activeBrowserId}`,
"",
];
annotations.forEach((annotation, index) => {
const lines = ["Browser annotations"];
const spokenInstruction = String(instruction || "").trim();
if (spokenInstruction) lines.push(`Instruction: ${spokenInstruction}`);
lines.push("");
const pages = new Map();
for (const annotation of annotations) {
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(
`Annotation ${index + 1}`,
`Comment: ${annotation.comment}`,
`Selection kind: ${annotation.kind}`,
`Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
`Page ${pageIndex + 1}`,
`Page title: ${page.title || "Untitled"}`,
`Page URL: ${page.url || "about:blank"}`,
`Browser id: ${page.browserId}`,
"",
);
const metadata = this.formatAnnotationMetadata(annotation.metadata);
if (metadata) {
lines.push(metadata);
}
lines.push("");
pageAnnotations.forEach((annotation) => {
annotationNumber += 1;
lines.push(
`Annotation ${annotationNumber}`,
`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();
},
draftAnnotationsToChat() {
const prompt = this.buildAnnotationsPrompt();
draftAnnotationsToChat(instruction = "") {
const prompt = this.buildAnnotationsPrompt(instruction);
if (!prompt) return;
const existingMessage = String(chatInputStore.message || "").trim();
chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt;
chatInputStore.adjustTextareaHeight?.();
chatInputStore.focus?.();
this.clearVisibleAnnotations();
this.clearPendingAnnotations();
this.toggleAnnotationMode(false);
},
async sendAnnotationsToChat() {
const prompt = this.buildAnnotationsPrompt();
async sendAnnotationsToChat(instruction = "") {
const prompt = this.buildAnnotationsPrompt(instruction);
if (!prompt) return;
chatInputStore.message = prompt;
chatInputStore.adjustTextareaHeight?.();
@ -2488,13 +2711,52 @@ const model = {
chatInputStore.focus?.();
return;
}
this.clearVisibleAnnotations();
this.clearPendingAnnotations();
this.toggleAnnotationMode(false);
} catch (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() {
const measurement = this.surfaceViewportMeasurement();
if (!measurement) return null;
@ -2521,13 +2783,21 @@ const model = {
queueViewportSync(force = false) {
this.clearRenderedFrameIfViewportChanged();
if (force) {
if (this._viewportSyncTimer) {
globalThis.clearTimeout(this._viewportSyncTimer);
this._viewportSyncTimer = null;
}
void this.syncViewport(true);
return;
}
if (this._viewportSyncTimer) {
globalThis.clearTimeout(this._viewportSyncTimer);
return;
}
this._viewportSyncTimer = globalThis.setTimeout(() => {
this._viewportSyncTimer = null;
void this.syncViewport(force);
}, force ? 0 : VIEWPORT_SYNC_DEBOUNCE_MS);
void this.syncViewport(false);
}, VIEWPORT_SYNC_INTERVAL_MS);
},
async syncViewport(force = false, options = {}) {
@ -2542,7 +2812,7 @@ const model = {
}
const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
if (
(!restartStream && this._lastViewportKey === key)
(!force && !restartStream && this._lastViewportKey === key)
|| (
!force
&& !restartStream
@ -2555,11 +2825,13 @@ const model = {
return;
}
try {
this.syncInteractiveViewSize();
await websocket.emit("browser_viewer_input", {
context_id: contextId,
browser_id: this.activeBrowserId,
viewer_id: this._viewerToken,
input_type: "viewport",
viewer_transport: this.viewerTransport,
width: viewport.width,
height: viewport.height,
restart_stream: restartStream && this.usesScreencastTransport(),
@ -2739,6 +3011,9 @@ const model = {
this._viewerToken = "";
this.switchingBrowserId = null;
this.viewerTransport = this.requestedViewerTransport();
this._subscribedViewerTransport = this.viewerTransport;
this.interactiveViewUrl = "";
this.viewerFallbackReason = "";
this.tabScope = "per_context";
this._surfaceMounted = false;
this._surfaceSwitching = false;
@ -2750,6 +3025,7 @@ const model = {
this.annotationError = "";
this.cancelAnnotationDraft();
this.cancelAnnotationSelection();
this.clearAnnotationHover();
this.resetAnnotationTrayPosition();
if (this.contextId) {
try {
@ -3010,13 +3286,6 @@ const model = {
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);

View file

@ -218,13 +218,13 @@
class="browser-config-field-help"
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
class="browser-config-field-help"
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>
</label>

View file

@ -2,7 +2,7 @@
## 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.
## Ownership
@ -12,7 +12,7 @@
- `api/commands.py` owns the Commands API actions used by the WebUI.
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`, 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.
- `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.
@ -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 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.
- 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.
- `/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

View file

@ -12,46 +12,54 @@
<template x-if="$store.commandsSlash.loading">
<div class="commands-slash-loading">
<x-icon class="spinning" name="progress_activity"></x-icon>
<span>Loading slash commands...</span>
<span x-text="$store.commandsSlash.loadingLabel"></span>
</div>
</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">
<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"
class="commands-slash-item"
:class="{ active: index === $store.commandsSlash.selectedIndex }"
@mouseenter="$store.commandsSlash.selectedIndex = index"
@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-name">
<span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
<div class="commands-slash-item-name"
: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>
<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 class="commands-slash-item-description" x-text="command.description"></div>
<template x-if="command.argument_hint">
<div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
<div class="commands-slash-item-description" x-text="item.description"></div>
<template x-if="$store.commandsSlash.mode !== 'reference' && item.argument_hint">
<div class="commands-slash-item-hint" x-text="item.argument_hint"></div>
</template>
</button>
</template>
</div>
</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-copy">
No matching slash commands.
<span x-text="$store.commandsSlash.emptyLabel"></span>
</div>
<button type="button"
class="commands-slash-create"
@mousedown.prevent
@click.prevent="$store.commandsSlash.openCreateCommand()">
<x-icon name="add"></x-icon>
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
</button>
<template x-if="$store.commandsSlash.mode !== 'reference'">
<button type="button"
class="commands-slash-create"
@mousedown.prevent
@click.prevent="$store.commandsSlash.openCreateCommand()">
<x-icon name="add"></x-icon>
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
</button>
</template>
</div>
</template>
</div>
@ -113,10 +121,26 @@
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 {
color: var(--color-highlight);
}
.commands-slash-item-name.is-reference .commands-slash-prefix {
color: inherit;
}
.commands-slash-scope {
padding: 0.18rem 0.45rem;
border-radius: 999px;
@ -126,6 +150,55 @@
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 {
color: var(--color-text-secondary);
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");
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)
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
def scope_fixture() -> ScopeFixture:
suffix = uuid.uuid4().hex[:8]

View file

@ -1,5 +1,5 @@
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 chatInputStore } from "/components/chat/input/input-store.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";
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) {
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) {
void toastFrontendError(message, "Commands");
}
@ -74,6 +128,13 @@ const model = {
loading: false,
applying: false,
commands: [],
references: [],
referenceContextId: null,
referenceDirectoryKey: "",
referenceRoot: "",
referenceCatalog: [],
referenceFiles: [],
referenceLoadGeneration: 0,
contextScope: { project_name: "" },
lastContextId: "",
active: false,
@ -81,6 +142,10 @@ const model = {
query: "",
rawArguments: "",
rawMessage: "",
mode: "",
referenceStart: 0,
referenceEnd: 0,
referenceRange: null,
selectedIndex: 0,
boundInput: 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() {
const commands = this.filteredCommands;
if (!commands.length) return 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() {
const name = sanitizeCommandName(this.query || "");
return name ? `Create /${name}` : "Create slash command";
@ -146,6 +236,15 @@ const model = {
this.query = "";
this.rawArguments = "";
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.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) {
this.ensureBindings();
this.dismissed = false;
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);
this.referenceRange = null;
this.mode = parsed.active ? "slash" : "";
this.active = parsed.active;
this.query = parsed.query;
this.rawArguments = parsed.rawArguments;
@ -297,33 +580,79 @@ const model = {
return;
}
if (event.key === "Enter" && this.selectedCommand) {
if (event.key === "Enter" && this.selectedItem) {
event.preventDefault();
event.stopPropagation();
void this.applySelection(this.selectedCommand);
void this.applySelectedItem(this.selectedItem);
}
},
ensureSelection() {
const commands = this.filteredCommands;
if (!commands.length) {
const items = this.filteredItems;
if (!items.length) {
this.selectedIndex = 0;
return;
}
if (this.selectedIndex >= commands.length) {
if (this.selectedIndex >= items.length) {
this.selectedIndex = 0;
}
},
moveSelection(delta) {
const commands = this.filteredCommands;
if (!commands.length) return;
const items = this.filteredItems;
if (!items.length) return;
const nextIndex =
(this.selectedIndex + delta + commands.length) % commands.length;
(this.selectedIndex + delta + items.length) % items.length;
this.selectedIndex = nextIndex;
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() {
requestAnimationFrame(() => {
document

View file

@ -14,6 +14,7 @@
## Local Contracts
- 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.
- 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.

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import threading
import time
import urllib.request
@ -22,8 +23,22 @@ LIBREOFFICE_RUNTIME_PACKAGES = (
XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
XPRA_KEYRING_FILE = Path("/usr/share/keyrings/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 = (
*LIBREOFFICE_RUNTIME_PACKAGES,
GTK_RUNTIME_PACKAGE,
"xpra-common",
"xpra-server",
"xpra-client",
"xpra-client-gtk3",
@ -54,10 +69,6 @@ RUNTIME_PACKAGES = (
"fonts-noto-cjk",
"fonts-noto-color-emoji",
)
OPTIONAL_RUNTIME_PACKAGES = (
"xpra-client",
"xpra-client-gtk3",
)
RETIRED_RUNTIME_PACKAGES = (
"firefox-esr",
)
@ -202,6 +213,17 @@ def _package_installed(package: str) -> bool:
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(
removed: list[str],
errors: list[str],
@ -223,6 +245,8 @@ def _purge_packages(
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"):
return
if not _ensure_kali_gtk_runtime(installed, errors):
return
missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
if not missing:
return
@ -230,8 +254,7 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
if not _apt_update(errors):
return
required_missing, optional_missing = _split_runtime_packages(missing)
required_xpra_missing = [package for package in required_missing if package.startswith("xpra")]
required_xpra_missing = [package for package in missing if package.startswith("xpra")]
if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
previous_error_count = len(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)]
if not missing:
return
required_missing, optional_missing = _split_runtime_packages(missing)
if required_missing and not _install_runtime_packages(required_missing, installed, errors):
return
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)
if missing:
_install_runtime_packages(missing, installed, errors)
def _split_runtime_packages(packages: list[str]) -> tuple[list[str], list[str]]:
optional = [package for package in packages if package in OPTIONAL_RUNTIME_PACKAGES]
required = [package for package in packages if package not in OPTIONAL_RUNTIME_PACKAGES]
return required, optional
def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool:
if (
GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES
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(
packages: list[str],
installed: list[str],
errors: list[str],
*,
optional: bool = False,
) -> 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:
installed.extend(packages)
return True
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)
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:
result = _run_apt_command(["apt-get", "update"], timeout=300)
if result.returncode == 0:
@ -359,21 +407,8 @@ def _xpra_repository_source() -> str:
codename = os_release.get("VERSION_CODENAME", "")
arch = _dpkg_architecture()
if os_id == "kali" and arch == "amd64":
uri = "https://xpra.org/beta"
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"
uri = "https://xpra.org"
suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie"
return (
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.
- `api/goal.py` owns the WebUI JSON API for reading, editing, pausing, resuming, and deleting goals.
- `commands/` owns the `/goal` slash command contributed to `_commands`.
- `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
- `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/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.
@ -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.
- 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.
- 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.
- 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.

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
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")
def test_goal_webui_uses_shared_hour_aware_duration_formatter():
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 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 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.
## 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]:
normalized = dict(body)
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 = normalized.get("reasoning")
if isinstance(reasoning, dict):
@ -775,6 +794,7 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
latest_error: Any = None
text_pieces: list[str] = []
latest_usage: dict[str, Any] | None = None
completed_items: dict[int, dict[str, Any]] = {}
for event in iter_sse_events(response):
data = event.get("data")
if not data:
@ -789,6 +809,11 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
latest_error = parsed
continue
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")
if isinstance(usage, dict):
latest_usage = usage
@ -796,6 +821,16 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
if isinstance(candidate, dict):
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:
text = "".join(text_pieces)
if latest_response is not None:

View file

@ -16,6 +16,8 @@
- Keep dependency installation and model bootstrap on Docker/bootstrap paths.
- 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.
## Work Guidance

View file

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

View file

@ -66,6 +66,7 @@ const model = {
packageVersion: "",
providerCleanup: null,
microphoneInput: null,
finalTranscriptHandler: null,
isProcessingClick: false,
devices: [],
selectedDevice: "",
@ -176,24 +177,26 @@ const model = {
},
updateMicrophoneButtonUI() {
const microphoneButton = document.getElementById("microphone-button");
if (!microphoneButton) return;
const status = this.enabled ? this.micStatus : "disabled";
const label = MicStatusLabels[status] || "Microphone";
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,
),
const microphoneButtons = document.querySelectorAll(
"[data-whisper-microphone], #microphone-button",
);
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() {
@ -268,13 +271,16 @@ const model = {
}
},
async handleMicrophoneClick() {
async handleMicrophoneClick(finalTranscriptHandler = null) {
if (this.isProcessingClick) return;
this.finalTranscriptHandler =
typeof finalTranscriptHandler === "function" ? finalTranscriptHandler : null;
this.isProcessingClick = true;
try {
await this.ensureStatusLoaded({ force: true, suppressError: false });
if (!this.enabled) {
this.finalTranscriptHandler = null;
globalThis.justToast?.("Whisper STT is disabled.", "info");
return;
}
@ -309,7 +315,7 @@ const model = {
const input = new MicrophoneInput(this, async (text, isFinal) => {
if (isFinal) {
await this.sendVoiceMessage(text);
await this.deliverVoiceMessage(text);
}
});
@ -318,6 +324,17 @@ const model = {
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) {
const message = String(text || "").trim();
if (!message) return;
@ -347,6 +364,8 @@ const model = {
this.microphoneInput = null;
}
this.finalTranscriptHandler = null;
this.notifyStatusChange();
},

View file

@ -1,9 +1,11 @@
### call_subordinate
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
- `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
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
do not repeat the same solving work or call extra tools after a sufficient subordinate result
example:

View file

@ -10,7 +10,7 @@ Rules:
- never nest `parallel`
- 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_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`
- 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`

View file

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

View file

@ -4,7 +4,9 @@ import zipfile
from pathlib import Path
import pytest
from dotenv import dotenv_values
from helpers import dotenv
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"]
@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
async def test_pattern_scan_can_run_without_file_limit(tmp_path):
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"]
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(
("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():
class FakeResponse:
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():
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 "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES
assert "libreoffice-calc" 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):
@ -1756,8 +1764,8 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
assert errors == []
assert installed == ["xpra"]
assert keyring.read_bytes() == b"xpra-key"
assert "URIs: https://xpra.org/beta" in source.read_text(encoding="utf-8")
assert "Suites: sid" in source.read_text(encoding="utf-8")
assert "URIs: https://xpra.org\n" 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[-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 "Suites: trixie" in source_text
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 = []
installed_state = {
"xpra-server": True,
"xpra-client": 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(
@ -1831,16 +1834,18 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
monkeypatch.setattr(
desktop_hooks,
"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_version", lambda package: "6.5.2-r0-1")
def fake_run(command, **kwargs):
calls.append(command)
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"]:
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="")
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)
assert installed == []
assert installed == ["xpra-client", "xpra-client-gtk3"]
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):

View file

@ -48,6 +48,7 @@ class _FakeContext:
self.id = "ctx"
self.data = {}
self.log = _FakeLog()
self.task = None
def get_data(self, key: str, recursive: bool = True):
return self.data.get(key)
@ -60,14 +61,29 @@ class _FakeAgent:
def __init__(self) -> None:
self.context = _FakeContext()
self.agent_name = "A0"
self.number = 0
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.alive = alive
self._result = result
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):
return self.ready
@ -81,6 +97,12 @@ class _FakeDeferredTask:
def kill(self):
self.killed += 1
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:
@ -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."
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:
with pytest.raises(ValueError, match="cannot be nested"):
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)
@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
async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None:
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
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):
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(
sys.modules,
"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 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
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(
[
{
@ -274,15 +276,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
"type": "response.completed",
"response": {
"id": "resp_1",
"output": [
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "lookup",
"arguments": '{"q":"a0"}',
}
],
"output": [],
},
},
]
@ -315,6 +309,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
assert stream.closed is False
assert result.response_id == "resp_1"
assert result.function_calls[0].call_id == "call_1"
assert result.function_calls[0].arguments == {"q": "a0"}
@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():
prompt = (PROJECT_ROOT / "prompts" / "agent.system.tool.response.md").read_text(
encoding="utf-8"
)
def test_response_tool_native_contract_stays_provider_neutral(monkeypatch):
prompt_root = PROJECT_ROOT / "agents" / "agent0" / "prompts"
prompt = (prompt_root / "agent.system.tool.response.md").read_text(encoding="utf-8")
description = tool_policy.tool_prompt_description(
prompt,
"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 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():

View file

@ -183,11 +183,12 @@ def test_chat_bar_keeps_existing_send_and_mic_icon_contract() -> None:
).read_text(encoding="utf-8")
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 ':title="$store.chatInput.sendButtonTitle"' in chat_bar
assert 'id="microphone-button"' in mic_extension
assert "data-whisper-microphone" in mic_extension
assert "<svg" in mic_extension
assert "material-symbols-outlined" not in mic_extension
assert "buttonIcon" not in mic_extension

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
@ -10,10 +11,28 @@ from helpers.errors import RepairableException
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):
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:
@ -45,10 +64,17 @@ class _FakeSubAgent:
DATA_NAME_SUPERIOR = "_superior"
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.agent_name = f"A{number}"
self.config = config
self.context = context
self.context.agent0 = self
self.data = {}
self.history = SimpleNamespace(new_topic=lambda: None)
self.messages = []
@ -56,6 +82,9 @@ class _FakeSubAgent:
def set_data(self, key: str, value):
self.data[key] = value
def get_data(self, key: str):
return self.data.get(key)
def hist_add_user_message(self, 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"),
),
)
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(
@ -118,13 +153,122 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
)
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.additional == {"context_id": child.context.id}
assert child.number == 1
assert child.config.profile == "developer"
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
async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None:
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)
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.asyncio
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 helpers import projects, subagents
from agent import Agent, AgentContext, UserMessage
from helpers import message_queue, persist_chat, projects, subagents
from helpers.errors import RepairableException
from helpers.tool import Tool, Response
from initialize import initialize_agent
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]:
project = projects.get_context_project_name(agent.context) if agent.context else None
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):
async def execute(self, message="", reset="", **kwargs):
requested_profile = _validate_subordinate_profile(
self.agent, kwargs.get("profile", kwargs.get("agent_profile", ""))
async def execute(self, message="", reset="", context_id="", **kwargs):
attachments = kwargs.get("attachments")
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)
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()
result = await run_subordinate(self.agent, subordinate, message, attachments)
# hint to use includes for long responses
additional = None
additional = {"context_id": subordinate.context.id}
if len(result) >= save_tool_call_file.LEN_MIN:
hint = self.agent.read_prompt("fw.hint.call_sub.md")
if hint:
additional = {"hint": hint}
additional["hint"] = hint
# result
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.
- Classes:
- `Delegation` (`Tool`)
- `async execute(self, message=..., reset=..., **kwargs)`
- `async execute(self, message=..., reset=..., context_id=..., **kwargs)`
- `get_log_object(self)`
- Top-level functions:
- `_subordinate_profile_labels(agent: Agent) -> dict[str, str]`
- `_validate_subordinate_profile(agent: Agent, profile: str) -> str`
- `get_or_create_subordinate(...) -> Agent`
- `run_subordinate(...) -> str`
## Runtime Contracts
@ -26,12 +28,20 @@
- `Delegation` defines `execute(...)`.
- 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.
- 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`.
## 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.
## Work Guidance
@ -46,6 +56,7 @@
- Related tests observed by source search:
- `tests/test_default_prompt_budget.py`
- `tests/test_subagent_profiles.py`
- `tests/test_parallel_tool.py`
## 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="collect"` returns completed job results without waiting.
- `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.
## Key Concepts

View file

@ -19,6 +19,7 @@
- Use shared API, WebSocket, notification, and attachment helpers where available.
- 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.
- 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.
- 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.

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 FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
const BLOCK_TAGS = new Set(["DIV", "P", "LI"]);
const DRAFT_STORAGE_PREFIX = "a0:chat-draft:";
function escapeHTML(value) {
return String(value ?? "")
@ -56,6 +57,7 @@ const model = {
_historyIndex: null,
_draft: "",
_historyCtxid: null,
_draftCtxid: null,
/** Composer + menu (bottom actions moved into dropdown) */
chatMoreMenuOpen: false,
progressText: "",
@ -68,6 +70,7 @@ const model = {
set message(value) {
this._message = String(value ?? "");
this._renderEditorFromText(this._message);
this._saveDraft();
},
toggleChatMoreMenu() {
@ -148,15 +151,17 @@ const model = {
async sendMessage() {
this._syncMessageFromEditor();
// Capture sent prompt to per-chat history (bash-style)
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
const pendingMessage = this.message;
if (!chatsStore.selected && (this.message.trim() || attachmentsStore?.attachments?.length > 0)) {
const ctxid = await chatsStore.newChat();
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
if (globalThis.sendMessage) {
await globalThis.sendMessage();
@ -174,6 +179,7 @@ const model = {
mountEditor(editor) {
this._editorEl = editor;
this.setDraftContext(shortcuts.getCurrentContextId());
this._renderEditorFromText(this._message);
this.adjustTextareaHeight({ target: editor });
},
@ -255,6 +261,7 @@ const model = {
if (!this._editorEl) return;
this._message = this._editorToMarkdown();
this._setEditorEmptyState();
this._saveDraft();
},
_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() {
let ctxid = null;
try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }

View file

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