handler-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. handler_ai-0.1.0.dist-info/METADATA +60 -0
  2. handler_ai-0.1.0.dist-info/RECORD +234 -0
  3. handler_ai-0.1.0.dist-info/WHEEL +4 -0
  4. handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
  5. handler_cli/__init__.py +1 -0
  6. handler_cli/account_commands.py +314 -0
  7. handler_cli/addon_commands.py +128 -0
  8. handler_cli/agent_commands.py +139 -0
  9. handler_cli/app_commands.py +175 -0
  10. handler_cli/app_window.py +85 -0
  11. handler_cli/automation_commands.py +309 -0
  12. handler_cli/bridge.py +42 -0
  13. handler_cli/cli.py +326 -0
  14. handler_cli/client.py +81 -0
  15. handler_cli/daemon_commands.py +108 -0
  16. handler_cli/image_commands.py +102 -0
  17. handler_cli/inbox_commands.py +33 -0
  18. handler_cli/output.py +12 -0
  19. handler_cli/project_commands.py +63 -0
  20. handler_cli/provider_commands.py +113 -0
  21. handler_cli/secret_commands.py +64 -0
  22. handler_cli/session_bridge.py +93 -0
  23. handler_cli/session_commands.py +485 -0
  24. handler_cli/setup.py +120 -0
  25. handler_cli/terminal.py +280 -0
  26. handler_cli/terminal_client.py +37 -0
  27. handler_contracts/__init__.py +642 -0
  28. handler_contracts/account.py +36 -0
  29. handler_contracts/acp_endpoint.py +69 -0
  30. handler_contracts/addon.py +147 -0
  31. handler_contracts/agent_spec.py +75 -0
  32. handler_contracts/automation.py +285 -0
  33. handler_contracts/common.py +229 -0
  34. handler_contracts/control.py +1208 -0
  35. handler_contracts/harness.py +300 -0
  36. handler_contracts/miniapp.py +141 -0
  37. handler_contracts/provider.py +82 -0
  38. handler_contracts/remote.py +167 -0
  39. handler_contracts/sandbox.py +199 -0
  40. handler_contracts/schemas.py +313 -0
  41. handler_contracts/terminal.py +32 -0
  42. handler_control/__init__.py +7 -0
  43. handler_control/access.py +62 -0
  44. handler_control/accounts.py +279 -0
  45. handler_control/addon_install.py +268 -0
  46. handler_control/addons.py +118 -0
  47. handler_control/agent_state.py +46 -0
  48. handler_control/agents.py +257 -0
  49. handler_control/api/__init__.py +137 -0
  50. handler_control/api/accounts.py +231 -0
  51. handler_control/api/acp.py +116 -0
  52. handler_control/api/addons.py +95 -0
  53. handler_control/api/agents.py +150 -0
  54. handler_control/api/apps.py +104 -0
  55. handler_control/api/automations.py +128 -0
  56. handler_control/api/bindings.py +105 -0
  57. handler_control/api/builtin_agents.py +22 -0
  58. handler_control/api/caches.py +79 -0
  59. handler_control/api/catalog.py +197 -0
  60. handler_control/api/common.py +205 -0
  61. handler_control/api/contexts.py +88 -0
  62. handler_control/api/folders.py +51 -0
  63. handler_control/api/images.py +164 -0
  64. handler_control/api/inbox.py +21 -0
  65. handler_control/api/projects.py +170 -0
  66. handler_control/api/providers.py +107 -0
  67. handler_control/api/remote.py +45 -0
  68. handler_control/api/secrets.py +177 -0
  69. handler_control/api/sessions.py +192 -0
  70. handler_control/api/skills.py +270 -0
  71. handler_control/app_api.py +84 -0
  72. handler_control/app_http.py +214 -0
  73. handler_control/app_remote.py +206 -0
  74. handler_control/apps.py +867 -0
  75. handler_control/auth.py +48 -0
  76. handler_control/automation_config.py +255 -0
  77. handler_control/automations.py +1134 -0
  78. handler_control/bindings.py +149 -0
  79. handler_control/builds.py +341 -0
  80. handler_control/builtin_agents.py +82 -0
  81. handler_control/caches.py +113 -0
  82. handler_control/checks.py +287 -0
  83. handler_control/compose.py +317 -0
  84. handler_control/config_files.py +41 -0
  85. handler_control/contexts.py +75 -0
  86. handler_control/daemon.py +64 -0
  87. handler_control/data/automation/bin/handler-llm +85 -0
  88. handler_control/data/automations/env-issues/automation.toml +17 -0
  89. handler_control/data/automations/env-issues/extract.py +73 -0
  90. handler_control/data/automations/env-issues/findings.schema.json +28 -0
  91. handler_control/data/automations/env-issues/prompt.md +19 -0
  92. handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
  93. handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
  94. handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
  95. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
  96. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
  97. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
  98. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
  99. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
  100. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
  101. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  102. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  103. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
  104. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
  105. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  106. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
  107. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
  108. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
  109. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
  110. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
  111. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
  112. handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
  113. handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
  114. handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
  115. handler_control/data/builtin-agents/skill-review/interface.json +83 -0
  116. handler_control/data/interfaces/llm/v1/interface.toml +17 -0
  117. handler_control/data/interfaces/llm/v1/tools.json +138 -0
  118. handler_control/data/templates/README.md +10 -0
  119. handler_control/data/templates/providers/anthropic-api.toml +18 -0
  120. handler_control/data/templates/providers/llamacpp.toml +210 -0
  121. handler_control/data/templates/providers/mistral-api.toml +14 -0
  122. handler_control/data/templates/providers/ollama.toml +10 -0
  123. handler_control/data/templates/providers/openai-api.toml +13 -0
  124. handler_control/data/templates/providers/openrouter.toml +10 -0
  125. handler_control/desktop.py +68 -0
  126. handler_control/display.py +62 -0
  127. handler_control/files.py +23 -0
  128. handler_control/folders.py +68 -0
  129. handler_control/git_read.py +39 -0
  130. handler_control/image_check.py +322 -0
  131. handler_control/image_updates.py +174 -0
  132. handler_control/inbox.py +70 -0
  133. handler_control/interfaces.py +151 -0
  134. handler_control/knowledge.py +237 -0
  135. handler_control/launcher.py +356 -0
  136. handler_control/llm.py +194 -0
  137. handler_control/migrations/__init__.py +62 -0
  138. handler_control/policy.py +305 -0
  139. handler_control/projects.py +266 -0
  140. handler_control/providers.py +180 -0
  141. handler_control/relay.py +133 -0
  142. handler_control/remote.py +888 -0
  143. handler_control/remote_crypto.py +97 -0
  144. handler_control/runtime.py +140 -0
  145. handler_control/sandbox_process.py +224 -0
  146. handler_control/schedules.py +58 -0
  147. handler_control/secrets.py +242 -0
  148. handler_control/service_router.py +180 -0
  149. handler_control/services.py +92 -0
  150. handler_control/session_files.py +429 -0
  151. handler_control/sessions.py +1116 -0
  152. handler_control/settings.py +129 -0
  153. handler_control/shortcuts.py +80 -0
  154. handler_control/skill_scan.py +88 -0
  155. handler_control/skill_sources.py +61 -0
  156. handler_control/skills.py +378 -0
  157. handler_control/terminal.py +79 -0
  158. handler_control/terminals.py +108 -0
  159. handler_control/transcript.py +174 -0
  160. handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
  161. handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
  162. handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  163. handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  164. handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  165. handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  166. handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  167. handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  168. handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  169. handler_control/web-ui/favicon.svg +1 -0
  170. handler_control/web-ui/index.html +15 -0
  171. handler_control/webpush.py +90 -0
  172. handler_control/webui.py +105 -0
  173. handler_harness/__init__.py +13 -0
  174. handler_harness/addons.py +129 -0
  175. handler_harness/catalog.py +116 -0
  176. handler_harness/cli.py +109 -0
  177. handler_harness/data/addons/README.md +44 -0
  178. handler_harness/data/addons/miniapp/Containerfile +20 -0
  179. handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
  180. handler_harness/data/addons/miniapp/addon.toml +16 -0
  181. handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
  182. handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
  183. handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
  184. handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
  185. handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
  186. handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
  187. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
  188. handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
  189. handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
  190. handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
  191. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
  192. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
  193. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
  194. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
  195. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
  196. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
  197. handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
  198. handler_harness/data/catalog/README.md +12 -0
  199. handler_harness/data/catalog/playwright/Containerfile +28 -0
  200. handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
  201. handler_harness/data/catalog/playwright/addon.toml +25 -0
  202. handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
  203. handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
  204. handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
  205. handler_harness/data/catalog/shopify/Containerfile +22 -0
  206. handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
  207. handler_harness/data/catalog/shopify/addon.toml +39 -0
  208. handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
  209. handler_harness/data/handler-sandbox-env +30 -0
  210. handler_harness/data/harnesses/README.md +47 -0
  211. handler_harness/data/harnesses/claude-code/Containerfile +27 -0
  212. handler_harness/data/harnesses/claude-code/harness.toml +95 -0
  213. handler_harness/data/harnesses/codex/Containerfile +26 -0
  214. handler_harness/data/harnesses/codex/harness.toml +103 -0
  215. handler_harness/data/harnesses/pi/Containerfile +27 -0
  216. handler_harness/data/harnesses/pi/harness.toml +65 -0
  217. handler_harness/data/harnesses/vibe/Containerfile +19 -0
  218. handler_harness/data/harnesses/vibe/harness.toml +96 -0
  219. handler_harness/data/toolbox/Containerfile +56 -0
  220. handler_harness/data/toolbox/README.md +24 -0
  221. handler_harness/data/toolbox/toolbox.toml +41 -0
  222. handler_harness/images.py +62 -0
  223. handler_harness/providers.py +300 -0
  224. handler_harness/render.py +472 -0
  225. handler_sandbox_podman/__init__.py +1 -0
  226. handler_sandbox_podman/caches.py +97 -0
  227. handler_sandbox_podman/check.py +121 -0
  228. handler_sandbox_podman/cli.py +239 -0
  229. handler_sandbox_podman/egress.py +118 -0
  230. handler_sandbox_podman/images.py +199 -0
  231. handler_sandbox_podman/launch.py +474 -0
  232. handler_sandbox_podman/podman.py +108 -0
  233. handler_sandbox_podman/proxy/Containerfile +6 -0
  234. handler_sandbox_podman/proxy/addon.py +255 -0
@@ -0,0 +1,180 @@
1
+ """Providers — contract E, D.12: `~/.config/handler-ai/providers/<name>.toml`, an endpoint for
2
+ each protocol it speaks, its key and its models, one per file, name = file stem. Written through the
3
+ Control API (from a template of `templates/providers/`, or edited), or by hand. Through the API,
4
+ its key goes to the secret store (`providers/<name>/apiKey`); a hand-written file may name an
5
+ `env`, `file` or `command` key instead, which the API keeps as it is and never writes.
6
+
7
+ Status: `ready` when its key, if it has one, resolves; `incomplete` with the problem otherwise;
8
+ `invalid` when the file is not a valid provider.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ import tomlkit
15
+ from pydantic import ValidationError
16
+
17
+ from handler_contracts import (
18
+ PROVIDER_API_VERSION,
19
+ CommandSecret,
20
+ Provider,
21
+ ProviderRequest,
22
+ ProviderTemplate,
23
+ StoreSecret,
24
+ )
25
+ from handler_harness import catalog
26
+
27
+ from . import secrets
28
+ from .files import describe, write_private
29
+
30
+
31
+ class ProviderError(ValueError):
32
+ pass
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LoadedProvider:
37
+ name: str
38
+ path: Path
39
+ provider: Provider | None
40
+ error: str | None = None # set when the file is invalid (its agents are then unusable)
41
+
42
+
43
+ def load(path: Path) -> LoadedProvider:
44
+ name = path.stem
45
+ if not path.is_file():
46
+ return LoadedProvider(name, path, None, f"no provider '{name}' ({path})")
47
+ try:
48
+ provider = Provider.from_toml(path.read_text())
49
+ except ValidationError as e:
50
+ return LoadedProvider(name, path, None, describe(e))
51
+ except (OSError, ValueError) as e: # unreadable file, TOML syntax error
52
+ return LoadedProvider(name, path, None, str(e))
53
+ return LoadedProvider(name, path, provider)
54
+
55
+
56
+ def get(directory: Path, name: str) -> LoadedProvider:
57
+ if "/" in name or name.startswith("."):
58
+ return LoadedProvider(name, directory / name, None, f"invalid provider name '{name}'")
59
+ return load(directory / f"{name}.toml")
60
+
61
+
62
+ def load_all(directory: Path) -> list[LoadedProvider]:
63
+ if not directory.is_dir():
64
+ return []
65
+ return [load(p) for p in sorted(directory.glob("*.toml"))]
66
+
67
+
68
+ def problems(provider: Provider, store: secrets.SecretStore) -> list[str]:
69
+ """What keeps it from being used as it is: a key that does not resolve. Resolving a `command`
70
+ key runs it: only `env` and `file` ones are checked."""
71
+ ref = provider.api_key
72
+ if ref is None or isinstance(ref, CommandSecret):
73
+ return []
74
+ try:
75
+ secrets.resolve_all({"apiKey": ref}, store)
76
+ except secrets.SecretError as e:
77
+ return [f"apiKey: {e}"]
78
+ return []
79
+
80
+
81
+ def harnesses(provider: Provider) -> list[str]:
82
+ """The harnesses that speak one of its protocols."""
83
+ return [
84
+ h.name
85
+ for h in catalog.all_harnesses()
86
+ if provider.endpoint_for(h.descriptor.provider_protocols) is not None
87
+ ]
88
+
89
+
90
+ def templates(templates_dir: Path) -> list[ProviderTemplate]:
91
+ return [
92
+ ProviderTemplate(
93
+ name=loaded.name,
94
+ description=p.description,
95
+ endpoints=dict(p.endpoints),
96
+ models=p.models,
97
+ needs_key=p.api_key is not None,
98
+ files=p.files,
99
+ )
100
+ for loaded in load_all(templates_dir / "providers")
101
+ if (p := loaded.provider) is not None
102
+ ]
103
+
104
+
105
+ def key_path(name: str) -> str:
106
+ return f"providers/{name}/apiKey"
107
+
108
+
109
+ def save(
110
+ directory: Path,
111
+ store: secrets.SecretStore,
112
+ request: ProviderRequest,
113
+ old: LoadedProvider | None,
114
+ ) -> LoadedProvider:
115
+ """Create the provider (`old` None) or replace `old`, renaming it to `request.name`: its key
116
+ follows. A new key goes to the secret store; without one, the key it has is kept."""
117
+ name = request.name
118
+ target = directory / f"{name}.toml"
119
+ if (old is None or old.name != name) and target.exists():
120
+ raise ProviderError(f"provider '{name}' exists")
121
+ key = old.provider.api_key if old is not None and old.provider is not None else None
122
+ files = old.provider.files if old is not None and old.provider is not None else None
123
+ if request.files is not None:
124
+ files = request.files
125
+ if old is not None and old.name != name:
126
+ store.rename(f"providers/{old.name}", f"providers/{name}")
127
+ if isinstance(key, StoreSecret) and key.secret == key_path(old.name):
128
+ key = StoreSecret(secret=key_path(name))
129
+ if request.key is not None:
130
+ store.put(key_path(name), request.key, f"key of the provider {name}")
131
+ key = StoreSecret(secret=key_path(name))
132
+ elif request.remove_key:
133
+ store.delete(key_path(name))
134
+ key = None
135
+ provider = Provider(
136
+ api_version=PROVIDER_API_VERSION,
137
+ description=request.description,
138
+ endpoints=request.endpoints,
139
+ api_key=key,
140
+ models=request.models,
141
+ files=files,
142
+ contexts=list(dict.fromkeys(request.contexts)),
143
+ )
144
+ write_private(target, _toml(provider))
145
+ if old is not None and old.name != name:
146
+ old.path.unlink(missing_ok=True)
147
+ return load(target)
148
+
149
+
150
+ def delete(directory: Path, store: secrets.SecretStore, name: str) -> None:
151
+ (directory / f"{name}.toml").unlink(missing_ok=True)
152
+ store.delete(f"providers/{name}")
153
+
154
+
155
+ def _toml(provider: Provider) -> str:
156
+ data = provider.model_dump(by_alias=True, exclude_defaults=True, mode="json")
157
+ doc = tomlkit.document()
158
+ for field in ("apiVersion", "description", "contexts"):
159
+ if field in data:
160
+ doc[field] = data[field]
161
+ if "apiKey" in data:
162
+ key = tomlkit.inline_table()
163
+ key.update(data["apiKey"])
164
+ doc["apiKey"] = key
165
+ doc["endpoints"] = data["endpoints"]
166
+ models = tomlkit.aot()
167
+ for model in data["models"]:
168
+ models.append(tomlkit.item(model))
169
+ doc["models"] = models
170
+ if "files" in data: # [files.<harness>] slot = { content | file | env = … }
171
+ files = tomlkit.table(is_super_table=True)
172
+ for harness, slots in data["files"].items():
173
+ table = tomlkit.table()
174
+ for slot, source in slots.items():
175
+ entry = tomlkit.inline_table()
176
+ entry.update(source)
177
+ table[slot] = entry
178
+ files[harness] = table
179
+ doc["files"] = files
180
+ return tomlkit.dumps(doc)
@@ -0,0 +1,133 @@
1
+ """ACP relay — contract C transport: one WebSocket ↔ one agent process, through the policy.
2
+
3
+ Security rules of the transport itself (on top of the policy):
4
+ - only single JSON objects are forwarded; batches and unparseable lines are dropped, so the
5
+ policy always sees every message the agent could act on;
6
+ - forwarded messages are re-serialized from what the policy inspected (no parser differential);
7
+ - one JSON-RPC message per WebSocket frame; binary frames close with 1003.
8
+ """
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ from typing import Any
15
+
16
+ from starlette.websockets import WebSocket, WebSocketDisconnect
17
+
18
+ from handler_contracts import AcpCloseCode
19
+
20
+ from .agents import AgentRecord, AgentRegistry
21
+ from .launcher import StartedAgent
22
+ from .policy import AcpPolicy, ToAgent, ToClient, parse
23
+ from .sandbox_process import read_stderr, stop
24
+
25
+ log = logging.getLogger("handler.relay")
26
+
27
+
28
+ class Relay:
29
+ def __init__(
30
+ self,
31
+ ws: WebSocket,
32
+ agent: StartedAgent,
33
+ policy: AcpPolicy,
34
+ record: AgentRecord,
35
+ registry: AgentRegistry,
36
+ ) -> None:
37
+ self.ws = ws
38
+ self.agent = agent
39
+ self.proc: asyncio.subprocess.Process = agent.process # ty: ignore[invalid-assignment]
40
+ self.policy = policy
41
+ self.record = record
42
+ self.registry = registry
43
+ self.ws_lock = asyncio.Lock()
44
+ self.agent_lock = asyncio.Lock()
45
+ self.close_code: int = AcpCloseCode.NORMAL
46
+ self.close_reason = ""
47
+
48
+ async def to_client(self, msg: dict[str, Any]) -> None:
49
+ async with self.ws_lock:
50
+ await self.ws.send_text(json.dumps(msg))
51
+
52
+ async def to_agent(self, msg: dict[str, Any]) -> None:
53
+ assert self.proc.stdin is not None
54
+ async with self.agent_lock:
55
+ self.proc.stdin.write(json.dumps(msg).encode() + b"\n")
56
+ await self.proc.stdin.drain()
57
+
58
+ async def execute(self, actions: list[ToAgent | ToClient]) -> None:
59
+ for action in actions:
60
+ if isinstance(action, ToAgent):
61
+ await self.to_agent(action.msg)
62
+ else:
63
+ await self.to_client(action.msg)
64
+
65
+ # ── the three pumps ─────────────────────────────────────────────────
66
+
67
+ async def client_to_agent(self) -> None:
68
+ while True:
69
+ event = await self.ws.receive()
70
+ if event["type"] == "websocket.disconnect":
71
+ return
72
+ if event.get("bytes") is not None:
73
+ self.close_code, self.close_reason = AcpCloseCode.UNSUPPORTED_DATA, "binary frame"
74
+ return
75
+ for line in (event.get("text") or "").splitlines():
76
+ if not line.strip():
77
+ continue
78
+ msg = parse(line)
79
+ if msg is None:
80
+ log.warning(
81
+ "[%s] dropped a non-object client message", self.agent.connection_id
82
+ )
83
+ continue
84
+ await self.execute(self.policy.from_client(msg))
85
+
86
+ async def agent_to_client(self) -> None:
87
+ assert self.proc.stdout is not None
88
+ while True:
89
+ try:
90
+ line = await self.proc.stdout.readline()
91
+ except (ValueError, asyncio.LimitOverrunError):
92
+ self.close_code, self.close_reason = (
93
+ AcpCloseCode.MESSAGE_TOO_BIG,
94
+ "agent message too big",
95
+ )
96
+ return
97
+ if not line:
98
+ code = await self.proc.wait()
99
+ self.close_code = AcpCloseCode.AGENT_EXITED
100
+ self.close_reason = f"agent exited ({code})"
101
+ return
102
+ msg = parse(line.decode(errors="replace"))
103
+ if msg is None:
104
+ log.warning("[%s] dropped a non-object agent message", self.agent.connection_id)
105
+ continue
106
+ await self.execute(self.policy.from_agent(msg))
107
+
108
+ # ── lifecycle (C.3) ─────────────────────────────────────────────────
109
+
110
+ async def serve(self) -> None:
111
+ pumps = [
112
+ asyncio.create_task(self.client_to_agent()),
113
+ asyncio.create_task(self.agent_to_client()),
114
+ ]
115
+ cid = self.agent.connection_id
116
+ stderr = asyncio.create_task(
117
+ read_stderr(self.proc, cid, lambda e: self.registry.add_event(self.record, cid, e))
118
+ )
119
+ try:
120
+ await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
121
+ except WebSocketDisconnect:
122
+ pass
123
+ finally:
124
+ for task in pumps:
125
+ task.cancel()
126
+ await self.stop_agent()
127
+ stderr.cancel()
128
+ with contextlib.suppress(Exception):
129
+ await self.ws.close(code=self.close_code, reason=self.close_reason[:120])
130
+
131
+ async def stop_agent(self) -> None:
132
+ """Closing the connection stops the sandbox (C.3)."""
133
+ await stop(self.proc)