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,305 @@
1
+ """ACP policy pipeline — contract C.4, rules P1-P9.
2
+
3
+ A pure state machine, independent of any transport: feed it one JSON-RPC message at a time
4
+ (`from_client` / `from_agent`) and it returns what to send and to whom. The WebSocket relay
5
+ (relay.py) is just a transport around it.
6
+
7
+ P1 initialize: force clientCapabilities fs/terminal off
8
+ P2 session/new|load|resume: cwd must be inside the agent's scope
9
+ P3 session/new|load|resume: HTTP MCP servers must be reachable under the network policy
10
+ (checked after P8, so the platform's own servers are checked too)
11
+ P4 agent→client fs/* and terminal/*: never forwarded, the agent gets an error
12
+ P5 client→agent `$/…` notifications (heartbeats): dropped
13
+ P6 `_…` extension methods: forwarded, logged
14
+ P7 after session/new or session/load: apply session defaults (mode, config options) before
15
+ the client sees the session; failure → the client gets an error, never a session in
16
+ the wrong mode (a loaded session runs in a new sandbox: the agent's mode applies again)
17
+ P8 session/new|load|resume: add the platform's MCP servers (its addons') to the
18
+ client's; a client server with the same name is replaced
19
+ P9 the first session/prompt of a session this connection started (session/new): the
20
+ agent's instructions before its text (a daemon session does it itself, F.3)
21
+ """
22
+
23
+ import itertools
24
+ import json
25
+ import logging
26
+ import posixpath
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ from handler_contracts import (
32
+ BLOCKED_AGENT_METHOD_PREFIXES,
33
+ CWD_CHECKED_METHODS,
34
+ FilteredNetwork,
35
+ NetworkPolicy,
36
+ NoNetwork,
37
+ PolicyErrorCode,
38
+ SessionDefaults,
39
+ egress_target,
40
+ is_within,
41
+ )
42
+
43
+ log = logging.getLogger("handler.policy")
44
+
45
+ Message = dict[str, Any]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ToAgent:
50
+ msg: Message
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ToClient:
55
+ msg: Message
56
+
57
+
58
+ Action = ToAgent | ToClient
59
+
60
+
61
+ def parse(line: str) -> Message | None:
62
+ """One line of an NDJSON stream as a JSON-RPC message; None when it is not a single object
63
+ (C.1: batches and unparseable lines are dropped)."""
64
+ try:
65
+ msg = json.loads(line)
66
+ except ValueError:
67
+ return None
68
+ return msg if isinstance(msg, dict) else None
69
+
70
+
71
+ def is_request(m: Message) -> bool:
72
+ return "method" in m and "id" in m
73
+
74
+
75
+ def is_notification(m: Message) -> bool:
76
+ return "method" in m and "id" not in m
77
+
78
+
79
+ def is_response(m: Message) -> bool:
80
+ return "method" not in m and "id" in m
81
+
82
+
83
+ def error_response(req_id: Any, code: PolicyErrorCode, rule: str, message: str) -> Message:
84
+ error = {"code": int(code), "message": message, "data": {"policy": rule}}
85
+ return {"jsonrpc": "2.0", "id": req_id, "error": error}
86
+
87
+
88
+ def target_allowed(target: str, network: NetworkPolicy) -> bool:
89
+ """Whether 'host:port' is reachable under the network policy (A.3: the port must match)."""
90
+ if isinstance(network, NoNetwork):
91
+ return False
92
+ if not isinstance(network, FilteredNetwork):
93
+ return True # open
94
+ host, _, port = target.rpartition(":")
95
+ for entry in network.allow:
96
+ entry_host, _, entry_port = entry.rpartition(":")
97
+ if entry_port != port:
98
+ continue
99
+ if entry_host.startswith("*.") and host.endswith(entry_host[1:]):
100
+ return True
101
+ if host == entry_host:
102
+ return True
103
+ return False
104
+
105
+
106
+ @dataclass
107
+ class _HeldSession:
108
+ """A session/new response held back while P7 applies the session defaults."""
109
+
110
+ response: Message
111
+ session_id: str
112
+ queue: list[Message] # injected requests still to send, in order
113
+ failures: list[str] = field(default_factory=list)
114
+
115
+
116
+ class AcpPolicy:
117
+ def __init__(
118
+ self,
119
+ *,
120
+ scope: str,
121
+ network: NetworkPolicy,
122
+ session_defaults: SessionDefaults | None = None,
123
+ instructions: str | None = None,
124
+ on_event: Callable[[str], None] | None = None,
125
+ ) -> None:
126
+ self.scope = scope
127
+ self.network = network
128
+ self.defaults = session_defaults
129
+ self.instructions = instructions
130
+ self.on_event = on_event or log.info
131
+ self._session_ids: dict[Any, str | None] = {} # session/new|load request id → its sessionId
132
+ self._uninstructed: set[str] = set() # sessions started here, their first prompt to come
133
+ self._held: dict[str, _HeldSession] = {} # injected request id → held session
134
+ self._ids = itertools.count(1)
135
+
136
+ # ── client → agent ──────────────────────────────────────────────────
137
+
138
+ def from_client(self, m: Message) -> list[Action]:
139
+ method = m.get("method")
140
+ if is_notification(m) and isinstance(method, str) and method.startswith("$/"):
141
+ return [] # P5
142
+ if method == "initialize" and is_request(m):
143
+ return [ToAgent(self._force_capabilities(m))] # P1
144
+ if method in CWD_CHECKED_METHODS and is_request(m):
145
+ m = self._add_mcp_servers(m) # P8
146
+ if refusal := self.check_session_params(m):
147
+ return [ToClient(refusal)] # P2 / P3
148
+ if method in ("session/new", "session/load"):
149
+ self._session_ids[m["id"]] = (m.get("params") or {}).get("sessionId")
150
+ if method == "session/prompt" and is_request(m):
151
+ m = self._p9_instruct(m)
152
+ if isinstance(method, str) and method.startswith("_"):
153
+ self.on_event(f"extension {method} (client→agent)") # P6
154
+ return [ToAgent(m)]
155
+
156
+ def _p9_instruct(self, m: Message) -> Message:
157
+ params = m.get("params") or {}
158
+ session_id = params.get("sessionId")
159
+ if not self.instructions or session_id not in self._uninstructed:
160
+ return m
161
+ self._uninstructed.discard(session_id)
162
+ return m | {"params": params | {"prompt": instructed(self.instructions, params)}}
163
+
164
+ def _force_capabilities(self, m: Message) -> Message:
165
+ params = dict(m.get("params") or {})
166
+ caps = dict(params.get("clientCapabilities") or {})
167
+ caps["fs"] = {"readTextFile": False, "writeTextFile": False}
168
+ caps["terminal"] = False
169
+ params["clientCapabilities"] = caps
170
+ return m | {"params": params}
171
+
172
+ def _add_mcp_servers(self, m: Message) -> Message:
173
+ platform = (self.defaults.mcp_servers if self.defaults else None) or []
174
+ if not platform:
175
+ return m
176
+ params = dict(m.get("params") or {})
177
+ theirs = params.get("mcpServers") or []
178
+ if not isinstance(theirs, list):
179
+ return m # malformed: forwarded as is, the agent refuses it
180
+ names = {s.name for s in platform}
181
+ client = []
182
+ for server in theirs:
183
+ if isinstance(server, dict) and server.get("name") in names:
184
+ self.on_event(f"client MCP server '{server['name']}' replaced by the platform's")
185
+ else:
186
+ client.append(server)
187
+ own = [s.model_dump(mode="json", by_alias=True, exclude_none=True) for s in platform]
188
+ params["mcpServers"] = client + own
189
+ return m | {"params": params}
190
+
191
+ def check_session_params(self, m: Message) -> Message | None:
192
+ params = m.get("params") or {}
193
+ cwd = params.get("cwd")
194
+ inside = (
195
+ isinstance(cwd, str)
196
+ and cwd.startswith("/")
197
+ and is_within(posixpath.normpath(cwd), self.scope)
198
+ )
199
+ if not inside:
200
+ return error_response(
201
+ m["id"], PolicyErrorCode.CWD_OUTSIDE_SCOPE, "P2", f"cwd must be inside {self.scope}"
202
+ )
203
+ for server in params.get("mcpServers") or []:
204
+ url = server.get("url") if isinstance(server, dict) else None
205
+ if not isinstance(url, str):
206
+ continue
207
+ try:
208
+ target = egress_target(url)
209
+ except (KeyError, ValueError): # not http(s), or a bad port
210
+ target = url
211
+ if not target_allowed(target, self.network):
212
+ return error_response(
213
+ m["id"], PolicyErrorCode.MCP_NOT_ALLOWED, "P3",
214
+ f"MCP server {target} is not reachable under the network policy",
215
+ ) # fmt: skip
216
+ return None
217
+
218
+ # ── agent → client ──────────────────────────────────────────────────
219
+
220
+ def from_agent(self, m: Message) -> list[Action]:
221
+ method = m.get("method")
222
+ if isinstance(method, str) and method.startswith(BLOCKED_AGENT_METHOD_PREFIXES):
223
+ self.on_event(f"blocked agent→client {method}")
224
+ if is_request(m): # P4
225
+ reply = error_response(
226
+ m["id"], PolicyErrorCode.NOT_PERMITTED, "P4", "not permitted by sandbox policy"
227
+ )
228
+ return [ToAgent(reply)]
229
+ return []
230
+ if isinstance(method, str) and method.startswith("_"):
231
+ self.on_event(f"extension {method} (agent→client)") # P6
232
+ if is_response(m):
233
+ if isinstance(m["id"], str) and m["id"] in self._held:
234
+ return self._p7_progress(m)
235
+ if m["id"] in self._session_ids:
236
+ return self._p7_start(m, self._session_ids.pop(m["id"]))
237
+ return [ToClient(m)]
238
+
239
+ # ── P7: session defaults ────────────────────────────────────────────
240
+
241
+ def _p7_start(self, response: Message, loaded: str | None) -> list[Action]:
242
+ result = response.get("result")
243
+ session_id = result.get("sessionId", loaded) if isinstance(result, dict) else None
244
+ if loaded is None and isinstance(session_id, str):
245
+ self._uninstructed.add(session_id) # P9: a new session, its first prompt instructed
246
+ if not self.defaults or not isinstance(session_id, str):
247
+ return [ToClient(response)]
248
+ queue: list[Message] = []
249
+ if self.defaults.mode_id:
250
+ params = {"sessionId": session_id, "modeId": self.defaults.mode_id}
251
+ queue.append({"method": "session/set_mode", "params": params})
252
+ for config_id, value in (self.defaults.config_options or {}).items():
253
+ params = {"sessionId": session_id, "configId": config_id, "value": value}
254
+ queue.append({"method": "session/set_config_option", "params": params})
255
+ if not queue:
256
+ return [ToClient(response)]
257
+ held = _HeldSession(response=response, session_id=session_id, queue=queue)
258
+ return self._p7_send_next(held)
259
+
260
+ def _p7_send_next(self, held: _HeldSession) -> list[Action]:
261
+ request = held.queue.pop(0)
262
+ req_id = f"handler-p7-{next(self._ids)}"
263
+ self._held[req_id] = held
264
+ return [ToAgent({"jsonrpc": "2.0", "id": req_id, **request})]
265
+
266
+ def _p7_progress(self, response: Message) -> list[Action]:
267
+ held = self._held.pop(response["id"])
268
+ if "error" in response:
269
+ held.failures.append(str(response["error"].get("message", response["error"])))
270
+ if held.queue and not held.failures:
271
+ return self._p7_send_next(held)
272
+ if held.failures:
273
+ detail = "; ".join(held.failures)
274
+ self.on_event(f"session defaults failed for {held.session_id}: {detail}")
275
+ reply = error_response(
276
+ held.response["id"], PolicyErrorCode.SESSION_DEFAULTS_FAILED, "P7",
277
+ f"session defaults could not be applied: {detail}",
278
+ ) # fmt: skip
279
+ return [ToClient(reply)]
280
+ return [ToClient(self._patched(held.response))]
281
+
282
+ def _patched(self, response: Message) -> Message:
283
+ assert self.defaults is not None
284
+ result = dict(response["result"])
285
+ if self.defaults.mode_id and isinstance(result.get("modes"), dict):
286
+ result["modes"] = result["modes"] | {"currentModeId": self.defaults.mode_id}
287
+ wanted = self.defaults.config_options or {}
288
+ if wanted and isinstance(result.get("configOptions"), list):
289
+ result["configOptions"] = [
290
+ o | {"currentValue": wanted[o["id"]]}
291
+ if isinstance(o, dict) and o.get("id") in wanted
292
+ else o
293
+ for o in result["configOptions"]
294
+ ]
295
+ return response | {"result": result}
296
+
297
+
298
+ def instructed(instructions: str, params: Message) -> list[Any]:
299
+ """A prompt's blocks with the agent's instructions before its text, a blank line between
300
+ them, in one text block: every adapter reads it (some would drop a second one)."""
301
+ blocks = list(params.get("prompt") or [])
302
+ if blocks and isinstance(blocks[0], dict) and blocks[0].get("type") == "text":
303
+ first = blocks[0]
304
+ return [first | {"text": f"{instructions}\n\n{first.get('text', '')}"}, *blocks[1:]]
305
+ return [{"type": "text", "text": instructions}, *blocks]
@@ -0,0 +1,266 @@
1
+ """Projects — what you work on: a code folder, its caches setting, the skills it gives to all
2
+ its agents, and its agents.
3
+
4
+ An agent of a project is an agent (agents.py) with a `project_id`, so its sessions and sandboxes
5
+ are the same as for an ad-hoc one. Its settings are its own; this module keeps in step
6
+ what it gets from its project: the folder, the caches setting and the project's skills.
7
+
8
+ ProjectRecord one project, persisted in <state>/projects.json
9
+ ProjectRegistry load, save, create, update, delete; its agents, resolved
10
+ """
11
+
12
+ import json
13
+ import secrets
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from handler_contracts import CacheSharing, RemoteAgent, RemoteProject
20
+
21
+ from .agents import AgentRecord, AgentRegistry, CheckedSettings
22
+
23
+
24
+ @dataclass
25
+ class ProjectRecord:
26
+ id: str
27
+ name: str
28
+ folder: str
29
+ description: str | None = None
30
+ skills: list[str] = field(default_factory=list) # given to all its agents (set by D.17)
31
+ default_agent: str | None = None
32
+ caches: CacheSharing = "project" # the toolbox's caches for its agents (caches.py)
33
+ context: str | None = None # its context: the accounts and providers it runs (contexts.py)
34
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
35
+
36
+ def __post_init__(self) -> None:
37
+ self.skills = sorted(set(self.skills))
38
+
39
+ def to_json(self) -> dict[str, Any]:
40
+ return {
41
+ "id": self.id,
42
+ "name": self.name,
43
+ "folder": self.folder,
44
+ "description": self.description,
45
+ "skills": self.skills,
46
+ "defaultAgent": self.default_agent,
47
+ "caches": self.caches,
48
+ "context": self.context,
49
+ "createdAt": self.created_at.isoformat(),
50
+ }
51
+
52
+ @classmethod
53
+ def from_json(cls, r: dict[str, Any]) -> "ProjectRecord":
54
+ return cls(
55
+ id=r["id"],
56
+ name=r["name"],
57
+ folder=r["folder"],
58
+ description=r.get("description"),
59
+ skills=r.get("skills", []),
60
+ default_agent=r.get("defaultAgent"),
61
+ caches=r.get("caches", "project"),
62
+ context=r.get("context"),
63
+ created_at=datetime.fromisoformat(r["createdAt"]),
64
+ )
65
+
66
+
67
+ def folder_name(folder: str) -> str:
68
+ return folder.rstrip("/").rsplit("/", 1)[-1] or folder
69
+
70
+
71
+ class ProjectRegistry:
72
+ def __init__(self, state_dir: Path, registry: AgentRegistry) -> None:
73
+ self.file = state_dir / "projects.json"
74
+ self.registry = registry
75
+ self.projects: dict[str, ProjectRecord] = {}
76
+ if self.file.exists():
77
+ for r in json.loads(self.file.read_text()):
78
+ self.projects[r["id"]] = ProjectRecord.from_json(r)
79
+
80
+ def save(self) -> None:
81
+ self.file.parent.mkdir(parents=True, exist_ok=True)
82
+ tmp = self.file.with_suffix(".tmp")
83
+ tmp.write_text(json.dumps([p.to_json() for p in self.projects.values()], indent=2))
84
+ tmp.replace(self.file)
85
+
86
+ def new_id(self) -> str:
87
+ pid = "p_" + secrets.token_hex(5)
88
+ while pid in self.projects:
89
+ pid = "p_" + secrets.token_hex(5)
90
+ return pid
91
+
92
+ # ── reading ─────────────────────────────────────────────────────────
93
+
94
+ def get(self, pid: str) -> ProjectRecord | None:
95
+ return self.projects.get(pid)
96
+
97
+ def by_folder(self, folder: str) -> ProjectRecord | None:
98
+ return next((p for p in self.projects.values() if p.folder == folder), None)
99
+
100
+ def agents(self, project: ProjectRecord) -> list[AgentRecord]:
101
+ """Its agents: the default one first, then by name."""
102
+ agents = [w for w in list(self.registry.agents.values()) if w.project_id == project.id]
103
+ return sorted(agents, key=lambda w: (w.id != project.default_agent, (w.name or "").lower()))
104
+
105
+ def remote_agents(self) -> list[RemoteProject]:
106
+ """The projects and their agents paired devices may start sessions with (D.7)."""
107
+ listed: list[RemoteProject] = []
108
+ for project in sorted(self.projects.values(), key=lambda p: p.name.lower()):
109
+ agents = [
110
+ RemoteAgent(
111
+ id=a.id,
112
+ name=a.name or a.models[0].harness,
113
+ description=a.description,
114
+ default=a.id == project.default_agent,
115
+ )
116
+ for a in self.agents(project)
117
+ if a.remote_start
118
+ ]
119
+ if agents:
120
+ listed.append(
121
+ RemoteProject(project_id=project.id, project=project.name, agents=agents)
122
+ )
123
+ return listed
124
+
125
+ def label(self, agent: AgentRecord) -> str | None:
126
+ """How a session's agent is named in short: "project · agent" for an agent of a
127
+ project."""
128
+ project = self.get(agent.project_id or "")
129
+ if project is None:
130
+ return agent.name
131
+ return f"{project.name} · {agent.name or agent.models[0].harness}"
132
+
133
+ def last_used(self, project: ProjectRecord) -> datetime | None:
134
+ used = [w.last_used_at for w in self.agents(project) if w.last_used_at]
135
+ return max(used) if used else None
136
+
137
+ # ── writing ─────────────────────────────────────────────────────────
138
+
139
+ def resolve(self, project: ProjectRecord, agent: AgentRecord) -> None:
140
+ """What the agent gets from its project: its folder, the caches setting, the project's
141
+ skills and its context."""
142
+ agent.scope = project.folder
143
+ agent.caches = project.caches
144
+ agent.context = project.context
145
+ agent.project_skills = list(project.skills)
146
+
147
+ def add(self, project: ProjectRecord) -> ProjectRecord:
148
+ self.projects[project.id] = project
149
+ self.save()
150
+ return project
151
+
152
+ def update(self, project: ProjectRecord, **fields: Any) -> None:
153
+ """Change its own settings; its agents follow at once (the caller applies what can change
154
+ in running sandboxes)."""
155
+ for key, value in fields.items():
156
+ setattr(project, key, value)
157
+ project.__post_init__()
158
+ for agent in self.agents(project):
159
+ self.resolve(project, agent)
160
+ self.registry.save()
161
+ self.save()
162
+
163
+ def delete(self, project: ProjectRecord) -> list[AgentRecord]:
164
+ """Remove it and its agents; returns the agents (the caller stops their sessions)."""
165
+ agents = self.agents(project)
166
+ for agent in agents:
167
+ self.registry.agents.pop(agent.id, None)
168
+ self.projects.pop(project.id, None)
169
+ self.registry.save()
170
+ self.save()
171
+ return agents
172
+
173
+ def add_agent(
174
+ self,
175
+ project: ProjectRecord,
176
+ settings: CheckedSettings,
177
+ *,
178
+ name: str,
179
+ description: str | None,
180
+ instructions: str | None,
181
+ default: bool = False,
182
+ remote_start: bool = False,
183
+ skills: list[str] | None = None,
184
+ ) -> AgentRecord:
185
+ agent = AgentRecord(
186
+ **settings.model_dump(),
187
+ name=name,
188
+ project_id=project.id,
189
+ description=description,
190
+ instructions=instructions,
191
+ remote_start=remote_start,
192
+ skills=sorted(set(skills or [])),
193
+ )
194
+ self.resolve(project, agent)
195
+ self.registry.add(agent)
196
+ if default or project.default_agent is None:
197
+ project.default_agent = agent.id
198
+ self.save()
199
+ return agent
200
+
201
+ def update_agent(
202
+ self,
203
+ project: ProjectRecord,
204
+ agent: AgentRecord,
205
+ settings: CheckedSettings,
206
+ *,
207
+ name: str,
208
+ description: str | None,
209
+ instructions: str | None,
210
+ default: bool = False,
211
+ remote_start: bool = False,
212
+ skills: list[str] | None = None,
213
+ ) -> None:
214
+ agent.set_settings(settings)
215
+ agent.name, agent.description, agent.instructions = name, description, instructions
216
+ agent.remote_start = remote_start
217
+ agent.skills = sorted(set(skills or []))
218
+ self.resolve(project, agent)
219
+ self.registry.save()
220
+ if default and project.default_agent != agent.id:
221
+ project.default_agent = agent.id
222
+ self.save()
223
+
224
+ def remove_agent(self, project: ProjectRecord, agent: AgentRecord) -> None:
225
+ self.registry.agents.pop(agent.id, None)
226
+ self.registry.save()
227
+ if project.default_agent == agent.id:
228
+ rest = self.agents(project)
229
+ project.default_agent = rest[0].id if rest else None
230
+ self.save()
231
+
232
+ # ── skills (D.17): the projects that give them to all their agents, the agents picking them ──
233
+
234
+ def skill_projects(self, name: str) -> list[ProjectRecord]:
235
+ """The projects that give a skill to all their agents."""
236
+ return [p for p in self.projects.values() if name in p.skills]
237
+
238
+ def skill_users(self, name: str) -> list[AgentRecord]:
239
+ return [w for w in self.registry.agents.values() if name in w.skills]
240
+
241
+ def give_skill(self, old: str | None, new: str, givers: list[ProjectRecord]) -> None:
242
+ """Skill `old` (None: a new one) is now `new`, given by `givers` to all their agents: the
243
+ agents picking it follow a rename, and drop it when their project gives it."""
244
+ ids = {p.id for p in givers}
245
+ for p in self.projects.values():
246
+ given = {new} if p.id in ids else set()
247
+ p.skills = sorted(({*p.skills} - {old or new, new}) | given)
248
+ for agent in self.skill_users(old or new):
249
+ kept: set[str] = set() if agent.project_id in ids else {new}
250
+ agent.skills = sorted(({*agent.skills} - {old or new}) | kept)
251
+ self._resolve_all()
252
+
253
+ def drop_skill(self, name: str) -> None:
254
+ """A deleted skill: no project gives it, no agent uses it."""
255
+ for p in self.projects.values():
256
+ p.skills = [s for s in p.skills if s != name]
257
+ for agent in self.skill_users(name):
258
+ agent.skills = [s for s in agent.skills if s != name]
259
+ self._resolve_all()
260
+
261
+ def _resolve_all(self) -> None:
262
+ for project in self.projects.values():
263
+ for agent in self.agents(project):
264
+ self.resolve(project, agent)
265
+ self.registry.save()
266
+ self.save()