Skip to content

feat(agents): custom ACP agent support - #2433

Merged
Dani Akash (DaniAkash) merged 19 commits into
mainfrom
feat/custom-acp-agents
Aug 25, 2026
Merged

feat(agents): custom ACP agent support#2433
Dani Akash (DaniAkash) merged 19 commits into
mainfrom
feat/custom-acp-agents

Conversation

@DaniAkash

Copy link
Copy Markdown
Contributor

What

Lets a user connect any ACP (Agent Client Protocol) agent, not just the two built-ins (Claude Code, Codex), by supplying a launch command through the AI settings UI. The acpx-ai-provider engine already runs arbitrary ACP agents over stdio; this exposes that through a data-driven server model and a settings dialog.

How it works

A new custom agent type carries a CustomAcpAgentConfig (command, optional env, fullAccessModes, reasoningEffortKey, systemPromptAppend, icon) stored as a nullable JSON column on acp_agents. The built-in claude/codex agents keep their curated behavior unchanged; custom is a strictly additive third path.

Server

  • Named migration 0007_add_custom_acp_agents adds the custom_config column (plus the bootstrap DDL + migration-history mirror for packaged builds).
  • The launcher runs a custom agent's full command line as given, quote-aware shell-split into argv, with its env at the process-launch boundary (no bundled-bun wrapping).
  • The agent policy is data-driven for custom agents: a per-agent registry id (so concurrent custom agents never collide), stored env, system-prompt append, and full-access modes. When no full-access modes are configured the agent runs in its own default permission mode instead of forcing one. The reasoning-effort key comes from config.
  • POST /agents cross-validates customConfig (required for custom, rejected for built-ins); a new PUT /agents/:agentId edits an agent and refreshes its running sessions. POST /acpx/probe accepts an explicit command so a not-yet-saved agent can be validated.
  • Custom chats route through the existing ACP path.

App

  • A dashed "Custom ACP agent" tile in the settings templates opens an add/edit dialog: name, a single full-command field, working directory, a Test-connection probe (enumerates the agent's models and reasoning efforts before saving), and an Advanced section (env, full-access modes, reasoning key, system prompt, icon).
  • A nested "Popular agents" helper offers verified starting points (opencode, Hermes, OpenClaw, pi; Antigravity is docs-only since it has no first-party ACP command yet) that fill the command field.
  • Custom agents render their stored icon and command in the agents list, get an edit action, and appear in the chat provider selector with a distinct icon/label.

Tests

Server: command splitter, config round-trip through the real migration, create cross-validation + PUT, launcher run-as-given argv + env, policy (per-agent registry id, stored env/prompt/modes, empty full-access), and probe. App: form-helper parsing/config builder and card rendering. Full monorepo typecheck, biome, and the touched suites are green.

Follow-ups

  • Real brand logos for the popular-agents list (from svgl.app, where available: opencode, Antigravity, OpenClaw) in place of the letter/emoji marks.
  • Optional encrypted storage for custom-agent env secrets; today it is stored locally in plaintext with a clear warning, matching the built-ins' existing full-filesystem access.

Widen the agent type enum to include 'custom', add a nullable custom_config
JSON column to acp_agents (named migration 0007) plus the bootstrap DDL and
migration-history mirror, and round-trip a validated CustomAcpAgentConfig
through the agent definition and store (with a new update method).
Add a quote-aware command splitter and a launcher branch that runs a custom
agent's full command line as given (no bundled-bun wrapping). Make the agent
policy data-driven for custom agents: a per-agent registry id, stored env,
system-prompt append, and full-access modes. Skip the full-access mode step
when none are configured, and read the reasoning-effort key from config.
Include 'custom' in the conversation target-type enum and dispatch custom
chat requests to processAcpMessage alongside claude and codex.
Extend POST /agents to accept and cross-validate customConfig (required for
custom agents, rejected for built-ins), and add PUT /agents/:agentId so a
custom agent's command can be edited. Refresh running sessions on update so
the next turn re-spawns with the new command.
Accept an optional command/env/cwd on POST /acpx/probe (command required for
type 'custom') so the settings UI can validate a not-yet-saved agent and
enumerate its models before the user commits.
Add a command-splitter suite and custom-agent cases across the store (config
round-trip + update through the real migration), agent routes (create
cross-validation + PUT), launcher (run-as-given argv, env injection, no
bundled-bun wrapping), policy (per-agent registry id, stored env/prompt/modes,
empty full-access), and probe (command required, forwarded).
Add customConfig to the app AcpAgent type, extend the create payload, add
useUpdateAcpAgent (PUT), and a useProbeCustomAgent mutation that probes a
not-yet-saved custom command for the Test-connection flow.
Add the add/edit custom-agent dialog (name, full command line, working dir,
Test-connection probe, advanced env/modes/prompt/icon), a nested Popular ACP
agents helper backed by a verified registry (opencode, hermes, openclaw, pi;
antigravity as docs-only), and the dashed 'Custom ACP agent' template tile.
Wire the custom-agent dialog into the AI settings pane (template tile + edit
from the agents list), render a custom agent's stored icon and command in its
card, and give custom agents a distinct icon/label fallback in the adapter
helpers and the chat provider selector.
Add unit tests for the env/CSV parsing and config builder, and card cases for
the custom-agent command, icon, and edit button.
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds configurable custom ACP agents across persistence, API routes, runtime policy, process launching, probing, and the AI settings UI.

  • Adds the custom agent type and persisted launch configuration.
  • Adds create, edit, probe, session-refresh, and chat-routing support.
  • Adds custom-agent settings, templates, icons, and provider selection.
  • Preserves Windows path separators in the command-line parser and covers quoted and unquoted paths with regression tests.

Confidence Score: 5/5

The PR appears safe to merge because the previously reported Windows command-path failure is fixed and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/browseros-agent/apps/server/src/lib/agents/host-acp/parse-command.ts Splits custom launch commands while preserving Windows backslashes; the previously reported path corruption is fixed.
packages/browseros-agent/apps/server/src/lib/agents/host-acp/launcher.ts Adds direct argv and environment launching for custom ACP agents.
packages/browseros-agent/apps/server/src/api/routes/agents.ts Adds custom-config validation and an agent update endpoint that refreshes active sessions.
packages/browseros-agent/apps/server/src/lib/agents/acp/acp-agent-policy.ts Derives custom-agent registry identity, environment, permissions, prompt, and reasoning configuration.
packages/browseros-agent/apps/app/screens/ai-settings/CustomCodingAgentDialog.tsx Adds the create/edit and connection-probe workflow for custom ACP agents.
packages/browseros-agent/packages/shared/src/schemas/agent.ts Extends shared agent schemas with the custom type and validated custom launch configuration.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  UI[Custom agent settings] --> Probe[POST /acpx/probe]
  UI --> Agents[POST or PUT /agents]
  Agents --> Store[(ACP agent store)]
  Store --> Runtime[ACP runtime policy]
  Probe --> Parser[Command-line parser]
  Runtime --> Parser
  Parser --> Launcher[Process launcher]
  Launcher --> ACP[Custom ACP agent over stdio]
Loading

Reviews (2): Last reviewed commit: "fix(server): keep Windows backslash path..." | Re-trigger Greptile

Comment thread packages/browseros-agent/apps/server/src/lib/agents/host-acp/parse-command.ts Outdated
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

✅ Tests passed: 2504/2507

Ran 14 of 15 suites (1 not affected by this change).

Suite Passed Failed Skipped
server-agent 298/298 0 0
server-api 170/170 0 0
server-tools 254/254 0 0
server-browser 10/10 0 0
server-integration 10/10 0 0
server-lib 141/141 0 0
server-root 38/41 0 3
agent 327/327 0 0
claw-app 392/392 0 0
claw-onboard 86/86 0 0
build 44/44 0 0
release n/a n/a not affected
claw-server-rust 625/625 0 0
claw-server-rust-quality passed 0 0
claw-mcp 109/109 0 0

passed = ran successfully but emits no JUnit counts (a lint/format gate).

View workflow run

Treat backslashes as literal in the custom-agent command splitter so Windows
paths like C:\Users\me\agent.exe survive; use quotes for arguments with
spaces. Previously every backslash was consumed as an escape, launching a
nonexistent executable path on Windows.
@DaniAkash

Copy link
Copy Markdown
Contributor Author

Greptile (@greptileai)

Render svgl brand marks for opencode (currentColor), OpenClaw, and Antigravity
in neutral tiles, with monograms for pi and Hermes (no svgl logo). Show each
suggested command as a proper code chip so it reads as code, not description.
A GUI-launched server inherits a minimal PATH that omits shell-profile
additions (homebrew, nvm/fnm, ~/.local/bin), so a custom agent's binary could
be unfindable even when it works in a terminal. Ask the user's login shell for
its PATH once (cached, timeout-guarded) and prepend it for custom command
spawns. Windows inherits the full registry PATH already, so it is unaffected.
Consolidate agent brand marks (Claude, Codex, opencode, OpenClaw, Antigravity,
pi) into a shared module reusing the project's svg components, and use them in
the provider templates, agent cards, and the Popular-agents picker. Picking a
popular agent now carries its logo onto the saved agent. Removed the manual
Icon text field. Hermes keeps a monogram until its logo asset is added.
…ode color

Render brand marks in the newtab + sidepanel provider selector by threading a
brand key through the chat targets. Add the Hermes icon (object-contain, from
the provided asset) and give opencode its own dark/white ink instead of the
tile accent color.
…eader

The newtab search-bar pill and input avatar and the sidepanel header rendered a
generic Bot for the selected agent. Resolve the agent's brand mark from its
brand key in all three, falling back to Bot when none.
The header agent selector read as a static title once it showed a brand logo.
Add a border, an active (foreground) name, and a rotating chevron caret so it
reads as a dropdown, matching the newtab pill.
@DaniAkash
Dani Akash (DaniAkash) merged commit 9de840c into main Aug 25, 2026
24 checks passed
@DaniAkash
Dani Akash (DaniAkash) deleted the feat/custom-acp-agents branch August 25, 2026 14:19
Dani Akash (DaniAkash) added a commit that referenced this pull request Aug 27, 2026
* feat(mcp): upgrade both MCP servers to the 2026-07-28 revision (#2290)

* feat(server): migrate the MCP server to the v2 SDK with dual-era 2026-07-28 support (#2272)

* feat(server): migrate the MCP server to the v2 SDK with dual-era 2026-07-28 support

Replace @hono/mcp and the server-side @modelcontextprotocol/sdk 1.x with @modelcontextprotocol/server and @modelcontextprotocol/hono 2.0, serving both the legacy initialize handshake and the modern 2026-07-28 protocol per request via a stateless WebStandardStreamableHTTPServerTransport. v2 derives tools/list JSON schemas only from Zod v4, so the local browser and filesystem tool schemas move to the zod/v4 subpath of the installed zod 3.25; the Klavis connector schemas are bridged through fromJsonSchema until the Strata client migration. Drop the logging capability and the SetLevel handler, both removed in 2026-07-28. The Strata MCP client stays on the 1.x SDK for a follow-up change.

Refs #2168

* fix(ci): call the real tabs tool in the MCP integration test

The test invoked a non-existent tool name and passed only because the 1.x server returned a lenient error result for unknown tools. The v2 server correctly rejects unknown tools (its sibling test asserts the throw), so call the registered tabs tool with a valid action instead.

* refactor(server): migrate the Strata MCP client to the v2 client SDK (#2274)

Move the Klavis Strata MCP client and the remaining Klavis type imports from the 1.x @modelcontextprotocol/sdk to @modelcontextprotocol/client 2.0, so apps/server no longer depends on the legacy SDK at runtime. The SDK stays as a devDependency for the integration and end-to-end tests that simulate a legacy client against the v2 server. No behavior change; the connector schema bridge is unchanged and can be cleaned up in a follow-up.

Refs #2168

* chore(claw-server): upgrade rmcp to 3.x with a legacy protocol version pin (#2275)

* chore(claw-server): upgrade rmcp to 3.x with a legacy protocol version pin

Bump rmcp from 2.1 to 3.x. rmcp 3.0 changes the ServerHandler call_tool return type to the MRTR-aware CallToolResponse, so wrap the existing CallToolResult with .into() in the production and reference handlers and the serving test. rmcp 3.x advertises the modern 2026-07-28 protocol by default, but the claw-server's sessionless state model for that revision is not in place yet, so override supported_protocol_versions() on the production handler to pin to legacy versions (2025-11-25 and earlier) and keep this upgrade behavior-neutral. The pin is lifted together with the handle-based session model. All crates compile, cargo test passes, clippy and fmt clean.

Refs #2168

* test(claw-server): prove the legacy protocol pin rejects the modern revision

Add a serving-level test that initializes against a legacy-pinned handler with protocolVersion 2026-07-28 and asserts the server negotiates down to a legacy revision rather than agreeing to the modern one. This exercises the same supported_protocol_versions() override the claw-server uses and confirms rmcp consults it during initialize negotiation.

* feat(claw-server): serve the modern MCP revision with a session handle (#2278)

* feat(claw-server): serve the modern MCP revision with a session handle

Advertise the 2026-07-28 revision alongside the legacy revisions. rmcp serves 2026-07-28 clients statelessly, so the per-agent tab-group session, previously keyed on the mcp-session-id header that revision removes, is now carried as a server-minted handle passed as an ordinary `session` tool argument: minted as a UUID when absent, returned in structuredContent, and looked up on later calls so the agent keeps its tab group. Legacy clients and stdio keep the existing session model unchanged, and idle sweeping reaps handle sessions independent of transport close.

* test(claw-server): expect the injected session arg in the name_session golden schema

* fix(claw-server): only reuse live server-minted session handles

Resolve a modern session by reusing a handle only when it maps to a live session; any absent or unrecognized handle now mints a fresh server-generated handle instead of being minted under the caller-supplied value. A caller can no longer choose or seed a session id, so it cannot land on another agent's ownership by presenting a chosen id, and because every mint uses a fresh unique id, two concurrent calls can no longer both mint under the same id and orphan one session. Continuity flows through the returned handle.

* feat(server): give /mcp clients a server-minted session identity handle (#2289)

* feat(server): give /mcp clients a server-minted session identity handle

Expose an optional session string argument on each /mcp browser tool. The server mints a UUID when the caller omits it and returns it in the result's structuredContent so an agent can thread it back on later calls to identify its own session; a supplied handle is echoed. The handle is stripped before the tool runs, so tool logic (including the one strict-schema tool) and the internal agent's shared tool schemas stay unchanged. It is gated behind an opt-in registration flag so only the /mcp surface carries it, and it is attributed in the per-tool execution metric. Identity only: no isolation, no per-session state, no change to browser behavior.

* fix(server): attribute the /mcp session handle on the per-call log

The session handle was added to the aggregated `tool_executed` metric event, whose rollup keeps only tool name, source, and success, so the handle was discarded. Move the attribution to the per-call log context instead, which is the right home for a per-session value and avoids the unbounded cardinality of a UUID in an aggregated metric. The returned handle in structuredContent is unchanged.

* fix(server): serve the 2026-07-28 revision on /mcp and harden the endpoint (#2298)

* fix(server): deliver the /mcp tool abort signal from the request context

The browser tool wrapper read the abort signal from a top-level `extra.signal`, which does not exist on the v2 SDK's tool handler context, so it was always undefined and cancellation never reached the tool over /mcp. Read it from `extra.mcpReq.signal` where the SDK actually places it. The internal AI-SDK agent path threads its own signal and is unaffected. Adds a test that a pre-aborted signal delivered via mcpReq.signal makes a waiting tool abort.

* feat(server): reject browser-originated /mcp requests via Sec-Fetch-Site

The /mcp endpoint is intentionally reachable across the LAN, so a loopback bind is not an option. Browsers always send a Sec-Fetch-Site header while native MCP clients and the internal ACP client never do, so rejecting requests that carry it filters browser-originated attacks (DNS rebinding, CSRF) without restricting the bind address. Scoped to /mcp so browser-facing routes are unaffected, and mirrors the claw-server's request hygiene.

* fix(server): serve the modern 2026-07-28 revision on the TS /mcp endpoint

The TS agent server advertised only legacy protocol versions and rejected 2026-07-28: the McpServer was built without supportedProtocolVersions (so it never registered server/discover), and the route dispatched through the bare streamable-HTTP transport, which does not run the modern request machinery.

Advertise the modern revision alongside the legacy list on the McpServer, and split the route: legacy (2025-era) requests keep the existing hand-wired transport with enableJsonResponse so the internal ACP client still gets single-JSON responses, while modern requests go through createMcpHandler, which serves server/discover and the stateless 2026-07-28 dispatch. Both eras are exercised end to end by new tests: legacy initialize negotiates 2025-11-25 as JSON, modern server/discover advertises 2026-07-28, and a modern tool call succeeds.

* feat(mcp-manager): add read-only tool catalogue endpoint for settings UI (#2410)

The settings Available Tools list previously ran a full MCP handshake against /mcp from the browser. With the browser-request guard on /mcp, that path is no longer reachable from the extension.

Add a GET /mcp-manager/tools endpoint that returns the same tool set (browser tools plus Klavis connector tools) as name and description pairs, and point the settings section at it through a react-query hook mirroring the existing agents hook. The MCP hardening is left unchanged.

* fix(klavis): pass connector tool JSON schemas through unchanged (#2412)

* fix(klavis): pass connector tool JSON schemas through unchanged

Strata connector tools (Linear and others) advertised an empty input schema, so every parameterized call was rejected with both "required" and "additional properties" errors and the agent could never pass arguments.

Their remote JSON schema was routed through zod-from-json-schema and then zod-to-json-schema, which under zod v3 silently drops every property. Pass the remote JSON schema straight through instead: fromJsonSchema for the MCP server path and the ai jsonSchema() helper for the AI SDK tool set. The hand-built connector_mcp_servers schema is unchanged. Removes the now-unused per-session schema map.

* chore(server): drop unused zod-from-json-schema dependency

* fix(claw-app): drop the audit timeline risk labels and recolor the rows (#2416)

The audit timeline tagged act/evaluate/run/download rows as "High risk", which
is inaccurate, and highlighted them in warning-amber. Remove the label and switch
the row emphasis to the theme accent (blue); errors keep their red highlight. The
same tool set still auto-expands on load, now named for what it is (notable
actions) rather than risk.

* chore(release): update extension alpha feeds to 0.2.17.0

Automated release snapshot update.

* chore: bump browserclaw extension version to 0.2.17.0 (#2421)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(claw-server): install the neo skill under browseros-neo and migrate legacy dirs (#2428)

* fix(claw-server): install the neo skill under browseros-neo and migrate legacy dirs

The managed skill's SKILL.md declares name: browseros-neo but was installed into a
directory named browserclaw, so agents that require name == parent directory reject
it (e.g. Claude Code: name "browseros-neo" does not match parent directory
"browserclaw").

Install the skill under browseros-neo so the directory matches the frontmatter, and
add a one-time, idempotent migration that removes the legacy browserclaw directories
(only ones whose ownership marker proves BrowserOS installed them) across every
harness and purges their manifest records, so the reconcile replants them under the
new name. User-authored browserclaw directories are left untouched. The migration
runs on every managed-skill reconcile (startup and connect), so existing users heal
without any action. Marker internals (managed_by) stay stable for recognition.

* fix(harness): keep legacy skill records for directories that remain

Purge a legacy manifest record only once its directory is actually gone. If removal
or marker inspection fails the directory stays on disk, so keeping its record leaves
the manifest consistent and lets the next reconcile retry, instead of orphaning it.

* fix(chat): surface the full error in a copyable block (#2434)

* fix(chat): surface the full scrubbed upstream error in a copyable block

When a turn fails, the classifier only forwarded the short constructed message
into the error envelope's details and capped it at 500 characters, so the
gateway's real reason (OpenRouter-style gateways hide it in the response body /
metadata.raw) never reached the user. Errors the AI SDK could not classify
carried no details at all.

Forward the full upstream response body (or structured data.raw), pretty-printed
as JSON, into details on every path, redacted and bounded at a larger cap so the
whole body survives to be copied. The card now renders that detail always
expanded under a Full error heading with a copy control that copies the entire
error even when the block clips it on screen, while the classified title and
message stay on top. The block is omitted when the detail only repeats the
message, so a clean one-line error is not shown twice.

* fix(chat): broaden secret scrubbing for the full upstream error body

Forwarding the whole response body means more credential shapes can appear than
the four key patterns caught. Redact value-shaped secrets on the raw text so it
works whether or not the body parses as an object: JWTs, PEM private keys,
Google/Slack/GitLab/GitHub tokens, URL userinfo credentials (host kept), and
sensitive JSON string values matched by key name. Skewed toward over-redaction
since a missed token is copied into a bug report.

* feat(agents): custom ACP agent support (#2433)

* feat(server): add custom ACP agent data model + migration

Widen the agent type enum to include 'custom', add a nullable custom_config
JSON column to acp_agents (named migration 0007) plus the bootstrap DDL and
migration-history mirror, and round-trip a validated CustomAcpAgentConfig
through the agent definition and store (with a new update method).

* feat(server): launch custom ACP agents from a stored command

Add a quote-aware command splitter and a launcher branch that runs a custom
agent's full command line as given (no bundled-bun wrapping). Make the agent
policy data-driven for custom agents: a per-agent registry id, stored env,
system-prompt append, and full-access modes. Skip the full-access mode step
when none are configured, and read the reasoning-effort key from config.

* feat(server): route custom agent chats through the ACP path

Include 'custom' in the conversation target-type enum and dispatch custom
chat requests to processAcpMessage alongside claude and codex.

* feat(server): validate custom agent config and add an update endpoint

Extend POST /agents to accept and cross-validate customConfig (required for
custom agents, rejected for built-ins), and add PUT /agents/:agentId so a
custom agent's command can be edited. Refresh running sessions on update so
the next turn re-spawns with the new command.

* feat(server): probe a custom agent by explicit command

Accept an optional command/env/cwd on POST /acpx/probe (command required for
type 'custom') so the settings UI can validate a not-yet-saved agent and
enumerate its models before the user commits.

* test(server): cover custom ACP agent config, launch, routes, and probe

Add a command-splitter suite and custom-agent cases across the store (config
round-trip + update through the real migration), agent routes (create
cross-validation + PUT), launcher (run-as-given argv, env injection, no
bundled-bun wrapping), policy (per-agent registry id, stored env/prompt/modes,
empty full-access), and probe (command required, forwarded).

* feat(app): custom agent data layer (config type, update + probe hooks)

Add customConfig to the app AcpAgent type, extend the create payload, add
useUpdateAcpAgent (PUT), and a useProbeCustomAgent mutation that probes a
not-yet-saved custom command for the Test-connection flow.

* feat(app): custom ACP agent dialog with Test connection + popular agents

Add the add/edit custom-agent dialog (name, full command line, working dir,
Test-connection probe, advanced env/modes/prompt/icon), a nested Popular ACP
agents helper backed by a verified registry (opencode, hermes, openclaw, pi;
antigravity as docs-only), and the dashed 'Custom ACP agent' template tile.

* feat(app): surface custom agents in settings and chat

Wire the custom-agent dialog into the AI settings pane (template tile + edit
from the agents list), render a custom agent's stored icon and command in its
card, and give custom agents a distinct icon/label fallback in the adapter
helpers and the chat provider selector.

* test(app): cover custom agent form helpers and card rendering

Add unit tests for the env/CSV parsing and config builder, and card cases for
the custom-agent command, icon, and edit button.

* test(server): use a neutral path in the command-splitter fixture

* fix(ci): expect the custom-agent migration in the bootstrap schema test

* fix(server): keep Windows backslash paths intact when splitting commands

Treat backslashes as literal in the custom-agent command splitter so Windows
paths like C:\Users\me\agent.exe survive; use quotes for arguments with
spaces. Previously every backslash was consumed as an escape, launching a
nonexistent executable path on Windows.

* feat(app): real brand logos and code-block commands in popular agents

Render svgl brand marks for opencode (currentColor), OpenClaw, and Antigravity
in neutral tiles, with monograms for pi and Hermes (no svgl logo). Show each
suggested command as a proper code chip so it reads as code, not description.

* fix(server): resolve custom agents against the login-shell PATH

A GUI-launched server inherits a minimal PATH that omits shell-profile
additions (homebrew, nvm/fnm, ~/.local/bin), so a custom agent's binary could
be unfindable even when it works in a terminal. Ask the user's login shell for
its PATH once (cached, timeout-guarded) and prepend it for custom command
spawns. Windows inherits the full registry PATH already, so it is unaffected.

* feat(app): real brand logos across built-in and custom agents

Consolidate agent brand marks (Claude, Codex, opencode, OpenClaw, Antigravity,
pi) into a shared module reusing the project's svg components, and use them in
the provider templates, agent cards, and the Popular-agents picker. Picking a
popular agent now carries its logo onto the saved agent. Removed the manual
Icon text field. Hermes keeps a monogram until its logo asset is added.

* feat(app): agent brand logos in the chat selector, Hermes icon, opencode color

Render brand marks in the newtab + sidepanel provider selector by threading a
brand key through the chat targets. Add the Hermes icon (object-contain, from
the provided asset) and give opencode its own dark/white ink instead of the
tile accent color.

* fix(app): brand logos in the selected-agent chip, input avatar, and header

The newtab search-bar pill and input avatar and the sidepanel header rendered a
generic Bot for the selected agent. Resolve the agent's brand mark from its
brand key in all three, falling back to Bot when none.

* fix(app): make the sidepanel agent selector look clickable

The header agent selector read as a static title once it showed a brand logo.
Add a border, an active (foreground) name, and a rotating chevron caret so it
reads as a dropdown, matching the newtab pill.

* fix(mcp): reject unknown arguments on every browser tool (#2432)

* fix(mcp): reject unknown arguments on every browser tool

Only tabs, windows, and history rejected unknown fields. The other 14
tools dropped them during deserialization and ran with defaults, so a
misspelled argument produced a successful but quietly wrong result:

  screenshot {page: 1, fullpage: true}   -> viewport shot, no error
  grep {page, pattern, maxResults: 5}    -> default 50 matches, no error

This is the failure mode behind #2161, and the only reason that one
surfaced is that tabs happens to be strict, so the retired hidden input
raised "unknown field" instead of being ignored.

Add deny_unknown_fields to the 14 permissive Rust arg structs and
.strict() to the matching zod schemas, which also closes a parity gap
where tabs and windows were strict in Rust but permissive in TypeScript.

The schema pipeline already handles this: normalize_schema_value rewrites
additionalProperties: false into {"not": {}}, so the generated schemas
stay free of boolean nodes.

Fixes #2431

* fix(mcp): reject unknown fields on nested tool arguments too

Top-level strictness did not propagate into nested argument objects, so a
misspelled nested key was still silently dropped and defaulted. screenshot
size {width, heigth} accepted 'heigth', defaulted height to 768, and returned
a successful but wrong capture. Add deny_unknown_fields / .strict() to the
nested input objects (screenshot size, act fill fields) in both the Rust and
TypeScript implementations, and cover a nested unknown key in the regression
test.

* test(mcp): walk nested schemas in the Rust strictness check

The Rust regression test only inspected the top-level additionalProperties,
so the nested structs fixed in the previous commit were guarded on the
TypeScript side but not the Rust side. Walk the whole generated schema
instead, so any object node that still accepts unknown properties fails
and the message names the offending path:

  screenshot accepts unknown arguments at $.properties.size

Verified by removing deny_unknown_fields from ScreenshotSize alone.

---------

Co-authored-by: DaniAkash <DaniAkash@users.noreply.github.com>

* perf(rust): drop dependency debug info from the dev profile (#2440)

A clean dev build of the workspace produces a 3.0G target directory, and
every git worktree pays it in full. A strip probe on the largest dependency
rlib put debug info at 81% of the artifact: 224M drops to 42M.

Dependencies are not stepped into during normal work, so their DWARF is
pure disk cost. Turning it off for `*` leaves workspace crates untouched,
and line-tables-only on those keeps panics and backtraces resolving to
file and line while dropping variable and type DWARF.

Measured on a clean build, same commit, same machine:

  target/         3.0G -> 1.8G   (-40%)
  target/debug/deps 2.1G -> 1.1G
  build time     57.33s -> 51.00s

The build is faster because there is less debug info to write. Object
files carrying DWARF drop from 9964 (1349M) to 1461 (121M).

Smaller artifacts also shrink the Swatinem/rust-cache entry in the test
workflow, which eases pressure on the per-repo Actions cache limit.

`cargo clippy --workspace --all-targets -- -D warnings` passes clean.

* fix(analytics): surface unrecognized MCP clients instead of dropping them (#2439)

* fix(analytics): surface unrecognized MCP clients instead of dropping them

Every client whose name was not in the ~19-entry allowlist was collapsed to a
single opaque "unrecognized-client" bucket, discarding the real name before it
reached analytics. That hid the largest slice of real users behind one label.

Split the unrecognized path into privacy-safe buckets: a blank name becomes
unrecognized-empty; a name carrying structural PII markers (email, path, URL,
host:port) or an opaque over-long blob becomes unrecognized-redacted; and a
safe-shaped name surfaces its own slug so real long-tail clients (roo-code,
librechat, 5ire, ...) become visible and can be allowlisted. The PII gate runs
on the original raw, not the slug, so stripped fragments cannot leak.

* refactor(analytics): record unlisted client names directly, drop the marker gate

An unlisted client now records its own slug; only a blank name is reported as
unrecognized-empty. Removes the PII-marker and length gates: clientInfo.name is
a client-chosen implementation identifier, so recording it verbatim is the
intended behaviour.

* ci: resolve the bun version from package.json in every workflow (#2441)

* ci: resolve the bun version from package.json in every workflow

Nine setup-bun steps had no version pinned, so they installed whatever
Bun was newest at run time: the whole of Code Quality (biome, typecheck,
claw-api-codegen, claw-api-contract, fallow), both Tests jobs, Audit, and
the Turbo cache warmer. Those are the workflows that gate merges, so a
Bun release could change install layout, resolution, or test behaviour
with no commit and no warning. It also let Tests pass on a Bun the
release workflows never use.

The remaining six hardcoded "1.3.6" in YAML, a third place to keep in
sync with what the repo already declares.

All fifteen now read the version from the existing source of truth:

  packageManager: "bun@1.3.6"

setup-bun's bun-version-file accepts package.json and prefers
packageManager, falling back to engines.bun. Both are present and agree,
so behaviour for the six release workflows is unchanged. Bumping Bun for
the entire repo is now a one-line change.

Every workflow was parsed to confirm valid YAML and that each setup-bun
step resolves from the file with no leftover literal: 15 steps, 0
problems.

* test(release): assert the release build pins bun via the version file

The release extensions workflow test asserted the literal
`bun-version: "1.3.6"` in the build job. The guard's intent is that the
release build pins Bun rather than floating, and that still holds; the
mechanism is now `bun-version-file` reading packageManager from
package.json.

Point the assertion at the new form so the guard keeps its meaning
instead of pinning a string that no longer appears.

* fix(ci): keep the macos builder on a literal bun pin

The macOS build job runs on the self-hosted builder against the
persistent tree at BROWSEROS_REPO_PATH and has no actions/checkout, so
GITHUB_WORKSPACE never holds the source. setup-bun resolves
bun-version-file from GITHUB_WORKSPACE, finds nothing, and falls back to
latest, which would have left source-mode macOS releases on a floating
toolchain.

Restore the literal pin for this one job and record why it cannot read
the version file. A job-level audit confirms it is the only one of the
fifteen setup-bun steps without a checkout ahead of it.

* feat(analytics): let a session declare its task category (#2443)

* feat(analytics): let a session declare its task category

name_session gains an optional category argument from a fixed enum. The
free-form name stays local and drives the tab group title exactly as before;
only the enum category is emitted, on a new agent_session_task_declared event
(task_category + client_name), so we can aggregate what kind of work sessions
do without any free-form text leaving the machine. An unrecognized category is
coerced to "other" so the declaration still counts; a non-string is dropped.
The prompt and skill now nudge the agent to pass a category.

* fix(analytics): declare task category at most once per session; fix catalog boundary test

Guard the task-declared emit with a per-session once-flag so a later
name_session rename does not emit a second agent_session_task_declared, which
would overcount the category mix and the declaration rate. Also register the
new wire name in the analytics single-source boundary test and its catalog
count.

* fix(ci): update name_session route test for the category argument

* refactor(server): replace custom compaction with AI SDK guided pruning (#2445)

* refactor(server): replace custom compaction with AI SDK guided pruning

Collapse the four-stage compaction pipeline (prune, tool-output reduction,
LLM summarization, sliding-window fallback) into the shape the AI SDK
compaction guide recommends: one trigger threshold, one prune pass, one
deterministic prune-only fallback.

The sliding-window fallback is replaced by pruneMessages hard mode at all
four of its former call sites, which cannot orphan a tool call from its
result. LLM summarization and the split-turn dual-summary path are removed.

Token estimation stays image-aware rather than adopting the guide's
JSON.stringify heuristic, which would read a single base64 screenshot as
roughly 250K tokens.

Fixes three latent issues along the way: the chars-per-token constant was 3
in the estimator and 4 in the inflation guard, the fallback targeted a
looser budget than the trigger so a compaction could succeed and re-fire
immediately, and a zero or negative client-supplied context window produced
a threshold every request exceeded.

* fix(server): stop compaction returning a transcript over the budget

Pruning has no lever against plain user and assistant text, so once the
floor pass has cleared every tool exchange a prose-heavy transcript came
back untouched and went to the provider over the model's limit. Verified at
a 200K window: 212,003 tokens in, 212,003 tokens out against a 180,000
threshold.

Adds a message-drop floor that keeps the first message and the longest
recent suffix that fits. It runs only after every tool call has already been
pruned, so unlike a general sliding window it cannot separate a tool call
from its result: there are no pairs left to break. A single message larger
than the window still cannot be shrunk, which is now surfaced on the log
line rather than left as a bare provider error.

Also tightens the context window guard from > 0 to >= 1. A fractional value
below one passed the check and then floored to zero, producing a zero-token
threshold that every step exceeded.

The test that should have caught the first bug asserted only that the
message count did not grow, which passes when nothing changes at all. It now
asserts the transcript lands under the threshold, at three window sizes.

* perf(rust): share cargo intermediates across checkouts (#2446)

* perf(rust): share cargo intermediates across checkouts

Every checkout compiles its own copy of the dependency graph. Anyone
keeping more than one clone or worktree open pays that in full each time,
around 1.6G apiece.

build-dir moves only the intermediate artifacts out of the checkout, and
it supports path templating, so {cargo-cache-home} resolves to CARGO_HOME
and one shared location covers every checkout on a machine. Nothing
absolute or machine specific is committed.

target-dir was the obvious alternative and does not work here: it has no
templating, cargo expands neither ~ nor $HOME, so a committed value could
only be relative to the checkout. That would limit sharing to sibling
directories, and because it also moves the final artifacts it would break
the three places the BrowserClaw release locates a built binary.

Final artifacts still land in <checkout>/target, so nothing that resolves
a build output by path changes.

Measured across two checkouts of the same branch:

  cold build         52.36s   target 227M   shared 1.6G
  second checkout    16.14s   target 227M   shared 2.1G

A release build against a warm shared directory still produces
target/release/browseros-claw-server-rs.

rust-cache saves only workspace target dirs plus the registry and git
caches, and never reads a build dir setting, so the shared directory is
named to it explicitly. Without that, CI would recompile the dependency
graph on every run.

* ci(rust): warm the rust cache on main and drop it fortnightly

Three related gaps around the shared cargo build directory.

The Rust cache was never warm for a new pull request. Tests run only on
pull_request, so rust-cache saved under a PR branch's scope, and branches
cannot read each other's caches. This is the same problem the Turbo warm
run already solves, and Rust was simply never covered. It matters more
now that the intermediates live in a cache-directories entry: without a
warm run, every PR recompiles the dependency graph.

Warming alone would not have worked. rust-cache builds its key from
GITHUB_JOB unless shared-key is set, and the existing keys show it:

  v0-rust-test-Linux-x64-<hash>-<hash>

A warm job under any other name would have written a cache nothing else
could read. Both steps now pin the same shared-key, workspaces,
cache-directories and toolchain, since the toolchain hashes into the key
too.

The new warm job mirrors what the Rust suites compile, test binaries and
clippy's separate artifacts, and deliberately omits -D warnings because
it exists to populate a cache rather than to gate on lints.

Finally, rust-cache prunes only workspace target dirs and never extra
cache-directories, so the shared build directory is cached wholesale and
grows without bound. It is already the larger part of the problem:

  v0-rust    25 entries    6.97 GB
  all caches 262 entries  10.35 GB   against a 10 GB allowance

Being over the allowance means LRU eviction is already discarding other
caches. Dropping the Rust entries on the 1st and 15th keeps that bounded,
matched on the prefix so nothing else is touched, and the warm workflow
is dispatched straight after so no branch waits for the next merge.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Athul Nambiar <108534940+athul-22@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant