Bundle Agent Zero ACP

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.

Alessandro committed Aug 16, 2026 at 19:25 UTC add781d3b3e5b3972fbd7cef54657b7bfb274ae9
9 files changed +463
plugins/_a0_acp/README.md new
+17
@@ -0,0 +1,17 @@
1 +# Agent Client Protocol
2 +
3 +`_a0_acp` is the bundled Agent Client Protocol bridge. ACP-capable editors
4 +start the local connector with:
5 +
6 +```bash
7 +a0 acp --host http://localhost:32081
8 +```
9 +
10 +The connector owns editor-hosted files and terminal access. The Agent Zero
11 +runtime owns ACP session metadata, history, modes, and model settings. The
12 +default transport is the connector; the hidden `transport: container` setting
13 +is only a compatibility fallback for an already configured legacy `a0_acp`
14 +plugin inside the selected container.
15 +
16 +On startup, Agent Zero removes retired `usr/plugins/a0_acp` installations and
17 +their project or agent overrides. The bundled `_a0_acp` configuration is kept.
plugins/_a0_acp/api/session.py new
+223
@@ -0,0 +1,223 @@
1 +"""Authenticated ACP session metadata API for the host-side A0 CLI."""
2 +from __future__ import annotations
3 +
4 +from pathlib import Path
5 +from typing import Any
6 +
7 +from helpers.api import Request, Response
8 +from plugins._a0_connector.api.v1.base import ProtectedConnectorApiHandler
9 +
10 +
11 +PLUGIN_NAME = "_a0_acp"
12 +CTX_IS_ACP = "acp_session"
13 +CTX_CWD = "acp_cwd"
14 +CTX_ADDITIONAL_DIRECTORIES = "acp_additional_directories"
15 +CTX_MODE = "acp_mode"
16 +CTX_MODEL_ID = "acp_model_id"
17 +CTX_CONFIG_OPTIONS = "acp_config_options"
18 +CTX_TRANSPORT = "acp_transport"
19 +CTX_WORKDIR = "workdir_path"
20 +_VALID_MODES = {"default", "plan", "act"}
21 +_MAX_PATHS = 32
22 +_MAX_PATH_LENGTH = 4096
23 +
24 +
25 +def _config() -> dict[str, Any]:
26 + from helpers.plugins import get_plugin_config
27 +
28 + return dict(get_plugin_config(PLUGIN_NAME) or {})
29 +
30 +
31 +def _paths(value: object) -> list[str]:
32 + if not isinstance(value, list):
33 + return []
34 + return [
35 + str(path).strip()
36 + for path in value[:_MAX_PATHS]
37 + if str(path).strip() and len(str(path).strip()) <= _MAX_PATH_LENGTH
38 + ]
39 +
40 +
41 +def _mode(value: object) -> str:
42 + mode = str(value or "default").strip().lower()
43 + return mode if mode in _VALID_MODES else "default"
44 +
45 +
46 +def _timestamp(value: object) -> str:
47 + if hasattr(value, "isoformat"):
48 + return value.isoformat()
49 + return str(value or "")
50 +
51 +
52 +def _session_payload(context) -> dict[str, Any]:
53 + return {
54 + "session_id": context.id,
55 + "title": context.name or "Agent Zero ACP",
56 + "cwd": str(context.get_data(CTX_CWD) or ""),
57 + "additional_directories": _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
58 + "updated_at": _timestamp(context.last_message or context.created_at),
59 + "mode": _mode(context.get_data(CTX_MODE)),
60 + "model_id": str(context.get_data(CTX_MODEL_ID) or ""),
61 + }
62 +
63 +
64 +def _mark_dirty(context_id: str, reason: str) -> None:
65 + try:
66 + from helpers.state_monitor_integration import mark_dirty_for_context
67 +
68 + mark_dirty_for_context(context_id, reason=reason)
69 + except Exception:
70 + return
71 +
72 +
73 +class Session(ProtectedConnectorApiHandler):
74 + async def process(self, input: dict, request: Request) -> dict | Response:
75 + del request
76 + action = str(input.get("action") or "config").strip().lower()
77 + if action == "config":
78 + return {"ok": True, "config": _config()}
79 +
80 + if action == "list":
81 + return self._list_sessions(input)
82 + if action == "configure":
83 + return self._configure(input)
84 + if action == "fork":
85 + return self._fork(input)
86 + if action == "close":
87 + return self._close(input)
88 + if action == "set_mode":
89 + return self._set_value(input, CTX_MODE, _mode(input.get("mode")))
90 + if action == "set_model":
91 + return self._set_value(input, CTX_MODEL_ID, str(input.get("model_id") or "").strip())
92 + if action == "set_config_option":
93 + return self._set_config_option(input)
94 + return Response(status=400, response=f"Unknown ACP action: {action}")
95 +
96 + def _context(self, input: dict):
97 + from agent import AgentContext
98 +
99 + context_id = str(input.get("context_id") or input.get("session_id") or "").strip()
100 + if not context_id:
101 + return None, Response(status=400, response="context_id is required")
102 + context = AgentContext.get(context_id)
103 + if context is None:
104 + return None, Response(status=404, response="ACP session not found")
105 + return context, None
106 +
107 + def _list_sessions(self, input: dict) -> dict:
108 + from agent import AgentContext
109 + from helpers import persist_chat
110 +
111 + persist_chat.load_tmp_chats()
112 + cwd = str(input.get("cwd") or "").strip()
113 + sessions = [
114 + _session_payload(context)
115 + for context in AgentContext.all()
116 + if context.get_data(CTX_IS_ACP)
117 + and (not cwd or str(context.get_data(CTX_CWD) or "") == cwd)
118 + ]
119 + sessions.sort(key=lambda session: str(session["updated_at"]), reverse=True)
120 + return {"ok": True, "sessions": sessions}
121 +
122 + def _configure(self, input: dict) -> dict | Response:
123 + from helpers import persist_chat
124 +
125 + config = _config()
126 + if not bool(config.get("enabled", True)):
127 + return Response(status=403, response="ACP is disabled in Agent Zero settings")
128 + context, error = self._context(input)
129 + if error:
130 + return error
131 +
132 + cwd = str(input.get("cwd") or "").strip()
133 + if not cwd or len(cwd) > _MAX_PATH_LENGTH:
134 + return Response(status=400, response="A valid ACP workspace path is required")
135 + transport = str(config.get("transport") or "connector").strip().lower()
136 + if transport not in {"connector", "container"}:
137 + transport = "connector"
138 +
139 + context.set_data(CTX_IS_ACP, True)
140 + context.set_data(CTX_CWD, cwd)
141 + context.set_data(CTX_ADDITIONAL_DIRECTORIES, _paths(input.get("additional_directories")))
142 + context.set_data(CTX_MODE, _mode(input.get("mode")))
143 + context.set_data(CTX_TRANSPORT, transport)
144 + if transport == "container":
145 + container_workspace = str(config.get("container_workspace_root") or "").strip()
146 + if container_workspace:
147 + context.set_data(CTX_WORKDIR, container_workspace)
148 + if not context.name:
149 + context.name = Path(cwd).name or "Agent Zero ACP"
150 + persist_chat.save_tmp_chat(context)
151 + _mark_dirty(context.id, "a0_acp.configure")
152 + return {"ok": True, "session": _session_payload(context), "config": config}
153 +
154 + def _fork(self, input: dict) -> dict | Response:
155 + from agent import AgentContext
156 + from helpers import persist_chat
157 +
158 + context, error = self._context(input)
159 + if error:
160 + return error
161 + if not context.get_data(CTX_IS_ACP):
162 + return Response(status=400, response="Only ACP sessions can be forked through ACP")
163 +
164 + new_ids = persist_chat.load_json_chats([persist_chat.export_json_chat(context)])
165 + if not new_ids:
166 + return Response(status=500, response="Could not fork ACP session")
167 + fork = AgentContext.get(new_ids[0])
168 + if fork is None:
169 + return Response(status=500, response="Forked ACP session could not be loaded")
170 +
171 + fork.name = f"{context.name or 'Agent Zero ACP'} (fork)"
172 + fork.set_data(CTX_IS_ACP, True)
173 + fork.set_data(CTX_CWD, str(input.get("cwd") or context.get_data(CTX_CWD) or ""))
174 + fork.set_data(
175 + CTX_ADDITIONAL_DIRECTORIES,
176 + _paths(input.get("additional_directories"))
177 + or _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
178 + )
179 + fork.set_data(CTX_MODE, _mode(context.get_data(CTX_MODE)))
180 + fork.set_data(CTX_TRANSPORT, context.get_data(CTX_TRANSPORT) or "connector")
181 + persist_chat.save_tmp_chat(fork)
182 + _mark_dirty(fork.id, "a0_acp.fork")
183 + return {"ok": True, "session": _session_payload(fork)}
184 +
185 + def _close(self, input: dict) -> dict | Response:
186 + from agent import AgentContext
187 + from helpers import persist_chat
188 +
189 + context, error = self._context(input)
190 + if error:
191 + return error
192 + context.kill_process()
193 + AgentContext.remove(context.id)
194 + persist_chat.remove_chat(context.id)
195 + return {"ok": True}
196 +
197 + def _set_value(self, input: dict, key: str, value: object) -> dict | Response:
198 + from helpers import persist_chat
199 +
200 + context, error = self._context(input)
201 + if error:
202 + return error
203 + context.set_data(key, value)
204 + persist_chat.save_tmp_chat(context)
205 + _mark_dirty(context.id, f"a0_acp.{key}")
206 + return {"ok": True, "session": _session_payload(context)}
207 +
208 + def _set_config_option(self, input: dict) -> dict | Response:
209 + from helpers import persist_chat
210 +
211 + context, error = self._context(input)
212 + if error:
213 + return error
214 + config_id = str(input.get("config_id") or "").strip()
215 + if not config_id:
216 + return Response(status=400, response="config_id is required")
217 + options = context.get_data(CTX_CONFIG_OPTIONS)
218 + options = dict(options) if isinstance(options, dict) else {}
219 + options[config_id] = input.get("value")
220 + context.set_data(CTX_CONFIG_OPTIONS, options)
221 + persist_chat.save_tmp_chat(context)
222 + _mark_dirty(context.id, "a0_acp.config_option")
223 + return {"ok": True, "config_options": options}
plugins/_a0_acp/default_config.yaml new
+15
@@ -0,0 +1,15 @@
1 +# The normal ACP transport is the A0 CLI running on the editor host.
2 +enabled: true
3 +agent_profile: ""
4 +host_file_access: read_write
5 +host_code_execution: true
6 +session_history: true
7 +
8 +# Advanced compatibility transport. These values are intentionally not exposed
9 +# by the standard settings UI because they require a preconfigured legacy ACP
10 +# plugin inside the selected container.
11 +transport: connector
12 +container_id: ""
13 +container_workdir: /a0
14 +container_python: /opt/venv-a0/bin/python
15 +container_workspace_root: ""
plugins/_a0_acp/extensions/python/message_loop_prompts_after/_50_acp_mode.py new
+17
@@ -0,0 +1,17 @@
1 +from agent import LoopData
2 +from helpers.extension import Extension
3 +
4 +
5 +_MODE_PROMPTS = {
6 + "plan": "ACP session mode: plan first. Prefer analysis and tradeoffs. Do not modify files unless the user explicitly asks.",
7 + "act": "ACP session mode: act. Complete actionable work end-to-end with focused implementation and validation.",
8 +}
9 +
10 +
11 +class AcpMode(Extension):
12 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
13 + if not self.agent or not self.agent.context.get_data("acp_session"):
14 + return
15 + prompt = _MODE_PROMPTS.get(str(self.agent.context.get_data("acp_mode") or ""))
16 + if prompt:
17 + loop_data.extras_temporary["acp_mode"] = prompt
plugins/_a0_acp/extensions/python/startup_migration/_10_migrate_legacy_acp.py new
+53
@@ -0,0 +1,53 @@
1 +"""Retire the former community ACP plugin after Core ships its replacement."""
2 +from __future__ import annotations
3 +
4 +import shutil
5 +from pathlib import Path
6 +from typing import Any
7 +
8 +from helpers import cache, files
9 +from helpers.extension import Extension
10 +from helpers.print_style import PrintStyle
11 +
12 +
13 +LEGACY_PLUGIN_NAME = "a0_acp"
14 +
15 +
16 +class LegacyAcpMigration(Extension):
17 + def execute(self, **kwargs: Any) -> None:
18 + result = migrate_legacy_acp()
19 + if result["removed_roots"]:
20 + PrintStyle.info("Removed retired ACP plugin files:", result["removed_roots"])
21 +
22 +
23 +def migrate_legacy_acp(base_dir: str | Path | None = None) -> dict[str, list[str]]:
24 + root = Path(base_dir or files.get_abs_path("")).resolve()
25 + removed_roots: list[str] = []
26 + errors: list[str] = []
27 +
28 + for plugin_root in _legacy_plugin_roots(root):
29 + try:
30 + if plugin_root.is_dir() and not plugin_root.is_symlink():
31 + shutil.rmtree(plugin_root)
32 + else:
33 + plugin_root.unlink()
34 + removed_roots.append(str(plugin_root))
35 + except OSError as exc:
36 + errors.append(f"Could not remove retired ACP plugin at {plugin_root}: {exc}")
37 +
38 + if removed_roots:
39 + cache.clear("*(plugins)*")
40 + cache.clear("*(extensions)*")
41 + cache.clear("*(api)*")
42 +
43 + return {"removed_roots": removed_roots, "errors": errors}
44 +
45 +
46 +def _legacy_plugin_roots(root: Path) -> list[Path]:
47 + candidates = [
48 + root / "usr" / "plugins" / LEGACY_PLUGIN_NAME,
49 + *root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
50 + *root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
51 + *root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
52 + ]
53 + return [candidate for candidate in candidates if candidate.exists() or candidate.is_symlink()]
plugins/_a0_acp/plugin.yaml new
+9
@@ -0,0 +1,9 @@
1 +name: _a0_acp
2 +title: Agent Client Protocol
3 +description: Connect ACP-capable editors through the local A0 CLI connector.
4 +version: "2.0"
5 +settings_sections:
6 + - external
7 +per_project_config: false
8 +per_agent_config: false
9 +always_enabled: true
plugins/_a0_acp/tests/test_migration.py new
+29
@@ -0,0 +1,29 @@
1 +from pathlib import Path
2 +
3 +from plugins._a0_acp.extensions.python.startup_migration._10_migrate_legacy_acp import (
4 + migrate_legacy_acp,
5 +)
6 +
7 +
8 +def test_migrate_legacy_acp_removes_all_stale_plugin_roots(tmp_path: Path) -> None:
9 + stale_roots = [
10 + tmp_path / "usr" / "plugins" / "a0_acp",
11 + tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "plugins" / "a0_acp",
12 + tmp_path / "usr" / "agents" / "reviewer" / "plugins" / "a0_acp",
13 + ]
14 + bundled_config = tmp_path / "usr" / "plugins" / "_a0_acp" / "config.json"
15 +
16 + for root in stale_roots:
17 + (root / ".git").mkdir(parents=True)
18 + (root / "plugin.yaml").write_text("name: a0_acp\n", encoding="utf-8")
19 + (root / ".git" / "config").write_text("[core]\n", encoding="utf-8")
20 + bundled_config.parent.mkdir(parents=True)
21 + bundled_config.write_text('{"enabled": true}\n', encoding="utf-8")
22 +
23 + result = migrate_legacy_acp(tmp_path)
24 +
25 + assert len(result["removed_roots"]) == len(stale_roots)
26 + assert result["errors"] == []
27 + assert all(not root.exists() for root in stale_roots)
28 + assert bundled_config.read_text(encoding="utf-8") == '{"enabled": true}\n'
29 + assert migrate_legacy_acp(tmp_path) == {"removed_roots": [], "errors": []}
plugins/_a0_acp/tests/test_session.py new
+19
@@ -0,0 +1,19 @@
1 +from datetime import datetime, timezone
2 +
3 +from plugins._a0_acp.api.session import _session_payload
4 +
5 +
6 +class _Context:
7 + id = "ctx-acp"
8 + name = "ACP"
9 + created_at = datetime(2026, 8, 16, tzinfo=timezone.utc)
10 + last_message = datetime(2026, 8, 16, 12, 34, tzinfo=timezone.utc)
11 +
12 + def get_data(self, key: str):
13 + return {"acp_cwd": "/workspace", "acp_mode": "default"}.get(key)
14 +
15 +
16 +def test_session_payload_serializes_datetime_metadata() -> None:
17 + payload = _session_payload(_Context())
18 +
19 + assert payload["updated_at"] == "2026-08-16T12:34:00+00:00"
plugins/_a0_acp/webui/config.html new
+81
@@ -0,0 +1,81 @@
1 +<html>
2 +<head>
3 + <title>Agent Client Protocol</title>
4 +</head>
5 +
6 +<body>
7 + <div x-data>
8 + <template x-if="config">
9 + <div>
10 + <div class="section-title">Agent Client Protocol</div>
11 + <div class="section-description">Connect an ACP-capable editor through the A0 CLI running on the editor computer.</div>
12 +
13 + <div class="field">
14 + <div class="field-label">
15 + <div class="field-title">Enable ACP</div>
16 + <div class="field-description">Allows A0 CLI ACP sessions for this Agent Zero instance.</div>
17 + </div>
18 + <div class="field-control">
19 + <label class="toggle">
20 + <input type="checkbox" x-model="config.enabled" />
21 + <span class="toggler"></span>
22 + </label>
23 + </div>
24 + </div>
25 +
26 + <div class="field">
27 + <div class="field-label">
28 + <div class="field-title">Default agent profile</div>
29 + <div class="field-description">Optional profile key used for new ACP sessions.</div>
30 + </div>
31 + <div class="field-control"><input type="text" x-model="config.agent_profile" autocomplete="off" /></div>
32 + </div>
33 +
34 + <div class="field">
35 + <div class="field-label">
36 + <div class="field-title">Editor workspace access</div>
37 + <div class="field-description">Controls whether Agent Zero can write through the connected A0 CLI.</div>
38 + </div>
39 + <div class="field-control">
40 + <select x-model="config.host_file_access">
41 + <option value="read_write">Read and write</option>
42 + <option value="read_only">Read only</option>
43 + </select>
44 + </div>
45 + </div>
46 +
47 + <div class="field">
48 + <div class="field-label">
49 + <div class="field-title">Editor terminal access</div>
50 + <div class="field-description">Allows code execution on the computer running the A0 CLI.</div>
51 + </div>
52 + <div class="field-control">
53 + <label class="toggle">
54 + <input type="checkbox" x-model="config.host_code_execution" />
55 + <span class="toggler"></span>
56 + </label>
57 + </div>
58 + </div>
59 +
60 + <div class="field">
61 + <div class="field-label">
62 + <div class="field-title">Session history</div>
63 + <div class="field-description">Lets ACP editors list and resume their prior Agent Zero sessions.</div>
64 + </div>
65 + <div class="field-control">
66 + <label class="toggle">
67 + <input type="checkbox" x-model="config.session_history" />
68 + <span class="toggler"></span>
69 + </label>
70 + </div>
71 + </div>
72 +
73 + <div class="field">
74 + <div class="field-label"><div class="field-title">Editor command</div></div>
75 + <div class="field-control"><code>a0 acp --host &lt;agent-zero-url&gt;</code></div>
76 + </div>
77 + </div>
78 + </template>
79 + </div>
80 +</body>
81 +</html>