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 <agent-zero-url></code></div>
76
+ </div>
77
+ </div>
78
+ </template>
79
+ </div>
80
+</body>
81
+</html>