add built-in A0 CLI Connector plugin
Introduce the builtin `_a0_connector` plugin that lets the host-side A0 CLI connect to Agent Zero over authenticated HTTP and `/ws`. This adds connector capability discovery, chat/context lifecycle endpoints, log streaming, and the remote text editing, code execution, and file tree bridge used by the CLI workflow.
Alessandro committed
Apr 11, 2026 at 18:56 UTC
8c5cf1f69fa95ef5c4b2bbf67be630b80002811d
36 files changed
+2702
plugins/_a0_connector/api/__init__.py
plugins/_a0_connector/api/v1/__init__.py
plugins/_a0_connector/api/v1/agents_list.py
new
+15
@@ -0,0 +1,15 @@
1
+"""POST /api/plugins/_a0_connector/v1/agents_list."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class AgentsList(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from helpers import subagents
11
+
12
+ return {
13
+ "ok": True,
14
+ "data": subagents.get_all_agents_list(),
15
+ }
plugins/_a0_connector/api/v1/base.py
new
+29
@@ -0,0 +1,29 @@
1
+from helpers.api import ApiHandler
2
+
3
+
4
+class PublicConnectorApiHandler(ApiHandler):
5
+ @classmethod
6
+ def requires_auth(cls) -> bool:
7
+ return False
8
+
9
+ @classmethod
10
+ def requires_csrf(cls) -> bool:
11
+ return False
12
+
13
+ @classmethod
14
+ def requires_api_key(cls) -> bool:
15
+ return False
16
+
17
+
18
+class ProtectedConnectorApiHandler(ApiHandler):
19
+ @classmethod
20
+ def requires_auth(cls) -> bool:
21
+ return True
22
+
23
+ @classmethod
24
+ def requires_csrf(cls) -> bool:
25
+ return False
26
+
27
+ @classmethod
28
+ def requires_api_key(cls) -> bool:
29
+ return False
plugins/_a0_connector/api/v1/capabilities.py
new
+86
@@ -0,0 +1,86 @@
1
+"""POST /api/plugins/_a0_connector/v1/capabilities."""
2
+from __future__ import annotations
3
+
4
+import importlib.util
5
+import sys
6
+
7
+from helpers.api import Request, Response
8
+import plugins._a0_connector.api.v1.base as connector_base
9
+
10
+
11
+_BASE_FEATURES = [
12
+ "chat_create",
13
+ "chats_list",
14
+ "chat_get",
15
+ "chat_reset",
16
+ "chat_delete",
17
+ "pause",
18
+ "nudge",
19
+ "message_send",
20
+ "log_tail",
21
+ "projects",
22
+ "text_editor_remote",
23
+ "code_execution_remote",
24
+ "remote_file_tree",
25
+ "token_status",
26
+]
27
+
28
+_OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
29
+ "settings_get": ("helpers.settings", "helpers.subagents"),
30
+ "settings_set": ("helpers.settings", "helpers.subagents"),
31
+ "agents_list": ("helpers.subagents",),
32
+ "skills_list": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
33
+ "skills_delete": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
34
+ "model_presets": ("plugins._model_config.helpers.model_config",),
35
+ "model_switcher": ("plugins._model_config.helpers.model_config",),
36
+ "compact_chat": (
37
+ "plugins._chat_compaction.helpers.compactor",
38
+ "plugins._model_config.helpers.model_config",
39
+ ),
40
+}
41
+
42
+
43
+def _module_available(module_name: str) -> bool:
44
+ if module_name in sys.modules:
45
+ return True
46
+
47
+ try:
48
+ return importlib.util.find_spec(module_name) is not None
49
+ except (AttributeError, ModuleNotFoundError, ValueError):
50
+ return False
51
+
52
+
53
+def _feature_available(feature: str) -> bool:
54
+ required = _OPTIONAL_FEATURES.get(feature, ())
55
+ return all(_module_available(module_name) for module_name in required)
56
+
57
+
58
+def _feature_list() -> list[str]:
59
+ features = list(_BASE_FEATURES)
60
+ for feature in _OPTIONAL_FEATURES:
61
+ if _feature_available(feature):
62
+ features.append(feature)
63
+ return features
64
+
65
+
66
+class Capabilities(connector_base.PublicConnectorApiHandler):
67
+ """Return the connector discovery contract for current Agent Zero."""
68
+
69
+ async def process(self, input: dict, request: Request) -> dict | Response:
70
+ from helpers import login
71
+
72
+ return {
73
+ "protocol": "a0-connector.v1",
74
+ "version": "0.1.0",
75
+ "auth": ["session"],
76
+ "auth_required": bool(login.is_login_required()),
77
+ "transports": ["http", "websocket"],
78
+ "streaming": True,
79
+ "websocket_namespace": "/ws",
80
+ "websocket_handlers": ["plugins/_a0_connector/ws_connector"],
81
+ "attachments": {
82
+ "mode": "base64",
83
+ "max_files": 20,
84
+ },
85
+ "features": _feature_list(),
86
+ }
plugins/_a0_connector/api/v1/chat_create.py
new
+40
@@ -0,0 +1,40 @@
1
+"""POST /api/plugins/_a0_connector/v1/chat_create."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class ChatCreate(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from helpers import projects
11
+ from plugins._a0_connector.helpers.chat_context import create_context
12
+
13
+ current_context_id = (
14
+ str(input.get("current_context", input.get("current_context_id", ""))).strip()
15
+ or None
16
+ )
17
+ project_name = str(input.get("project_name", "")).strip() or None
18
+ agent_profile = str(input.get("agent_profile", "")).strip() or None
19
+
20
+ try:
21
+ context = create_context(
22
+ lock=self.thread_lock,
23
+ current_context_id=current_context_id,
24
+ agent_profile=agent_profile,
25
+ project_name=project_name,
26
+ )
27
+ except Exception as exc:
28
+ return Response(
29
+ response=f'{{"error": "Failed to activate project: {str(exc)}"}}',
30
+ status=400,
31
+ mimetype="application/json",
32
+ )
33
+
34
+ context_data = context.output()
35
+ return {
36
+ "context_id": context.id,
37
+ "created_at": context_data.get("created_at"),
38
+ "agent_profile": agent_profile or getattr(context.agent0.config, "profile", "default"),
39
+ "project_name": context.get_data(projects.CONTEXT_DATA_KEY_PROJECT),
40
+ }
plugins/_a0_connector/api/v1/chat_delete.py
new
+39
@@ -0,0 +1,39 @@
1
+"""POST /api/plugins/_a0_connector/v1/chat_delete."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class ChatDelete(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from agent import AgentContext
11
+ from api.chat_remove import RemoveChat
12
+
13
+ context_id = str(input.get("context_id", "")).strip()
14
+ if not context_id:
15
+ return Response(
16
+ response='{"error": "context_id is required"}',
17
+ status=400,
18
+ mimetype="application/json",
19
+ )
20
+
21
+ context = AgentContext.get(context_id)
22
+ if context is None:
23
+ return Response(
24
+ response='{"error": "Context not found"}',
25
+ status=404,
26
+ mimetype="application/json",
27
+ )
28
+
29
+ try:
30
+ handler = RemoveChat(self.app, self.thread_lock)
31
+ await handler.process({"context": context_id}, request)
32
+ except Exception as exc:
33
+ return Response(
34
+ response=f'{{"error": "{str(exc)}"}}',
35
+ status=500,
36
+ mimetype="application/json",
37
+ )
38
+
39
+ return {"context_id": context_id, "status": "deleted"}
plugins/_a0_connector/api/v1/chat_get.py
new
+46
@@ -0,0 +1,46 @@
1
+"""POST /api/plugins/_a0_connector/v1/chat_get."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class ChatGet(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from agent import AgentContext
11
+ from plugins._a0_connector.helpers.event_bridge import (
12
+ get_context_log_entries,
13
+ )
14
+
15
+ context_id = str(input.get("context_id", "")).strip()
16
+ if not context_id:
17
+ return Response(
18
+ response='{"error": "context_id is required"}',
19
+ status=400,
20
+ mimetype="application/json",
21
+ )
22
+
23
+ context = AgentContext.get(context_id)
24
+ if context is None:
25
+ return Response(
26
+ response='{"error": "Context not found"}',
27
+ status=404,
28
+ mimetype="application/json",
29
+ )
30
+
31
+ context_data = context.output()
32
+ events, last_sequence = get_context_log_entries(context_id)
33
+
34
+ return {
35
+ "context_id": context.id,
36
+ "id": context.id,
37
+ "name": context_data.get("name") or context.id,
38
+ "created_at": context_data.get("created_at"),
39
+ "last_message": context_data.get("last_message"),
40
+ "running": context_data.get("running", False),
41
+ "agent_profile": getattr(context.agent0.config, "profile", "default")
42
+ if context.agent0
43
+ else "default",
44
+ "log_entries": len(events),
45
+ "last_sequence": last_sequence,
46
+ }
plugins/_a0_connector/api/v1/chat_reset.py
new
+31
@@ -0,0 +1,31 @@
1
+"""POST /api/plugins/_a0_connector/v1/chat_reset."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class ChatReset(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from agent import AgentContext
11
+ from api.chat_reset import Reset
12
+
13
+ context_id = str(input.get("context_id", "")).strip()
14
+ if not context_id:
15
+ return Response(
16
+ response='{"error": "context_id is required"}',
17
+ status=400,
18
+ mimetype="application/json",
19
+ )
20
+
21
+ context = AgentContext.get(context_id)
22
+ if context is None:
23
+ return Response(
24
+ response='{"error": "Context not found"}',
25
+ status=404,
26
+ mimetype="application/json",
27
+ )
28
+
29
+ handler = Reset(self.app, self.thread_lock)
30
+ await handler.process({"context": context_id}, request)
31
+ return {"context_id": context_id, "status": "reset"}
plugins/_a0_connector/api/v1/chats_list.py
new
+31
@@ -0,0 +1,31 @@
1
+"""POST /api/plugins/_a0_connector/v1/chats_list."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class ChatsList(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from agent import AgentContext
11
+
12
+ contexts: list[dict[str, object]] = []
13
+ for context in AgentContext.all():
14
+ data = context.output()
15
+ contexts.append(
16
+ {
17
+ "id": context.id,
18
+ "name": data.get("name") or context.name or context.id,
19
+ "created_at": data.get("created_at"),
20
+ "last_message": data.get("last_message"),
21
+ "running": data.get("running", False),
22
+ "agent_profile": getattr(context.agent0.config, "profile", "default")
23
+ if context.agent0
24
+ else "default",
25
+ }
26
+ )
27
+
28
+ return {
29
+ "contexts": contexts,
30
+ "chats": contexts,
31
+ }
plugins/_a0_connector/api/v1/compact_chat.py
new
+75
@@ -0,0 +1,75 @@
1
+"""POST /api/plugins/_a0_connector/v1/compact_chat."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+def _coerce_bool(value: object, default: bool = False) -> bool:
9
+ if isinstance(value, bool):
10
+ return value
11
+ if isinstance(value, str):
12
+ normalized = value.strip().lower()
13
+ if not normalized:
14
+ return default
15
+ return normalized in {"1", "true", "yes", "on"}
16
+ if value is None:
17
+ return default
18
+ return bool(value)
19
+
20
+
21
+async def _run_compaction_task(context, use_chat_model: bool, preset_name: str | None) -> None:
22
+ from helpers.state_monitor_integration import mark_dirty_all
23
+ from plugins._chat_compaction.helpers.compactor import run_compaction
24
+
25
+ try:
26
+ await run_compaction(context, use_chat_model, preset_name)
27
+ except Exception as exc:
28
+ context.log.log(
29
+ type="error",
30
+ heading="Compaction Failed",
31
+ content=str(exc),
32
+ )
33
+ mark_dirty_all(reason="plugins._a0_connector.compact_chat_error")
34
+
35
+
36
+class CompactChat(connector_base.ProtectedConnectorApiHandler):
37
+ async def process(self, input: dict, request: Request) -> dict | Response:
38
+ from agent import AgentContext
39
+ from plugins._chat_compaction.helpers.compactor import (
40
+ MIN_COMPACTION_TOKENS,
41
+ get_compaction_stats,
42
+ )
43
+
44
+ action = str(input.get("action", "compact")).strip() or "compact"
45
+ context_id = str(
46
+ input.get("context", input.get("context_id", input.get("ctxid", "")))
47
+ ).strip()
48
+
49
+ if not context_id:
50
+ return Response("Missing context id", 400)
51
+
52
+ context = AgentContext.get(context_id)
53
+ if not context:
54
+ return Response("Context not found", 404)
55
+
56
+ if context.is_running():
57
+ return Response("Cannot compact while agent is running", 409)
58
+
59
+ stats = await get_compaction_stats(context)
60
+ if stats["token_count"] < MIN_COMPACTION_TOKENS:
61
+ return {
62
+ "ok": False,
63
+ "message": f"Not enough content to compact (minimum {MIN_COMPACTION_TOKENS:,} tokens)",
64
+ }
65
+
66
+ if action == "stats":
67
+ return {"ok": True, "stats": stats}
68
+
69
+ if action == "compact":
70
+ use_chat_model = _coerce_bool(input.get("use_chat_model", True), default=True)
71
+ preset_name = str(input.get("preset_name", "")).strip() or None
72
+ context.run_task(_run_compaction_task, context, use_chat_model, preset_name)
73
+ return {"ok": True, "message": "Compaction started"}
74
+
75
+ return Response(f"Unknown action: {action}", 400)
plugins/_a0_connector/api/v1/log_tail.py
new
+32
@@ -0,0 +1,32 @@
1
+"""POST /api/plugins/_a0_connector/v1/log_tail."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class LogTail(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from plugins._a0_connector.helpers.event_bridge import (
11
+ get_context_log_entries,
12
+ )
13
+
14
+ context_id = str(input.get("context_id", "")).strip()
15
+ if not context_id:
16
+ return Response(
17
+ response='{"error": "context_id is required"}',
18
+ status=400,
19
+ mimetype="application/json",
20
+ )
21
+
22
+ after = int(input.get("after", 0) or 0)
23
+ limit = min(int(input.get("limit", 50) or 50), 250)
24
+
25
+ events, last_sequence = get_context_log_entries(context_id, after=after)
26
+ limited_events = events[:limit]
27
+ return {
28
+ "context_id": context_id,
29
+ "events": limited_events,
30
+ "last_sequence": last_sequence,
31
+ "has_more": len(events) > len(limited_events),
32
+ }
plugins/_a0_connector/api/v1/message_send.py
new
+120
@@ -0,0 +1,120 @@
1
+"""POST /api/plugins/_a0_connector/v1/message_send."""
2
+from __future__ import annotations
3
+
4
+import base64
5
+import os
6
+import uuid
7
+
8
+from helpers.api import Request, Response
9
+from helpers.print_style import PrintStyle
10
+from helpers.security import safe_filename
11
+import plugins._a0_connector.api.v1.base as connector_base
12
+
13
+
14
+class MessageSend(connector_base.ProtectedConnectorApiHandler):
15
+ async def process(self, input: dict, request: Request) -> dict | Response:
16
+ from agent import UserMessage
17
+ from helpers import files
18
+ from plugins._a0_connector.helpers.chat_context import (
19
+ ConnectorContextError,
20
+ create_context,
21
+ get_existing_context,
22
+ )
23
+
24
+ message = str(input.get("message", "")).strip()
25
+ if not message:
26
+ return Response(
27
+ response='{"error": "message is required"}',
28
+ status=400,
29
+ mimetype="application/json",
30
+ )
31
+
32
+ context_id = str(input.get("context_id", "")).strip() or None
33
+ current_context_id = (
34
+ str(input.get("current_context", input.get("current_context_id", ""))).strip()
35
+ or None
36
+ )
37
+ project_name = str(input.get("project_name", "")).strip() or None
38
+ agent_profile = str(input.get("agent_profile", "")).strip() or None
39
+ attachments_data = input.get("attachments", [])
40
+
41
+ attachment_paths: list[str] = []
42
+ if isinstance(attachments_data, list) and attachments_data:
43
+ upload_folder_ext = files.get_abs_path("usr/uploads")
44
+ upload_folder_int = "/a0/usr/uploads"
45
+ os.makedirs(upload_folder_ext, exist_ok=True)
46
+
47
+ for attachment in attachments_data:
48
+ if not isinstance(attachment, dict):
49
+ continue
50
+ filename = str(attachment.get("filename", "")).strip()
51
+ b64_content = str(attachment.get("base64", "")).strip()
52
+ if not filename or not b64_content:
53
+ continue
54
+
55
+ try:
56
+ safe_name = safe_filename(filename)
57
+ if not safe_name:
58
+ continue
59
+ save_path = os.path.join(upload_folder_ext, safe_name)
60
+ with open(save_path, "wb") as handle:
61
+ handle.write(base64.b64decode(b64_content))
62
+ attachment_paths.append(os.path.join(upload_folder_int, safe_name))
63
+ except Exception as exc:
64
+ PrintStyle.error(f"[a0-connector] attachment error: {exc}")
65
+
66
+ try:
67
+ if context_id:
68
+ context = get_existing_context(
69
+ context_id,
70
+ agent_profile=agent_profile,
71
+ project_name=project_name,
72
+ )
73
+ else:
74
+ context = create_context(
75
+ lock=self.thread_lock,
76
+ current_context_id=current_context_id,
77
+ agent_profile=agent_profile,
78
+ project_name=project_name,
79
+ )
80
+ context_id = context.id
81
+ except ConnectorContextError as exc:
82
+ return Response(
83
+ response=f'{{"error": "{str(exc)}"}}',
84
+ status=exc.status_code,
85
+ mimetype="application/json",
86
+ )
87
+ except Exception as exc:
88
+ return Response(
89
+ response=f'{{"error": "Failed to activate project: {str(exc)}"}}',
90
+ status=400,
91
+ mimetype="application/json",
92
+ )
93
+
94
+ attachment_names = [os.path.basename(path) for path in attachment_paths]
95
+ message_id = str(uuid.uuid4())
96
+ context.log.log(
97
+ type="user",
98
+ heading="",
99
+ content=message,
100
+ kvps={"attachments": attachment_names},
101
+ id=message_id,
102
+ )
103
+
104
+ try:
105
+ task = context.communicate(
106
+ UserMessage(message=message, attachments=attachment_paths, id=message_id)
107
+ )
108
+ result = await task.result()
109
+ return {
110
+ "context_id": context_id,
111
+ "status": "completed",
112
+ "response": result,
113
+ }
114
+ except Exception as exc:
115
+ PrintStyle.error(f"[a0-connector] message_send error: {exc}")
116
+ return Response(
117
+ response=f'{{"error": "{str(exc)}"}}',
118
+ status=500,
119
+ mimetype="application/json",
120
+ )
plugins/_a0_connector/api/v1/model_presets.py
new
+29
@@ -0,0 +1,29 @@
1
+"""POST /api/plugins/_a0_connector/v1/model_presets."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class ModelPresets(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from plugins._model_config.helpers import model_config
11
+
12
+ action = str(input.get("action", "get")).strip() or "get"
13
+
14
+ if action == "get":
15
+ presets = model_config.get_presets()
16
+ return {"ok": True, "presets": presets}
17
+
18
+ if action == "save":
19
+ presets = input.get("presets")
20
+ if not isinstance(presets, list):
21
+ return Response(status=400, response="presets must be an array")
22
+ model_config.save_presets(presets)
23
+ return {"ok": True, "presets": presets}
24
+
25
+ if action == "reset":
26
+ presets = model_config.reset_presets()
27
+ return {"ok": True, "presets": presets}
28
+
29
+ return Response(status=400, response=f"Unknown action: {action}")
plugins/_a0_connector/api/v1/model_switcher.py
new
+167
@@ -0,0 +1,167 @@
1
+"""POST /api/plugins/_a0_connector/v1/model_switcher."""
2
+from __future__ import annotations
3
+
4
+from typing import Callable
5
+
6
+from helpers.api import Request, Response
7
+import plugins._a0_connector.api.v1.base as connector_base
8
+
9
+
10
+def _model_payload(config: dict | None, *, has_api_key: bool = False) -> dict[str, object]:
11
+ config = config or {}
12
+ provider = str(config.get("provider") or "").strip()
13
+ name = str(config.get("name") or "").strip()
14
+ return {
15
+ "provider": provider,
16
+ "name": name,
17
+ "label": f"{provider}/{name}" if provider and name else (name or provider or "—"),
18
+ "has_api_key": bool(has_api_key),
19
+ }
20
+
21
+
22
+def _coerce_override_model(value: object) -> dict[str, str]:
23
+ if not isinstance(value, dict):
24
+ return {}
25
+
26
+ payload: dict[str, str] = {}
27
+ provider = str(value.get("provider") or "").strip()
28
+ name = str(value.get("name") or "").strip()
29
+ api_key = str(value.get("api_key") or "").strip()
30
+ api_base = str(value.get("api_base") or value.get("base_url") or "").strip()
31
+
32
+ if provider:
33
+ payload["provider"] = provider
34
+ if name:
35
+ payload["name"] = name
36
+ if api_key:
37
+ payload["api_key"] = api_key
38
+ if api_base:
39
+ payload["api_base"] = api_base
40
+
41
+ return payload
42
+
43
+
44
+def _provider_payload(
45
+ value: object,
46
+ *,
47
+ has_api_key_lookup: Callable[[str], bool] | None = None,
48
+) -> list[dict[str, object]]:
49
+ if not isinstance(value, list):
50
+ return []
51
+
52
+ options: list[dict[str, object]] = []
53
+ seen: set[str] = set()
54
+ for item in value:
55
+ if isinstance(item, dict):
56
+ provider = str(item.get("value") or item.get("id") or "").strip().lower()
57
+ label = str(item.get("label") or item.get("name") or provider).strip()
58
+ else:
59
+ provider = str(item or "").strip().lower()
60
+ label = provider.replace("_", " ").title()
61
+
62
+ if not provider or provider in seen:
63
+ continue
64
+ seen.add(provider)
65
+ has_api_key = False
66
+ if callable(has_api_key_lookup):
67
+ try:
68
+ has_api_key = bool(has_api_key_lookup(provider))
69
+ except Exception:
70
+ has_api_key = False
71
+ elif isinstance(item, dict):
72
+ has_api_key = bool(item.get("has_api_key"))
73
+
74
+ options.append({"value": provider, "label": label or provider, "has_api_key": has_api_key})
75
+
76
+ return options
77
+
78
+
79
+class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
80
+ async def process(self, input: dict, request: Request) -> dict | Response:
81
+ from agent import AgentContext
82
+ from helpers.persist_chat import save_tmp_chat
83
+ from plugins._model_config.helpers import model_config
84
+
85
+ action = str(input.get("action", "get")).strip() or "get"
86
+ context_id = str(input.get("context_id", "")).strip()
87
+ context = AgentContext.get(context_id) if context_id else None
88
+ agent = getattr(context, "agent0", None) if context is not None else None
89
+
90
+ def build_state() -> dict[str, object]:
91
+ override = context.get_data("chat_model_override") if context is not None else None
92
+ try:
93
+ chat_providers = _provider_payload(
94
+ model_config.get_chat_providers(),
95
+ has_api_key_lookup=lambda provider: model_config.has_provider_api_key(provider, ""),
96
+ )
97
+ except Exception:
98
+ chat_providers = []
99
+ chat_model = model_config.get_chat_model_config(agent)
100
+ utility_model = model_config.get_utility_model_config(agent)
101
+
102
+ def _has_api_key(config: object) -> bool:
103
+ if not isinstance(config, dict):
104
+ return False
105
+ provider = str(config.get("provider") or "").strip().lower()
106
+ api_key = str(config.get("api_key") or "").strip()
107
+ if not provider:
108
+ return bool(api_key)
109
+ try:
110
+ return bool(model_config.has_provider_api_key(provider, api_key))
111
+ except Exception:
112
+ return bool(api_key)
113
+
114
+ return {
115
+ "ok": True,
116
+ "allowed": bool(model_config.is_chat_override_allowed(agent)),
117
+ "override": override,
118
+ "presets": model_config.get_presets(),
119
+ "chat_providers": chat_providers,
120
+ "main_model": _model_payload(chat_model, has_api_key=_has_api_key(chat_model)),
121
+ "utility_model": _model_payload(utility_model, has_api_key=_has_api_key(utility_model)),
122
+ }
123
+
124
+ if action == "get":
125
+ return build_state()
126
+
127
+ if not context_id:
128
+ return Response(status=400, response="Missing context_id")
129
+
130
+ if context is None:
131
+ return Response(status=404, response="Context not found")
132
+
133
+ if not model_config.is_chat_override_allowed(agent):
134
+ return Response(status=403, response="Per-chat override is disabled")
135
+
136
+ if action == "set_preset":
137
+ preset_name = str(input.get("preset_name", "")).strip()
138
+ if not preset_name:
139
+ return Response(status=400, response="Missing preset_name")
140
+ preset = model_config.get_preset_by_name(preset_name)
141
+ if not preset:
142
+ return Response(status=404, response=f"Preset '{preset_name}' not found")
143
+ context.set_data("chat_model_override", {"preset_name": preset_name})
144
+ save_tmp_chat(context)
145
+ return build_state()
146
+
147
+ if action == "clear":
148
+ context.set_data("chat_model_override", None)
149
+ save_tmp_chat(context)
150
+ return build_state()
151
+
152
+ if action == "set_override":
153
+ main_model = _coerce_override_model(input.get("main_model"))
154
+ utility_model = _coerce_override_model(input.get("utility_model"))
155
+ if not main_model and not utility_model:
156
+ return Response(status=400, response="Missing model override payload")
157
+
158
+ override: dict[str, dict[str, str]] = {}
159
+ if main_model:
160
+ override["chat"] = main_model
161
+ if utility_model:
162
+ override["utility"] = utility_model
163
+ context.set_data("chat_model_override", override)
164
+ save_tmp_chat(context)
165
+ return build_state()
166
+
167
+ return Response(status=400, response=f"Unknown action: {action}")
plugins/_a0_connector/api/v1/nudge.py
new
+36
@@ -0,0 +1,36 @@
1
+"""POST /api/plugins/_a0_connector/v1/nudge."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class Nudge(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from agent import AgentContext
11
+
12
+ context_id = str(input.get("context_id", "")).strip()
13
+ if not context_id:
14
+ return Response(
15
+ response='{"error": "context_id is required"}',
16
+ status=400,
17
+ mimetype="application/json",
18
+ )
19
+
20
+ context = AgentContext.get(context_id)
21
+ if context is None:
22
+ return Response(
23
+ response='{"error": "Context not found"}',
24
+ status=404,
25
+ mimetype="application/json",
26
+ )
27
+
28
+ context.nudge()
29
+ message = "Process reset, agent nudged."
30
+ context.log.log(type="info", content=message)
31
+ return {
32
+ "ok": True,
33
+ "context_id": context_id,
34
+ "status": "nudged",
35
+ "message": message,
36
+ }
plugins/_a0_connector/api/v1/pause.py
new
+48
@@ -0,0 +1,48 @@
1
+"""POST /api/plugins/_a0_connector/v1/pause."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class Pause(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from agent import AgentContext
11
+
12
+ context_id = str(input.get("context_id", "")).strip()
13
+ raw_paused = input.get("paused", True)
14
+ if isinstance(raw_paused, str):
15
+ paused = raw_paused.strip().lower() not in {"", "0", "false", "no", "off"}
16
+ else:
17
+ paused = bool(raw_paused)
18
+
19
+ if not context_id:
20
+ return Response(
21
+ response='{"error": "context_id is required"}',
22
+ status=400,
23
+ mimetype="application/json",
24
+ )
25
+
26
+ context = AgentContext.get(context_id)
27
+ if context is None:
28
+ return Response(
29
+ response='{"error": "Context not found"}',
30
+ status=404,
31
+ mimetype="application/json",
32
+ )
33
+
34
+ if paused and not context.is_running():
35
+ return Response(
36
+ response='{"error": "Context is not currently running"}',
37
+ status=409,
38
+ mimetype="application/json",
39
+ )
40
+
41
+ context.paused = paused
42
+ return {
43
+ "ok": True,
44
+ "context_id": context_id,
45
+ "paused": paused,
46
+ "status": "paused" if paused else "running",
47
+ "message": "Agent paused." if paused else "Agent unpaused.",
48
+ }
plugins/_a0_connector/api/v1/projects.py
new
+108
@@ -0,0 +1,108 @@
1
+"""POST /api/plugins/_a0_connector/v1/projects."""
2
+from __future__ import annotations
3
+
4
+from typing import Any, Mapping
5
+
6
+from helpers.api import Request, Response
7
+import plugins._a0_connector.api.v1.base as connector_base
8
+
9
+
10
+def _string(value: object) -> str:
11
+ if value is None:
12
+ return ""
13
+ return str(value).strip()
14
+
15
+
16
+def _normalize_project_summary(value: object) -> dict[str, str] | None:
17
+ if not isinstance(value, Mapping):
18
+ return None
19
+
20
+ name = _string(value.get("name"))
21
+ if not name:
22
+ return None
23
+
24
+ return {
25
+ "name": name,
26
+ "title": _string(value.get("title")),
27
+ "description": _string(value.get("description")),
28
+ "color": _string(value.get("color")),
29
+ }
30
+
31
+
32
+class Projects(connector_base.ProtectedConnectorApiHandler):
33
+ """Thin connector proxy around the core `api.projects.Projects` surface."""
34
+
35
+ async def process(self, input: dict, request: Request) -> dict | Response:
36
+ action = _string(input.get("action")).lower() or "list"
37
+ if action not in {"list", "load", "update", "activate", "deactivate"}:
38
+ return {"ok": False, "error": f"Unsupported action: {action or '<missing>'}"}
39
+
40
+ core_response = await self._call_core(
41
+ {
42
+ "action": action,
43
+ "context_id": _string(input.get("context_id")),
44
+ "name": _string(input.get("name")),
45
+ "project": input.get("project"),
46
+ },
47
+ request,
48
+ )
49
+ if isinstance(core_response, Response):
50
+ return core_response
51
+ if not isinstance(core_response, Mapping):
52
+ return {"ok": False, "error": "Invalid response from core projects handler"}
53
+ if not core_response.get("ok"):
54
+ return {"ok": False, "error": _string(core_response.get("error")) or "Project request failed"}
55
+
56
+ if action in {"activate", "deactivate", "list"}:
57
+ return await self._normalized_list_state(_string(input.get("context_id")), request)
58
+
59
+ project = core_response.get("data")
60
+ return {
61
+ "ok": True,
62
+ "project": dict(project) if isinstance(project, Mapping) else {},
63
+ }
64
+
65
+ async def _normalized_list_state(self, context_id: str, request: Request) -> dict[str, Any] | Response:
66
+ core_response = await self._call_core(
67
+ {
68
+ "action": "list",
69
+ "context_id": context_id,
70
+ },
71
+ request,
72
+ )
73
+ if isinstance(core_response, Response):
74
+ return core_response
75
+ if not isinstance(core_response, Mapping):
76
+ return {"ok": False, "error": "Invalid response from core projects handler"}
77
+ if not core_response.get("ok"):
78
+ return {"ok": False, "error": _string(core_response.get("error")) or "Project request failed"}
79
+
80
+ projects: list[dict[str, str]] = []
81
+ for item in core_response.get("data") or []:
82
+ normalized = _normalize_project_summary(item)
83
+ if normalized is not None:
84
+ projects.append(normalized)
85
+
86
+ return {
87
+ "ok": True,
88
+ "projects": projects,
89
+ "current_project": self._load_current_project(context_id),
90
+ }
91
+
92
+ async def _call_core(self, payload: dict[str, Any], request: Request) -> dict | Response:
93
+ from api.projects import Projects as CoreProjects
94
+
95
+ handler = CoreProjects(self.app, self.thread_lock)
96
+ return await handler.process(payload, request)
97
+
98
+ def _load_current_project(self, context_id: str) -> dict[str, str] | None:
99
+ if not context_id:
100
+ return None
101
+
102
+ from agent import AgentContext
103
+
104
+ context = AgentContext.get(context_id)
105
+ if context is None:
106
+ return None
107
+
108
+ return _normalize_project_summary(context.get_output_data("project"))
plugins/_a0_connector/api/v1/settings_get.py
new
+12
@@ -0,0 +1,12 @@
1
+"""POST /api/plugins/_a0_connector/v1/settings_get."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class SettingsGet(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from helpers import settings
11
+
12
+ return dict(settings.convert_out(settings.get_settings()))
plugins/_a0_connector/api/v1/settings_set.py
new
+22
@@ -0,0 +1,22 @@
1
+"""POST /api/plugins/_a0_connector/v1/settings_set."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class SettingsSet(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from helpers import settings
11
+
12
+ payload = input.get("settings", input)
13
+ if not isinstance(payload, dict):
14
+ return Response(
15
+ response='{"error":"settings must be an object"}',
16
+ status=400,
17
+ mimetype="application/json",
18
+ )
19
+
20
+ backend = settings.convert_in(settings.Settings(**payload))
21
+ backend = settings.set_settings(backend)
22
+ return dict(settings.convert_out(backend))
plugins/_a0_connector/api/v1/skills_delete.py
new
+26
@@ -0,0 +1,26 @@
1
+"""POST /api/plugins/_a0_connector/v1/skills_delete."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class SkillsDelete(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from helpers import skills
11
+
12
+ skill_path = str(input.get("skill_path") or "").strip()
13
+ if not skill_path:
14
+ return Response(
15
+ response='{"error":"skill_path is required"}',
16
+ status=400,
17
+ mimetype="application/json",
18
+ )
19
+
20
+ skills.delete_skill(skill_path)
21
+ return {
22
+ "ok": True,
23
+ "data": {
24
+ "skill_path": skill_path,
25
+ },
26
+ }
plugins/_a0_connector/api/v1/skills_list.py
new
+55
@@ -0,0 +1,55 @@
1
+"""POST /api/plugins/_a0_connector/v1/skills_list."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class SkillsList(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from helpers import files, projects, runtime, skills
11
+
12
+ skill_list = skills.list_skills()
13
+ project_name = str(input.get("project_name", "")).strip() or None
14
+
15
+ if project_name:
16
+ project_folder = projects.get_project_folder(project_name)
17
+ if runtime.is_development():
18
+ project_folder = files.normalize_a0_path(project_folder)
19
+ skill_list = [
20
+ item
21
+ for item in skill_list
22
+ if files.is_in_dir(str(item.path), project_folder)
23
+ ]
24
+
25
+ agent_profile = str(input.get("agent_profile", "")).strip() or None
26
+ if agent_profile:
27
+ roots: list[str] = [
28
+ files.get_abs_path("agents", agent_profile, "skills"),
29
+ files.get_abs_path("usr", "agents", agent_profile, "skills"),
30
+ ]
31
+ if project_name:
32
+ roots.append(
33
+ projects.get_project_meta(project_name, "agents", agent_profile, "skills")
34
+ )
35
+
36
+ skill_list = [
37
+ item
38
+ for item in skill_list
39
+ if any(files.is_in_dir(str(item.path), root) for root in roots)
40
+ ]
41
+
42
+ result = [
43
+ {
44
+ "name": skill.name,
45
+ "description": skill.description,
46
+ "path": str(skill.path),
47
+ }
48
+ for skill in skill_list
49
+ ]
50
+ result.sort(key=lambda item: (item["name"], item["path"]))
51
+
52
+ return {
53
+ "ok": True,
54
+ "data": result,
55
+ }
plugins/_a0_connector/api/v1/token_status.py
new
+57
@@ -0,0 +1,57 @@
1
+"""POST /api/plugins/_a0_connector/v1/token_status."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+import plugins._a0_connector.api.v1.base as connector_base
6
+
7
+
8
+class TokenStatus(connector_base.ProtectedConnectorApiHandler):
9
+ async def process(self, input: dict, request: Request) -> dict | Response:
10
+ from agent import Agent, AgentContext
11
+
12
+ context_id = str(
13
+ input.get("context", input.get("context_id", input.get("ctxid", "")))
14
+ ).strip()
15
+ if not context_id:
16
+ return Response(
17
+ response='{"error":"context_id required"}',
18
+ status=400,
19
+ mimetype="application/json",
20
+ )
21
+
22
+ context = AgentContext.get(context_id)
23
+ if context is None:
24
+ return Response(
25
+ response='{"error":"Context not found"}',
26
+ status=404,
27
+ mimetype="application/json",
28
+ )
29
+
30
+ agent = context.streaming_agent or context.agent0
31
+ window = agent.get_data(Agent.DATA_NAME_CTX_WINDOW) if agent is not None else None
32
+ token_count: int | None = None
33
+ if isinstance(window, dict):
34
+ raw_tokens = window.get("tokens")
35
+ try:
36
+ token_count = int(raw_tokens)
37
+ except (TypeError, ValueError):
38
+ token_count = None
39
+
40
+ context_window: int | None = None
41
+ try:
42
+ from plugins._model_config.helpers.model_config import get_chat_model_config
43
+
44
+ chat_config = get_chat_model_config(agent)
45
+ if isinstance(chat_config, dict):
46
+ raw_context_window = int(chat_config.get("ctx_length", 0))
47
+ if raw_context_window > 0:
48
+ context_window = raw_context_window
49
+ except Exception:
50
+ context_window = None
51
+
52
+ return {
53
+ "ok": True,
54
+ "context_id": context_id,
55
+ "token_count": token_count,
56
+ "context_window": context_window,
57
+ }
plugins/_a0_connector/api/ws_connector.py
new
+490
@@ -0,0 +1,490 @@
1
+"""Connector WebSocket handler for the shared `/ws` namespace."""
2
+from __future__ import annotations
3
+
4
+import asyncio
5
+from typing import TYPE_CHECKING, Any, ClassVar
6
+
7
+from helpers.print_style import PrintStyle
8
+from helpers.ws import WsHandler
9
+from helpers.ws_manager import WsResult
10
+
11
+from plugins._a0_connector.helpers.event_bridge import get_context_log_entries
12
+from plugins._a0_connector.helpers.ws_runtime import (
13
+ clear_remote_tree_snapshot,
14
+ fail_pending_file_ops_for_sid,
15
+ fail_pending_exec_ops_for_sid,
16
+ register_sid,
17
+ resolve_pending_file_op,
18
+ resolve_pending_exec_op,
19
+ store_remote_tree_snapshot,
20
+ subscribe_sid_to_context,
21
+ subscribed_contexts_for_sid,
22
+ subscribed_sids_for_context,
23
+ unsubscribe_sid_from_context,
24
+ unregister_sid,
25
+)
26
+
27
+if TYPE_CHECKING:
28
+ from agent import AgentContext, AgentContextType, UserMessage
29
+
30
+
31
+PROTOCOL_VERSION = "a0-connector.v1"
32
+WS_FEATURES = [
33
+ "connector_subscribe_context",
34
+ "connector_send_message",
35
+ "text_editor_remote",
36
+ "remote_file_tree",
37
+ "code_execution_remote",
38
+]
39
+
40
+
41
+class WsConnector(WsHandler):
42
+ _streaming_tasks: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {}
43
+
44
+ @classmethod
45
+ def requires_auth(cls) -> bool:
46
+ return True
47
+
48
+ @classmethod
49
+ def requires_csrf(cls) -> bool:
50
+ return False
51
+
52
+ @classmethod
53
+ def requires_api_key(cls) -> bool:
54
+ return False
55
+
56
+ async def on_connect(self, sid: str) -> None:
57
+ register_sid(sid)
58
+ PrintStyle.debug(f"[a0-connector] /ws connected: {sid}")
59
+
60
+ async def on_disconnect(self, sid: str) -> None:
61
+ contexts = unregister_sid(sid)
62
+ for context_id in contexts:
63
+ self._cancel_streaming(sid, context_id)
64
+ clear_remote_tree_snapshot(sid)
65
+ fail_pending_file_ops_for_sid(
66
+ sid,
67
+ error="CLI disconnected before completing the requested file operation",
68
+ )
69
+ fail_pending_exec_ops_for_sid(
70
+ sid,
71
+ error="CLI disconnected before completing the requested remote execution",
72
+ )
73
+ PrintStyle.debug(f"[a0-connector] /ws disconnected: {sid}")
74
+
75
+ async def process(
76
+ self,
77
+ event: str,
78
+ data: dict[str, Any],
79
+ sid: str,
80
+ ) -> dict[str, Any] | WsResult | None:
81
+ if event == "connector_hello":
82
+ return {
83
+ "protocol": PROTOCOL_VERSION,
84
+ "features": WS_FEATURES,
85
+ }
86
+
87
+ if event == "connector_subscribe_context":
88
+ return await self._handle_subscribe_context(data, sid)
89
+
90
+ if event == "connector_unsubscribe_context":
91
+ return self._handle_unsubscribe_context(data, sid)
92
+
93
+ if event == "connector_send_message":
94
+ return await self._handle_send_message(data, sid)
95
+
96
+ if event == "connector_file_op_result":
97
+ return self._handle_file_op_result(data, sid)
98
+
99
+ if event == "connector_remote_tree_update":
100
+ return self._handle_remote_tree_update(data, sid)
101
+
102
+ if event == "connector_exec_op_result":
103
+ return self._handle_exec_op_result(data, sid)
104
+
105
+ if event.startswith("connector_"):
106
+ return WsResult.error(
107
+ code="UNKNOWN_EVENT",
108
+ message=f"Unknown connector event: {event}",
109
+ correlation_id=data.get("correlationId"),
110
+ )
111
+
112
+ return None
113
+
114
+ async def _handle_subscribe_context(
115
+ self,
116
+ data: dict[str, Any],
117
+ sid: str,
118
+ ) -> dict[str, Any] | WsResult:
119
+ from agent import AgentContext
120
+
121
+ context_id = str(data.get("context_id", "")).strip()
122
+ from_sequence = int(data.get("from", 0) or 0)
123
+
124
+ if not context_id:
125
+ return WsResult.error(
126
+ code="MISSING_CONTEXT_ID",
127
+ message="context_id is required",
128
+ correlation_id=data.get("correlationId"),
129
+ )
130
+
131
+ context = AgentContext.get(context_id)
132
+ if context is None:
133
+ return WsResult.error(
134
+ code="CONTEXT_NOT_FOUND",
135
+ message=f"Context '{context_id}' not found",
136
+ correlation_id=data.get("correlationId"),
137
+ )
138
+
139
+ subscribe_sid_to_context(sid, context_id)
140
+ events, last_sequence = get_context_log_entries(context_id, after=from_sequence)
141
+ await self.emit_to(
142
+ sid,
143
+ "connector_context_snapshot",
144
+ {
145
+ "context_id": context_id,
146
+ "events": events,
147
+ "last_sequence": last_sequence,
148
+ },
149
+ correlation_id=data.get("correlationId"),
150
+ )
151
+ self._start_streaming(sid, context_id, from_sequence=last_sequence)
152
+
153
+ return {
154
+ "context_id": context_id,
155
+ "subscribed": True,
156
+ "last_sequence": last_sequence,
157
+ }
158
+
159
+ def _handle_unsubscribe_context(
160
+ self,
161
+ data: dict[str, Any],
162
+ sid: str,
163
+ ) -> dict[str, Any] | WsResult:
164
+ context_id = str(data.get("context_id", "")).strip()
165
+ if not context_id:
166
+ return WsResult.error(
167
+ code="MISSING_CONTEXT_ID",
168
+ message="context_id is required",
169
+ correlation_id=data.get("correlationId"),
170
+ )
171
+
172
+ self._cancel_streaming(sid, context_id)
173
+ unsubscribe_sid_from_context(sid, context_id)
174
+ return {"context_id": context_id, "unsubscribed": True}
175
+
176
+ async def _handle_send_message(
177
+ self,
178
+ data: dict[str, Any],
179
+ sid: str,
180
+ ) -> dict[str, Any] | WsResult:
181
+ from plugins._a0_connector.helpers.chat_context import ConnectorContextError
182
+
183
+ message = str(data.get("message", "")).strip()
184
+ if not message:
185
+ return WsResult.error(
186
+ code="MISSING_MESSAGE",
187
+ message="message is required",
188
+ correlation_id=data.get("correlationId"),
189
+ )
190
+
191
+ context_id = str(data.get("context_id", "")).strip() or None
192
+ current_context_id = (
193
+ str(data.get("current_context", data.get("current_context_id", ""))).strip()
194
+ or None
195
+ )
196
+ client_message_id = str(data.get("client_message_id", "")).strip()
197
+ attachments = list(data.get("attachments", [])) if isinstance(data.get("attachments"), list) else []
198
+ project_name = str(data.get("project_name", "")).strip() or None
199
+ agent_profile = str(data.get("agent_profile", "")).strip() or None
200
+
201
+ try:
202
+ context, context_id = await self._resolve_context(
203
+ context_id=context_id,
204
+ current_context_id=current_context_id,
205
+ agent_profile=agent_profile,
206
+ project_name=project_name,
207
+ )
208
+ except ConnectorContextError as exc:
209
+ return WsResult.error(
210
+ code=exc.code,
211
+ message=str(exc),
212
+ correlation_id=data.get("correlationId"),
213
+ )
214
+ except Exception as exc:
215
+ return WsResult.error(
216
+ code="BAD_REQUEST",
217
+ message=str(exc),
218
+ correlation_id=data.get("correlationId"),
219
+ )
220
+ if context is None or context_id is None:
221
+ return WsResult.error(
222
+ code="CONTEXT_NOT_FOUND",
223
+ message="Unable to resolve or create the requested context",
224
+ correlation_id=data.get("correlationId"),
225
+ )
226
+
227
+ if context_id not in subscribed_contexts_for_sid(sid):
228
+ subscribe_sid_to_context(sid, context_id)
229
+ events, last_sequence = get_context_log_entries(context_id, after=0)
230
+ await self.emit_to(
231
+ sid,
232
+ "connector_context_snapshot",
233
+ {
234
+ "context_id": context_id,
235
+ "events": events,
236
+ "last_sequence": last_sequence,
237
+ },
238
+ correlation_id=data.get("correlationId"),
239
+ )
240
+ self._start_streaming(sid, context_id, from_sequence=last_sequence)
241
+
242
+ message_id = client_message_id or data.get("correlationId") or ""
243
+ context.log.log(
244
+ type="user",
245
+ heading="",
246
+ content=message,
247
+ kvps={},
248
+ id=message_id,
249
+ )
250
+
251
+ asyncio.create_task(
252
+ self._run_message(
253
+ context=context,
254
+ context_id=context_id,
255
+ message=message,
256
+ attachments=attachments,
257
+ )
258
+ )
259
+
260
+ return {
261
+ "context_id": context_id,
262
+ "status": "accepted",
263
+ "client_message_id": client_message_id or None,
264
+ }
265
+
266
+ def _handle_file_op_result(
267
+ self,
268
+ data: dict[str, Any],
269
+ sid: str,
270
+ ) -> dict[str, Any] | WsResult:
271
+ op_id = str(data.get("op_id", "")).strip()
272
+ if not op_id:
273
+ return WsResult.error(
274
+ code="MISSING_OP_ID",
275
+ message="op_id is required",
276
+ correlation_id=data.get("correlationId"),
277
+ )
278
+
279
+ if not resolve_pending_file_op(op_id, sid=sid, payload=data):
280
+ return WsResult.error(
281
+ code="UNKNOWN_OP_ID",
282
+ message=f"No pending file operation for op_id '{op_id}'",
283
+ correlation_id=data.get("correlationId"),
284
+ )
285
+
286
+ return {"op_id": op_id, "accepted": True}
287
+
288
+ def _handle_remote_tree_update(
289
+ self,
290
+ data: dict[str, Any],
291
+ sid: str,
292
+ ) -> dict[str, Any] | WsResult:
293
+ tree = data.get("tree")
294
+ root_path = data.get("root_path")
295
+ tree_hash = data.get("tree_hash")
296
+
297
+ if not isinstance(tree, str) or not tree.strip():
298
+ return WsResult.error(
299
+ code="INVALID_TREE_PAYLOAD",
300
+ message="tree is required",
301
+ correlation_id=data.get("correlationId"),
302
+ )
303
+
304
+ if not isinstance(root_path, str) or not root_path.strip():
305
+ return WsResult.error(
306
+ code="INVALID_TREE_PAYLOAD",
307
+ message="root_path is required",
308
+ correlation_id=data.get("correlationId"),
309
+ )
310
+
311
+ if not isinstance(tree_hash, str) or not tree_hash.strip():
312
+ return WsResult.error(
313
+ code="INVALID_TREE_PAYLOAD",
314
+ message="tree_hash is required",
315
+ correlation_id=data.get("correlationId"),
316
+ )
317
+
318
+ snapshot = store_remote_tree_snapshot(sid, data)
319
+ return {
320
+ "accepted": True,
321
+ "sid": sid,
322
+ "tree_hash": tree_hash,
323
+ "updated_at": snapshot.updated_at,
324
+ }
325
+
326
+ def _handle_exec_op_result(
327
+ self,
328
+ data: dict[str, Any],
329
+ sid: str,
330
+ ) -> dict[str, Any] | WsResult:
331
+ op_id = str(data.get("op_id", "")).strip()
332
+ if not op_id:
333
+ return WsResult.error(
334
+ code="MISSING_OP_ID",
335
+ message="op_id is required",
336
+ correlation_id=data.get("correlationId"),
337
+ )
338
+
339
+ if not resolve_pending_exec_op(op_id, sid=sid, payload=data):
340
+ return WsResult.error(
341
+ code="UNKNOWN_OP_ID",
342
+ message=f"No pending exec operation for op_id '{op_id}'",
343
+ correlation_id=data.get("correlationId"),
344
+ )
345
+
346
+ return {"op_id": op_id, "accepted": True}
347
+
348
+ async def _resolve_context(
349
+ self,
350
+ *,
351
+ context_id: str | None,
352
+ current_context_id: str | None,
353
+ agent_profile: str | None,
354
+ project_name: str | None,
355
+ ) -> tuple[AgentContext | None, str | None]:
356
+ from plugins._a0_connector.helpers.chat_context import (
357
+ create_context,
358
+ get_existing_context,
359
+ )
360
+
361
+ if context_id:
362
+ context = get_existing_context(
363
+ context_id,
364
+ agent_profile=agent_profile,
365
+ project_name=project_name,
366
+ )
367
+ return context, context_id
368
+
369
+ context = create_context(
370
+ lock=self.lock,
371
+ current_context_id=current_context_id,
372
+ agent_profile=agent_profile,
373
+ project_name=project_name,
374
+ )
375
+ context_id = context.id
376
+ return context, context_id
377
+
378
+ async def _run_message(
379
+ self,
380
+ *,
381
+ context: AgentContext,
382
+ context_id: str,
383
+ message: str,
384
+ attachments: list[Any],
385
+ ) -> None:
386
+ from agent import AgentContext, UserMessage
387
+
388
+ try:
389
+ AgentContext.use(context_id)
390
+ task = context.communicate(
391
+ UserMessage(message=message, attachments=attachments)
392
+ )
393
+ result = await task.result()
394
+ except Exception as exc:
395
+ PrintStyle.error(f"[a0-connector] connector_send_message error: {exc}")
396
+ await self._emit_context_error(
397
+ context_id=context_id,
398
+ code="AGENT_ERROR",
399
+ message=str(exc),
400
+ )
401
+ await self._emit_context_complete(
402
+ context_id=context_id,
403
+ payload={"status": "error", "error": str(exc)},
404
+ )
405
+ return
406
+
407
+ await self._emit_context_complete(
408
+ context_id=context_id,
409
+ payload={"status": "completed", "response": result},
410
+ )
411
+
412
+ async def _emit_context_error(
413
+ self,
414
+ *,
415
+ context_id: str,
416
+ code: str,
417
+ message: str,
418
+ ) -> None:
419
+ payload = {
420
+ "context_id": context_id,
421
+ "code": code,
422
+ "message": message,
423
+ }
424
+ for target_sid in subscribed_sids_for_context(context_id):
425
+ try:
426
+ await self.emit_to(target_sid, "connector_error", payload)
427
+ except Exception as exc:
428
+ PrintStyle.error(
429
+ f"[a0-connector] failed to emit connector_error to {target_sid}: {exc}"
430
+ )
431
+
432
+ async def _emit_context_complete(
433
+ self,
434
+ *,
435
+ context_id: str,
436
+ payload: dict[str, Any],
437
+ ) -> None:
438
+ event_payload = {"context_id": context_id, **payload}
439
+ for target_sid in subscribed_sids_for_context(context_id):
440
+ try:
441
+ await self.emit_to(
442
+ target_sid,
443
+ "connector_context_complete",
444
+ event_payload,
445
+ )
446
+ except Exception as exc:
447
+ PrintStyle.error(
448
+ f"[a0-connector] failed to emit connector_context_complete to {target_sid}: {exc}"
449
+ )
450
+
451
+ def _start_streaming(self, sid: str, context_id: str, *, from_sequence: int) -> None:
452
+ key = (sid, context_id)
453
+ task = self._streaming_tasks.get(key)
454
+ if task is not None and not task.done():
455
+ return
456
+
457
+ task = asyncio.create_task(
458
+ self._stream_events(sid, context_id, from_sequence=from_sequence)
459
+ )
460
+ self._streaming_tasks[key] = task
461
+
462
+ def _cancel_streaming(self, sid: str, context_id: str) -> None:
463
+ task = self._streaming_tasks.pop((sid, context_id), None)
464
+ if task is not None and not task.done():
465
+ task.get_loop().call_soon_threadsafe(task.cancel)
466
+
467
+ async def _stream_events(
468
+ self,
469
+ sid: str,
470
+ context_id: str,
471
+ *,
472
+ from_sequence: int,
473
+ ) -> None:
474
+ # `from_sequence` is a log-output cursor (not an event sequence number).
475
+ cursor = max(int(from_sequence or 0), 0)
476
+ try:
477
+ while context_id in subscribed_contexts_for_sid(sid):
478
+ events, next_cursor = get_context_log_entries(context_id, after=cursor)
479
+ for event in events:
480
+ await self.emit_to(sid, "connector_context_event", event)
481
+ cursor = max(cursor, int(next_cursor or cursor))
482
+ await asyncio.sleep(0.5)
483
+ except asyncio.CancelledError:
484
+ raise
485
+ except Exception as exc:
486
+ PrintStyle.error(
487
+ f"[a0-connector] stream error sid={sid} context={context_id}: {exc}"
488
+ )
489
+ finally:
490
+ self._streaming_tasks.pop((sid, context_id), None)
plugins/_a0_connector/extensions/python/message_loop_prompts_after/_76_include_remote_file_structure.py
new
+40
@@ -0,0 +1,40 @@
1
+from __future__ import annotations
2
+
3
+import time
4
+
5
+from agent import LoopData
6
+from helpers.extension import Extension
7
+
8
+from plugins._a0_connector.helpers.ws_runtime import latest_remote_tree_for_context
9
+
10
+
11
+class IncludeRemoteFileStructure(Extension):
12
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
13
+ if not self.agent:
14
+ return
15
+
16
+ context_id = getattr(self.agent.context, "id", "")
17
+ if not context_id:
18
+ return
19
+
20
+ snapshot = latest_remote_tree_for_context(context_id, max_age_seconds=90.0)
21
+ if not snapshot:
22
+ return
23
+
24
+ file_structure = str(snapshot.get("tree") or "").strip()
25
+ if not file_structure:
26
+ return
27
+
28
+ folder = str(snapshot.get("root_path") or "").strip() or "unknown"
29
+ generated_at = str(snapshot.get("generated_at") or "unknown")
30
+ updated_at = float(snapshot.get("updated_at") or 0.0)
31
+ age_seconds = max(0, int(time.time() - updated_at))
32
+
33
+ prompt = self.agent.read_prompt(
34
+ "agent.extras.remote_file_structure.md",
35
+ folder=folder,
36
+ generated_at=generated_at,
37
+ age_seconds=age_seconds,
38
+ file_structure=file_structure,
39
+ )
40
+ loop_data.extras_temporary["remote_file_structure"] = prompt
plugins/_a0_connector/helpers/__init__.py
plugins/_a0_connector/helpers/chat_context.py
new
+115
@@ -0,0 +1,115 @@
1
+"""Shared chat-context helpers for connector handlers."""
2
+
3
+from __future__ import annotations
4
+
5
+from contextlib import nullcontext
6
+from typing import Any
7
+
8
+
9
+class ConnectorContextError(Exception):
10
+ def __init__(
11
+ self,
12
+ message: str,
13
+ *,
14
+ status_code: int = 400,
15
+ code: str = "BAD_REQUEST",
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.status_code = status_code
19
+ self.code = code
20
+
21
+
22
+def get_existing_context(
23
+ context_id: str,
24
+ *,
25
+ agent_profile: str | None = None,
26
+ project_name: str | None = None,
27
+):
28
+ from agent import AgentContext
29
+ from helpers import projects
30
+
31
+ context = AgentContext.get(context_id)
32
+ if context is None:
33
+ raise ConnectorContextError(
34
+ "Context not found",
35
+ status_code=404,
36
+ code="CONTEXT_NOT_FOUND",
37
+ )
38
+
39
+ if agent_profile and getattr(context.agent0.config, "profile", None) != agent_profile:
40
+ raise ConnectorContextError(
41
+ "Cannot change agent_profile on existing context",
42
+ status_code=400,
43
+ code="INVALID_AGENT_PROFILE",
44
+ )
45
+
46
+ existing_project = context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
47
+ if project_name and existing_project and existing_project != project_name:
48
+ raise ConnectorContextError(
49
+ "Project can only be set on first message",
50
+ status_code=400,
51
+ code="PROJECT_CONFLICT",
52
+ )
53
+
54
+ return context
55
+
56
+
57
+def create_context(
58
+ *,
59
+ lock: Any | None = None,
60
+ current_context_id: str | None = None,
61
+ agent_profile: str | None = None,
62
+ project_name: str | None = None,
63
+):
64
+ from agent import AgentContext, AgentContextType
65
+ from helpers import projects, settings
66
+ from helpers.state_monitor_integration import mark_dirty_all
67
+ from initialize import initialize_agent
68
+ from plugins._model_config.helpers.model_config import is_chat_override_allowed
69
+
70
+ override_settings: dict[str, str] = {}
71
+ if agent_profile:
72
+ override_settings["agent_profile"] = agent_profile
73
+
74
+ with lock if lock is not None else nullcontext():
75
+ current_context = AgentContext.get(current_context_id or "") if current_context_id else None
76
+
77
+ context = AgentContext(
78
+ config=initialize_agent(override_settings=override_settings),
79
+ type=AgentContextType.USER,
80
+ set_current=True,
81
+ )
82
+
83
+ if current_context and settings.get_settings().get("chat_inherit_project", True):
84
+ current_project = current_context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
85
+ if current_project:
86
+ context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, current_project)
87
+
88
+ current_project_output = current_context.get_output_data(
89
+ projects.CONTEXT_DATA_KEY_PROJECT
90
+ )
91
+ if current_project_output:
92
+ context.set_output_data(
93
+ projects.CONTEXT_DATA_KEY_PROJECT,
94
+ current_project_output,
95
+ )
96
+
97
+ if current_context:
98
+ model_override = current_context.get_data("chat_model_override")
99
+ if model_override and is_chat_override_allowed(context.agent0):
100
+ context.set_data("chat_model_override", model_override)
101
+
102
+ if project_name:
103
+ try:
104
+ try:
105
+ projects.activate_project(context.id, project_name, mark_dirty=False)
106
+ except TypeError as exc:
107
+ if "mark_dirty" not in str(exc):
108
+ raise
109
+ projects.activate_project(context.id, project_name)
110
+ except Exception:
111
+ AgentContext.remove(context.id)
112
+ raise
113
+
114
+ mark_dirty_all(reason="plugins._a0_connector.chat_context.create_context")
115
+ return context
plugins/_a0_connector/helpers/event_bridge.py
new
+128
@@ -0,0 +1,128 @@
1
+"""Context event streaming bridge for the a0-connector plugin."""
2
+from __future__ import annotations
3
+
4
+import asyncio
5
+import time
6
+from typing import Any, AsyncIterator, Callable
7
+
8
+from helpers.print_style import PrintStyle
9
+
10
+
11
+EVENT_USER_MESSAGE = "user_message"
12
+EVENT_ASSISTANT_DELTA = "assistant_delta"
13
+EVENT_ASSISTANT_MESSAGE = "assistant_message"
14
+EVENT_TOOL_START = "tool_start"
15
+EVENT_TOOL_OUTPUT = "tool_output"
16
+EVENT_TOOL_END = "tool_end"
17
+EVENT_CODE_START = "code_start"
18
+EVENT_CODE_OUTPUT = "code_output"
19
+EVENT_WARNING = "warning"
20
+EVENT_ERROR = "error"
21
+EVENT_INFO = "info"
22
+EVENT_STATUS = "status"
23
+EVENT_UTIL_MESSAGE = "util_message"
24
+EVENT_MESSAGE_COMPLETE = "message_complete"
25
+EVENT_CONTEXT_UPDATED = "context_updated"
26
+
27
+_LOG_TYPE_MAP: dict[str, str] = {
28
+ "agent": EVENT_STATUS,
29
+ "ai_response": EVENT_ASSISTANT_MESSAGE,
30
+ "browser": EVENT_TOOL_OUTPUT,
31
+ "code": EVENT_CODE_START,
32
+ "code_exe": EVENT_CODE_OUTPUT,
33
+ "code_output": EVENT_CODE_OUTPUT,
34
+ "error": EVENT_ERROR,
35
+ "hint": EVENT_STATUS,
36
+ "info": EVENT_INFO,
37
+ "input": EVENT_USER_MESSAGE,
38
+ "mcp": EVENT_TOOL_START,
39
+ "progress": EVENT_STATUS,
40
+ "response": EVENT_ASSISTANT_MESSAGE,
41
+ "subagent": EVENT_STATUS,
42
+ "tool": EVENT_TOOL_START,
43
+ "tool_output": EVENT_TOOL_OUTPUT,
44
+ "user": EVENT_USER_MESSAGE,
45
+ "util": EVENT_UTIL_MESSAGE,
46
+ "warning": EVENT_WARNING,
47
+}
48
+
49
+
50
+def log_entry_to_connector_event(
51
+ log_entry: dict[str, Any],
52
+ context_id: str,
53
+) -> dict[str, Any]:
54
+ entry_type = str(log_entry.get("type", "")).strip()
55
+ event_type = _LOG_TYPE_MAP.get(entry_type, EVENT_STATUS)
56
+ item_no = int(log_entry.get("no", 0) or 0)
57
+
58
+ data: dict[str, Any] = {}
59
+ content = log_entry.get("content")
60
+ heading = log_entry.get("heading")
61
+ kvps = log_entry.get("kvps")
62
+
63
+ if isinstance(content, str) and content:
64
+ data["text"] = content
65
+ if isinstance(heading, str) and heading:
66
+ data["heading"] = heading
67
+ if isinstance(kvps, dict) and kvps:
68
+ data["meta"] = kvps
69
+
70
+ return {
71
+ "context_id": context_id,
72
+ "sequence": item_no + 1,
73
+ "event": event_type,
74
+ "timestamp": log_entry.get("timestamp", ""),
75
+ "data": data,
76
+ }
77
+
78
+
79
+def get_context_log_entries(
80
+ context_id: str,
81
+ after: int = 0,
82
+) -> tuple[list[dict[str, Any]], int]:
83
+ """Return connector events plus the next log cursor for the context."""
84
+ try:
85
+ from agent import AgentContext
86
+
87
+ context = AgentContext.get(context_id)
88
+ if context is None:
89
+ return [], 0
90
+
91
+ log_output = context.log.output(start=max(int(after or 0), 0))
92
+ events = [
93
+ log_entry_to_connector_event(entry, context_id)
94
+ for entry in log_output.items
95
+ if isinstance(entry, dict)
96
+ ]
97
+ return events, int(log_output.end)
98
+ except Exception as exc:
99
+ PrintStyle.error(
100
+ f"[a0-connector] event_bridge error for context {context_id}: {exc}"
101
+ )
102
+ return [], max(int(after or 0), 0)
103
+
104
+
105
+async def stream_context_events(
106
+ context_id: str,
107
+ from_sequence: int = 0,
108
+ poll_interval: float = 0.5,
109
+ timeout: float = 300.0,
110
+ emit_fn: Callable[[dict[str, Any]], Any] | None = None,
111
+) -> AsyncIterator[dict[str, Any]]:
112
+ cursor = max(int(from_sequence or 0), 0)
113
+ deadline = time.monotonic() + timeout
114
+
115
+ while time.monotonic() < deadline:
116
+ events, next_cursor = get_context_log_entries(context_id, after=cursor)
117
+ for event in events:
118
+ if emit_fn is not None:
119
+ try:
120
+ result = emit_fn(event)
121
+ if asyncio.iscoroutine(result):
122
+ await result
123
+ except Exception as exc:
124
+ PrintStyle.error(f"[a0-connector] emit_fn error: {exc}")
125
+ yield event
126
+
127
+ cursor = max(cursor, next_cursor)
128
+ await asyncio.sleep(poll_interval)
plugins/_a0_connector/helpers/ws_runtime.py
new
+295
@@ -0,0 +1,295 @@
1
+from __future__ import annotations
2
+
3
+import asyncio
4
+import threading
5
+import time
6
+from dataclasses import dataclass
7
+from typing import Any
8
+
9
+
10
+@dataclass
11
+class PendingFileOperation:
12
+ sid: str
13
+ loop: asyncio.AbstractEventLoop
14
+ future: asyncio.Future[dict[str, Any]]
15
+ context_id: str | None = None
16
+
17
+
18
+@dataclass
19
+class PendingExecOperation:
20
+ sid: str
21
+ loop: asyncio.AbstractEventLoop
22
+ future: asyncio.Future[dict[str, Any]]
23
+ context_id: str | None = None
24
+
25
+
26
+@dataclass(frozen=True)
27
+class RemoteTreeSnapshot:
28
+ sid: str
29
+ payload: dict[str, Any]
30
+ updated_at: float
31
+
32
+
33
+_context_subscriptions: dict[str, set[str]] = {}
34
+_sid_contexts: dict[str, set[str]] = {}
35
+_pending_file_ops: dict[str, PendingFileOperation] = {}
36
+_pending_exec_ops: dict[str, PendingExecOperation] = {}
37
+_remote_tree_snapshots: dict[str, RemoteTreeSnapshot] = {}
38
+_state_lock = threading.RLock()
39
+
40
+
41
+def register_sid(sid: str) -> None:
42
+ with _state_lock:
43
+ _sid_contexts.setdefault(sid, set())
44
+
45
+
46
+def unregister_sid(sid: str) -> set[str]:
47
+ with _state_lock:
48
+ contexts = _sid_contexts.pop(sid, set())
49
+ _remote_tree_snapshots.pop(sid, None)
50
+ for context_id in contexts:
51
+ subscribers = _context_subscriptions.get(context_id)
52
+ if not subscribers:
53
+ continue
54
+ subscribers.discard(sid)
55
+ if not subscribers:
56
+ _context_subscriptions.pop(context_id, None)
57
+ return contexts
58
+
59
+
60
+def subscribe_sid_to_context(sid: str, context_id: str) -> None:
61
+ with _state_lock:
62
+ _sid_contexts.setdefault(sid, set()).add(context_id)
63
+ _context_subscriptions.setdefault(context_id, set()).add(sid)
64
+
65
+
66
+def unsubscribe_sid_from_context(sid: str, context_id: str) -> None:
67
+ with _state_lock:
68
+ contexts = _sid_contexts.get(sid)
69
+ if contexts is not None:
70
+ contexts.discard(context_id)
71
+ if not contexts:
72
+ _sid_contexts.pop(sid, None)
73
+
74
+ subscribers = _context_subscriptions.get(context_id)
75
+ if subscribers is not None:
76
+ subscribers.discard(sid)
77
+ if not subscribers:
78
+ _context_subscriptions.pop(context_id, None)
79
+
80
+
81
+def subscribed_contexts_for_sid(sid: str) -> set[str]:
82
+ with _state_lock:
83
+ return set(_sid_contexts.get(sid, set()))
84
+
85
+
86
+def subscribed_sids_for_context(context_id: str) -> set[str]:
87
+ with _state_lock:
88
+ return set(_context_subscriptions.get(context_id, set()))
89
+
90
+
91
+def store_remote_tree_snapshot(
92
+ sid: str,
93
+ payload: dict[str, Any],
94
+) -> RemoteTreeSnapshot:
95
+ snapshot = RemoteTreeSnapshot(
96
+ sid=sid,
97
+ payload=dict(payload),
98
+ updated_at=time.time(),
99
+ )
100
+ with _state_lock:
101
+ _remote_tree_snapshots[sid] = snapshot
102
+ return snapshot
103
+
104
+
105
+def clear_remote_tree_snapshot(sid: str) -> None:
106
+ with _state_lock:
107
+ _remote_tree_snapshots.pop(sid, None)
108
+
109
+
110
+def latest_remote_tree_for_context(
111
+ context_id: str,
112
+ *,
113
+ max_age_seconds: float = 90.0,
114
+) -> dict[str, Any] | None:
115
+ now = time.time()
116
+ with _state_lock:
117
+ subscribers = _context_subscriptions.get(context_id, set())
118
+ snapshots = [
119
+ _remote_tree_snapshots[sid]
120
+ for sid in subscribers
121
+ if sid in _remote_tree_snapshots
122
+ ]
123
+
124
+ if not snapshots:
125
+ return None
126
+
127
+ snapshots.sort(key=lambda item: item.updated_at, reverse=True)
128
+ for snapshot in snapshots:
129
+ if max_age_seconds > 0 and now - snapshot.updated_at > max_age_seconds:
130
+ continue
131
+ payload = dict(snapshot.payload)
132
+ payload["sid"] = snapshot.sid
133
+ payload["updated_at"] = snapshot.updated_at
134
+ return payload
135
+ return None
136
+
137
+
138
+def select_target_sid(context_id: str) -> str | None:
139
+ with _state_lock:
140
+ subscribers = _context_subscriptions.get(context_id, set())
141
+ if not subscribers:
142
+ return None
143
+ return sorted(subscribers)[0]
144
+
145
+
146
+def store_pending_file_op(
147
+ op_id: str,
148
+ *,
149
+ sid: str,
150
+ future: asyncio.Future[dict[str, Any]],
151
+ loop: asyncio.AbstractEventLoop,
152
+ context_id: str | None = None,
153
+) -> None:
154
+ with _state_lock:
155
+ _pending_file_ops[op_id] = PendingFileOperation(
156
+ sid=sid,
157
+ loop=loop,
158
+ future=future,
159
+ context_id=context_id,
160
+ )
161
+
162
+
163
+def clear_pending_file_op(op_id: str) -> None:
164
+ with _state_lock:
165
+ _pending_file_ops.pop(op_id, None)
166
+
167
+
168
+def resolve_pending_file_op(
169
+ op_id: str,
170
+ *,
171
+ sid: str,
172
+ payload: dict[str, Any],
173
+) -> bool:
174
+ with _state_lock:
175
+ pending = _pending_file_ops.get(op_id)
176
+ if pending is None or pending.sid != sid:
177
+ return False
178
+ _pending_file_ops.pop(op_id, None)
179
+
180
+ pending.loop.call_soon_threadsafe(_set_future_result, pending.future, dict(payload))
181
+ return True
182
+
183
+
184
+def fail_pending_file_op(
185
+ op_id: str,
186
+ *,
187
+ sid: str | None = None,
188
+ error: str,
189
+) -> bool:
190
+ with _state_lock:
191
+ pending = _pending_file_ops.get(op_id)
192
+ if pending is None:
193
+ return False
194
+ if sid is not None and pending.sid != sid:
195
+ return False
196
+ _pending_file_ops.pop(op_id, None)
197
+
198
+ payload = {"op_id": op_id, "ok": False, "error": error}
199
+ pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
200
+ return True
201
+
202
+
203
+def fail_pending_file_ops_for_sid(sid: str, *, error: str) -> None:
204
+ with _state_lock:
205
+ matches = [
206
+ (op_id, pending)
207
+ for op_id, pending in _pending_file_ops.items()
208
+ if pending.sid == sid
209
+ ]
210
+ for op_id, _pending in matches:
211
+ _pending_file_ops.pop(op_id, None)
212
+
213
+ for op_id, pending in matches:
214
+ payload = {"op_id": op_id, "ok": False, "error": error}
215
+ pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
216
+
217
+
218
+def store_pending_exec_op(
219
+ op_id: str,
220
+ *,
221
+ sid: str,
222
+ future: asyncio.Future[dict[str, Any]],
223
+ loop: asyncio.AbstractEventLoop,
224
+ context_id: str | None = None,
225
+) -> None:
226
+ with _state_lock:
227
+ _pending_exec_ops[op_id] = PendingExecOperation(
228
+ sid=sid,
229
+ loop=loop,
230
+ future=future,
231
+ context_id=context_id,
232
+ )
233
+
234
+
235
+def clear_pending_exec_op(op_id: str) -> None:
236
+ with _state_lock:
237
+ _pending_exec_ops.pop(op_id, None)
238
+
239
+
240
+def resolve_pending_exec_op(
241
+ op_id: str,
242
+ *,
243
+ sid: str,
244
+ payload: dict[str, Any],
245
+) -> bool:
246
+ with _state_lock:
247
+ pending = _pending_exec_ops.get(op_id)
248
+ if pending is None or pending.sid != sid:
249
+ return False
250
+ _pending_exec_ops.pop(op_id, None)
251
+
252
+ pending.loop.call_soon_threadsafe(_set_future_result, pending.future, dict(payload))
253
+ return True
254
+
255
+
256
+def fail_pending_exec_op(
257
+ op_id: str,
258
+ *,
259
+ sid: str | None = None,
260
+ error: str,
261
+) -> bool:
262
+ with _state_lock:
263
+ pending = _pending_exec_ops.get(op_id)
264
+ if pending is None:
265
+ return False
266
+ if sid is not None and pending.sid != sid:
267
+ return False
268
+ _pending_exec_ops.pop(op_id, None)
269
+
270
+ payload = {"op_id": op_id, "ok": False, "error": error}
271
+ pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
272
+ return True
273
+
274
+
275
+def fail_pending_exec_ops_for_sid(sid: str, *, error: str) -> None:
276
+ with _state_lock:
277
+ matches = [
278
+ (op_id, pending)
279
+ for op_id, pending in _pending_exec_ops.items()
280
+ if pending.sid == sid
281
+ ]
282
+ for op_id, _pending in matches:
283
+ _pending_exec_ops.pop(op_id, None)
284
+
285
+ for op_id, pending in matches:
286
+ payload = {"op_id": op_id, "ok": False, "error": error}
287
+ pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
288
+
289
+
290
+def _set_future_result(
291
+ future: asyncio.Future[dict[str, Any]],
292
+ payload: dict[str, Any],
293
+) -> None:
294
+ if not future.done():
295
+ future.set_result(payload)
plugins/_a0_connector/plugin.yaml
new
+9
@@ -0,0 +1,9 @@
1
+name: _a0_connector
2
+title: A0 Connector
3
+description: Current Agent Zero connector plugin for HTTP plus /ws integration, using session auth and handler activation through auth.handlers.
4
+version: 0.1.0
5
+settings_sections:
6
+ - external
7
+ - developer
8
+per_project_config: false
9
+per_agent_config: false
plugins/_a0_connector/prompts/agent.extras.remote_file_structure.md
new
+7
@@ -0,0 +1,7 @@
1
+# Remote file structure of connected CLI workspace {{folder}}
2
+- this snapshot comes from the frontend machine, not the Agent Zero server filesystem
3
+- snapshot age (seconds): {{age_seconds}}
4
+- generated at: {{generated_at}}
5
+
6
+## file tree
7
+{{file_structure}}
plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md
new
+102
@@ -0,0 +1,102 @@
1
+# code_execution_remote tool
2
+
3
+This tool runs shell-backed execution on the **remote machine where the CLI is running**.
4
+It converges onto Agent Zero Core's persistent local-shell model, so the frontend session
5
+can execute terminal commands and shell-launched `python` / `nodejs` snippets while keeping
6
+session ids stable across calls.
7
+
8
+## Requirements
9
+- A CLI client must be connected to this context via the shared `/ws` namespace.
10
+- The CLI client must support `connector_exec_op`.
11
+- Frontend execution may be locally disabled in the CLI session; in that case the result is
12
+ a structured `{ok: false}` error and no fallback runtime is used.
13
+
14
+## Arguments
15
+- `runtime`: one of `terminal`, `python`, `nodejs`, `output`, `reset`
16
+- `runtime=input` is a temporary deprecated compatibility alias for sending one line of
17
+ keyboard input into a running shell session
18
+- `session`: integer session id (default `0`)
19
+
20
+Runtime-specific fields:
21
+- `terminal`, `python`, `nodejs`: require `code`
22
+- `input`: requires `keyboard` (or `code` as fallback)
23
+- `reset`: optional `reason`
24
+
25
+## Usage
26
+
27
+### Execute a terminal command
28
+```json
29
+{
30
+ "tool_name": "code_execution_remote",
31
+ "tool_args": {
32
+ "runtime": "terminal",
33
+ "session": 0,
34
+ "code": "pwd && ls -la"
35
+ }
36
+}
37
+```
38
+
39
+### Execute Python through the shell-backed runtime
40
+```json
41
+{
42
+ "tool_name": "code_execution_remote",
43
+ "tool_args": {
44
+ "runtime": "python",
45
+ "session": 0,
46
+ "code": "import os\nprint(os.getcwd())"
47
+ }
48
+}
49
+```
50
+
51
+### Execute Node.js through the shell-backed runtime
52
+```json
53
+{
54
+ "tool_name": "code_execution_remote",
55
+ "tool_args": {
56
+ "runtime": "nodejs",
57
+ "session": 0,
58
+ "code": "console.log(process.cwd())"
59
+ }
60
+}
61
+```
62
+
63
+### Poll output from a running session
64
+```json
65
+{
66
+ "tool_name": "code_execution_remote",
67
+ "tool_args": {
68
+ "runtime": "output",
69
+ "session": 0
70
+ }
71
+}
72
+```
73
+
74
+### Send keyboard input to a running session
75
+```json
76
+{
77
+ "tool_name": "code_execution_remote",
78
+ "tool_args": {
79
+ "runtime": "input",
80
+ "session": 0,
81
+ "keyboard": "yes"
82
+ }
83
+}
84
+```
85
+
86
+### Reset a session
87
+```json
88
+{
89
+ "tool_name": "code_execution_remote",
90
+ "tool_args": {
91
+ "runtime": "reset",
92
+ "session": 0,
93
+ "reason": "stuck process"
94
+ }
95
+}
96
+```
97
+
98
+## Notes
99
+- Session state is frontend-local and shell-backed.
100
+- `output` is for long-running operations where a prior call returned control before the
101
+ shell reached a prompt.
102
+- The transport uses `connector_exec_op` and `connector_exec_op_result` with shared `op_id`.
plugins/_a0_connector/prompts/agent.system.tool.text_editor_remote.md
new
+60
@@ -0,0 +1,60 @@
1
+# text_editor_remote tool
2
+
3
+This tool allows you to read, write, and patch files on the **remote machine where the CLI is running**.
4
+This is different from `text_editor` which operates on the Agent Zero server's filesystem.
5
+
6
+Use `text_editor_remote` when the user asks you to edit files on their local machine while connected via the CLI.
7
+
8
+## Requirements
9
+- A CLI client must be connected to this context via the shared `/ws` namespace.
10
+- The CLI client must have enabled remote file editing support.
11
+
12
+## Operations
13
+
14
+### Read a file
15
+```json
16
+{
17
+ "tool_name": "text_editor_remote",
18
+ "tool_args": {
19
+ "op": "read",
20
+ "path": "/path/on/remote/machine/file.py",
21
+ "line_from": 1,
22
+ "line_to": 50
23
+ }
24
+}
25
+```
26
+Returns file content with line numbers. `line_from` and `line_to` are optional.
27
+
28
+### Write a file
29
+```json
30
+{
31
+ "tool_name": "text_editor_remote",
32
+ "tool_args": {
33
+ "op": "write",
34
+ "path": "/path/on/remote/machine/file.py",
35
+ "content": "import os\nprint('hello')\n"
36
+ }
37
+}
38
+```
39
+Creates or overwrites the file on the remote machine.
40
+
41
+### Patch a file
42
+```json
43
+{
44
+ "tool_name": "text_editor_remote",
45
+ "tool_args": {
46
+ "op": "patch",
47
+ "path": "/path/on/remote/machine/file.py",
48
+ "edits": [
49
+ {"from": 5, "to": 5, "content": " if x == 2:\n"}
50
+ ]
51
+ }
52
+}
53
+```
54
+Applies line-range patches to the file. Use the same format as the standard `text_editor:patch` tool.
55
+
56
+## Notes
57
+- Always read the file first before patching to get current line numbers.
58
+- Paths are evaluated on the **remote machine's filesystem**, not the Agent Zero server.
59
+- If no CLI is connected, the tool will return an error message.
60
+- The transport uses `connector_file_op` and `connector_file_op_result` with a shared `op_id`.
plugins/_a0_connector/tools/__init__.py
plugins/_a0_connector/tools/code_execution_remote.py
new
+196
@@ -0,0 +1,196 @@
1
+"""code_execution_remote tool — run shell-backed frontend operations on the CLI machine via `/ws`."""
2
+from __future__ import annotations
3
+
4
+import asyncio
5
+import uuid
6
+from typing import Any
7
+
8
+from helpers.tool import Response, Tool
9
+from helpers.ws import NAMESPACE
10
+from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
11
+
12
+from plugins._a0_connector.helpers.ws_runtime import (
13
+ clear_pending_exec_op,
14
+ select_target_sid,
15
+ store_pending_exec_op,
16
+)
17
+
18
+
19
+EXEC_OP_TIMEOUT = 120.0
20
+EXEC_OP_EVENT = "connector_exec_op"
21
+
22
+
23
+class CodeExecutionRemote(Tool):
24
+ """Send shell-backed frontend execution operations to the connected CLI machine."""
25
+
26
+ def get_log_object(self):
27
+ import uuid
28
+
29
+ return self.agent.context.log.log(
30
+ type="code_exe",
31
+ heading=self.get_heading(),
32
+ content="",
33
+ kvps=self.args,
34
+ id=str(uuid.uuid4()),
35
+ )
36
+
37
+ def get_heading(self, text: str = "") -> str:
38
+ if not text:
39
+ name = str(getattr(self, "name", "code_execution_remote"))
40
+ runtime = str(self.args.get("runtime", "unknown") or "unknown")
41
+ text = f"{name} - {runtime}"
42
+
43
+ normalized = " ".join(str(text).split())
44
+ if len(normalized) > 200:
45
+ normalized = normalized[:197].rstrip() + "..."
46
+
47
+ session = self.args.get("session", None)
48
+ session_text = f"[{session}] " if session or session == 0 else ""
49
+ return f"icon://terminal {session_text}{normalized}"
50
+
51
+ async def execute(self, **kwargs: Any) -> Response:
52
+ runtime = str(self.args.get("runtime", "")).strip().lower()
53
+ if runtime not in {"terminal", "python", "nodejs", "output", "input", "reset"}:
54
+ return Response(
55
+ message=(
56
+ "runtime is required (terminal, python, nodejs, output, reset, "
57
+ "or input [deprecated compatibility alias])"
58
+ ),
59
+ break_loop=False,
60
+ )
61
+
62
+ context_id = self.agent.context.id
63
+ sid = select_target_sid(context_id)
64
+ if not sid:
65
+ return Response(
66
+ message=(
67
+ "code_execution_remote: no CLI client connected to this context. "
68
+ "Make sure the CLI is connected and subscribed."
69
+ ),
70
+ break_loop=False,
71
+ )
72
+
73
+ try:
74
+ session = int(self.args.get("session", 0) or 0)
75
+ except (TypeError, ValueError):
76
+ return Response(
77
+ message="session must be an integer",
78
+ break_loop=False,
79
+ )
80
+
81
+ op_id = str(uuid.uuid4())
82
+ payload: dict[str, Any] = {
83
+ "op_id": op_id,
84
+ "runtime": runtime,
85
+ "session": session,
86
+ "context_id": context_id,
87
+ }
88
+
89
+ if runtime in {"terminal", "python", "nodejs"}:
90
+ code = self.args.get("code")
91
+ if code is None or not str(code).strip():
92
+ return Response(
93
+ message=f"code is required for runtime={runtime}",
94
+ break_loop=False,
95
+ )
96
+ payload["code"] = str(code)
97
+
98
+ elif runtime == "input":
99
+ keyboard = self.args.get("keyboard")
100
+ if keyboard is None:
101
+ keyboard = self.args.get("code")
102
+ if keyboard is None:
103
+ return Response(
104
+ message="keyboard is required for runtime=input",
105
+ break_loop=False,
106
+ )
107
+ payload["keyboard"] = str(keyboard)
108
+
109
+ elif runtime == "reset":
110
+ reason = self.args.get("reason")
111
+ if reason is not None:
112
+ payload["reason"] = str(reason)
113
+
114
+ loop = asyncio.get_running_loop()
115
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
116
+ store_pending_exec_op(
117
+ op_id,
118
+ sid=sid,
119
+ future=future,
120
+ loop=loop,
121
+ context_id=context_id,
122
+ )
123
+
124
+ try:
125
+ await get_shared_ws_manager().emit_to(
126
+ NAMESPACE,
127
+ sid,
128
+ EXEC_OP_EVENT,
129
+ payload,
130
+ handler_id=f"{self.__class__.__module__}.{self.__class__.__name__}",
131
+ )
132
+ result = await asyncio.wait_for(future, timeout=EXEC_OP_TIMEOUT)
133
+ except ConnectionNotFoundError:
134
+ clear_pending_exec_op(op_id)
135
+ return Response(
136
+ message=(
137
+ "code_execution_remote: the selected CLI client disconnected before "
138
+ "the execution request could be delivered"
139
+ ),
140
+ break_loop=False,
141
+ )
142
+ except asyncio.TimeoutError:
143
+ clear_pending_exec_op(op_id)
144
+ return Response(
145
+ message=(
146
+ "code_execution_remote: timed out waiting for CLI to respond "
147
+ f"to runtime={runtime!r} in session {session}"
148
+ ),
149
+ break_loop=False,
150
+ )
151
+ except Exception as exc:
152
+ clear_pending_exec_op(op_id)
153
+ return Response(
154
+ message=f"code_execution_remote: error sending exec_op: {exc}",
155
+ break_loop=False,
156
+ )
157
+ finally:
158
+ clear_pending_exec_op(op_id)
159
+
160
+ return Response(
161
+ message=self._extract_result(result, runtime, session),
162
+ break_loop=False,
163
+ )
164
+
165
+ def _extract_result(self, result: Any, runtime: str, session: int) -> str:
166
+ if not isinstance(result, dict):
167
+ return f"Unexpected response format from CLI: {result!r}"
168
+
169
+ ok = bool(result.get("ok"))
170
+ data = result.get("result")
171
+ error = result.get("error")
172
+
173
+ if not ok:
174
+ return (
175
+ f"Error (runtime={runtime!r}, session={session}): "
176
+ f"{error or 'Unknown error'}"
177
+ )
178
+
179
+ if not isinstance(data, dict):
180
+ data = {}
181
+
182
+ output = str(data.get("output") or data.get("text") or "").strip()
183
+ message = str(data.get("message") or "").strip()
184
+ running = bool(data.get("running"))
185
+
186
+ parts: list[str] = []
187
+ if message:
188
+ parts.append(message)
189
+ if output:
190
+ parts.append(output)
191
+
192
+ if not parts:
193
+ state = "running" if running else "completed"
194
+ parts.append(f"Session {session} {state}.")
195
+
196
+ return "\n\n".join(parts)
plugins/_a0_connector/tools/text_editor_remote.py
new
+156
@@ -0,0 +1,156 @@
1
+"""text_editor_remote tool — edit files on the CLI machine via `/ws`."""
2
+from __future__ import annotations
3
+
4
+import asyncio
5
+import uuid
6
+from typing import Any
7
+
8
+from helpers.tool import Response, Tool
9
+from helpers.ws import NAMESPACE
10
+from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
11
+
12
+from plugins._a0_connector.helpers.ws_runtime import (
13
+ clear_pending_file_op,
14
+ select_target_sid,
15
+ store_pending_file_op,
16
+)
17
+
18
+
19
+FILE_OP_TIMEOUT = 30.0
20
+FILE_OP_EVENT = "connector_file_op"
21
+
22
+
23
+class TextEditorRemote(Tool):
24
+ """Send file-editing operations to the connected CLI machine."""
25
+
26
+ async def execute(self, **kwargs: Any) -> Response:
27
+ op = str(self.args.get("op") or self.args.get("operation", "")).strip().lower()
28
+ if not op:
29
+ return Response(
30
+ message="op is required (read, write, or patch)",
31
+ break_loop=False,
32
+ )
33
+ if op not in {"read", "write", "patch"}:
34
+ return Response(
35
+ message=f"Unknown operation: {op!r}. Use read, write, or patch.",
36
+ break_loop=False,
37
+ )
38
+
39
+ path = str(self.args.get("path", "")).strip()
40
+ if not path:
41
+ return Response(message="path is required", break_loop=False)
42
+
43
+ context_id = self.agent.context.id
44
+ sid = select_target_sid(context_id)
45
+ if not sid:
46
+ return Response(
47
+ message=(
48
+ "text_editor_remote: no CLI client connected to this context. "
49
+ "Make sure the CLI is connected and subscribed."
50
+ ),
51
+ break_loop=False,
52
+ )
53
+
54
+ op_id = str(uuid.uuid4())
55
+ payload: dict[str, Any] = {
56
+ "op_id": op_id,
57
+ "op": op,
58
+ "path": path,
59
+ "context_id": context_id,
60
+ }
61
+ if op == "read":
62
+ if self.args.get("line_from"):
63
+ payload["line_from"] = int(self.args["line_from"])
64
+ if self.args.get("line_to"):
65
+ payload["line_to"] = int(self.args["line_to"])
66
+ elif op == "write":
67
+ content = self.args.get("content")
68
+ if content is None:
69
+ return Response(
70
+ message="content is required for write",
71
+ break_loop=False,
72
+ )
73
+ payload["content"] = content
74
+ else:
75
+ edits = self.args.get("edits")
76
+ if not edits:
77
+ return Response(
78
+ message="edits is required for patch",
79
+ break_loop=False,
80
+ )
81
+ payload["edits"] = edits
82
+
83
+ loop = asyncio.get_running_loop()
84
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
85
+ store_pending_file_op(
86
+ op_id,
87
+ sid=sid,
88
+ future=future,
89
+ loop=loop,
90
+ context_id=context_id,
91
+ )
92
+
93
+ try:
94
+ await get_shared_ws_manager().emit_to(
95
+ NAMESPACE,
96
+ sid,
97
+ FILE_OP_EVENT,
98
+ payload,
99
+ handler_id=f"{self.__class__.__module__}.{self.__class__.__name__}",
100
+ )
101
+ result = await asyncio.wait_for(future, timeout=FILE_OP_TIMEOUT)
102
+ except ConnectionNotFoundError:
103
+ clear_pending_file_op(op_id)
104
+ return Response(
105
+ message=(
106
+ "text_editor_remote: the selected CLI client disconnected before "
107
+ "the file operation could be delivered"
108
+ ),
109
+ break_loop=False,
110
+ )
111
+ except asyncio.TimeoutError:
112
+ clear_pending_file_op(op_id)
113
+ return Response(
114
+ message=(
115
+ f"text_editor_remote: timed out waiting for CLI to respond "
116
+ f"to {op} on {path!r}"
117
+ ),
118
+ break_loop=False,
119
+ )
120
+ except Exception as exc:
121
+ clear_pending_file_op(op_id)
122
+ return Response(
123
+ message=f"text_editor_remote: error sending file_op: {exc}",
124
+ break_loop=False,
125
+ )
126
+ finally:
127
+ clear_pending_file_op(op_id)
128
+
129
+ return Response(
130
+ message=self._extract_result(result, op, path),
131
+ break_loop=False,
132
+ )
133
+
134
+ def _extract_result(self, result: Any, op: str, path: str) -> str:
135
+ if not isinstance(result, dict):
136
+ return f"Unexpected response format from CLI: {result!r}"
137
+
138
+ ok = bool(result.get("ok"))
139
+ data = result.get("result")
140
+ error = result.get("error")
141
+
142
+ if not ok:
143
+ return f"Error ({op} {path!r}): {error or 'Unknown error'}"
144
+
145
+ if not isinstance(data, dict):
146
+ data = {}
147
+
148
+ if op == "read":
149
+ content = data.get("content", "")
150
+ total_lines = data.get("total_lines", "?")
151
+ return f"{path} {total_lines} lines\n>>>\n{content}\n<<<"
152
+ if op == "write":
153
+ return data.get("message") or f"{path} written successfully"
154
+ if op == "patch":
155
+ return data.get("message") or f"{path} patched successfully"
156
+ return str(data)