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,1116 @@
1
+ """Sessions (contract F) — daemon-held chat sessions: one sandbox each, outliving its WebSocket.
2
+
3
+ A client attaches with plain ACP. The daemon answers `initialize` (cached) and `session/load`
4
+ (replay of the event log) itself, then relays through the policy (C.4). Several clients may be
5
+ attached (F.7): every one gets the agent's traffic, the first answer to a permission request
6
+ wins, and what one client changes (prompt, mode, option) is shown to the others. Detaching never
7
+ stops the agent or its turn.
8
+
9
+ SessionStore records on disk, live sessions, single-flight start, stop, delete
10
+ LiveSession one running agent: its own requests, the clients' (ids rewritten),
11
+ pending permission requests, the event log, the idle timer
12
+ Attachment one attached client (a WebSocket, or in-process: the remote connector),
13
+ with its own bounded queue so a slow client cannot hold up the agent
14
+
15
+ A session may be started with a request (sent by the daemon as its first turn). A run of an
16
+ agent with an interface (D.18) is started with an input instead, and is unattended: its
17
+ permission requests are answered by its interface's policy, its turn is cancelled after its
18
+ timeout, no client may prompt it, and its result is read from its final message (one more turn
19
+ when it is missing or invalid); then it stops, its answer (the agent's last message) kept.
20
+
21
+ Storage, one folder per session under <state>/sessions/<id>/ (F.2):
22
+ session.json the record
23
+ events.ndjson the event log (F.5), secret values redacted
24
+ agent/ bound to the harness's per-session state slot
25
+ work/ a folderless agent's (D.18): the folder the session works in
26
+ """
27
+
28
+ import asyncio
29
+ import contextlib
30
+ import itertools
31
+ import json
32
+ import logging
33
+ import secrets as pysecrets
34
+ import shutil
35
+ from collections.abc import Awaitable, Callable, Sequence
36
+ from dataclasses import asdict, dataclass
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import Any, Literal
40
+
41
+ from starlette.websockets import WebSocket
42
+
43
+ from handler_contracts import (
44
+ AcpCloseCode,
45
+ AgentInterface,
46
+ Session,
47
+ SessionErrorCode,
48
+ SessionInput,
49
+ SessionLifecycle,
50
+ SessionOrigin,
51
+ SessionOutput,
52
+ session_acp_path,
53
+ )
54
+
55
+ from . import __version__, interfaces, launcher, session_files, skill_scan, skills
56
+ from .agents import AgentRecord, AgentRegistry, LiveConnection, RunsAs
57
+ from .policy import AcpPolicy, ToAgent, ToClient, instructed, is_request, is_response, parse
58
+ from .sandbox_process import read_stderr, stop
59
+ from .secrets import redactor
60
+ from .settings import Settings
61
+
62
+ log = logging.getLogger("handler.sessions")
63
+
64
+ Message = dict[str, Any]
65
+ START_TIMEOUT = 300.0 # initialize + session/new|load, image start included
66
+ TITLE_LENGTH = 80 # a title taken from the first request
67
+ CANCEL_GRACE = 30.0 # after a timeout's session/cancel, for the agent to end its turn
68
+ QUEUE_SIZE = 10_000 # messages waiting for one client; more: it is closed as too slow (F.7)
69
+
70
+ # how a session stopped (F.5), on its `lifecycle stopped` event: read by automations (I.2)
71
+ StopHow = Literal["explicit", "idle", "exited", "error", "run_ended", "daemon_restarted", "deleted"]
72
+
73
+
74
+ def with_session_id(msg: Message, old: str | None, new: str | None) -> Message:
75
+ """`msg` with `params.sessionId` set to `new` (only where it is `old`, unless None)."""
76
+ params = msg.get("params")
77
+ if not isinstance(params, dict) or "sessionId" not in params:
78
+ return msg
79
+ if old is not None and params["sessionId"] != old:
80
+ return msg
81
+ return msg | {"params": params | {"sessionId": new}}
82
+
83
+
84
+ class SessionError(RuntimeError):
85
+ """The session cannot be started or attached (the reason is shown to the client)."""
86
+
87
+
88
+ def now() -> datetime:
89
+ return datetime.now(UTC)
90
+
91
+
92
+ def error(req_id: Any, code: int, message: str) -> Message:
93
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
94
+
95
+
96
+ @dataclass
97
+ class SessionRecord:
98
+ id: str
99
+ agent_id: str
100
+ harness: str # which of its agent's models it was started with: the harness, on the
101
+ # account or provider below, with `model`
102
+ scope: str
103
+ created_at: datetime
104
+ last_activity_at: datetime
105
+ account: str | None = None
106
+ provider: str | None = None
107
+ model: str | None = None
108
+ acp_session_id: str | None = None
109
+ # the agent's id of its session, when not `acp_session_id`: restarted before its first turn,
110
+ # the agent's session is a new one; the clients keep the recorded id (F.6)
111
+ agent_session_id: str | None = None
112
+ title: str | None = None
113
+ state: SessionLifecycle = "starting"
114
+ can_resume: bool = False
115
+ error: str | None = None
116
+ harness_version: str | None = None
117
+ origin: SessionOrigin = "ui"
118
+ last_stop_reason: str | None = None
119
+ answer: str | None = None # a run's: the agent's last message, kept when it ended
120
+ interface: dict[str, Any] | None = None # a run (D.18): its agent's interface when it started
121
+ input: dict[str, Any] | None = None # a run: SessionInput, the subject's digest set
122
+ output: dict[str, Any] | None = None # a run: SessionOutput, once its turn ended
123
+ # its agent's instructions, until they go with the first prompt a client sends (F.3)
124
+ instructions: str | None = None
125
+
126
+ def to_json(self) -> dict[str, Any]:
127
+ return asdict(self) | {
128
+ "created_at": self.created_at.isoformat(),
129
+ "last_activity_at": self.last_activity_at.isoformat(),
130
+ }
131
+
132
+ @classmethod
133
+ def from_json(cls, data: dict[str, Any]) -> "SessionRecord":
134
+ fields: dict[str, Any] = data | {
135
+ "created_at": datetime.fromisoformat(data["created_at"]),
136
+ "last_activity_at": datetime.fromisoformat(data["last_activity_at"]),
137
+ }
138
+ return cls(**fields)
139
+
140
+
141
+ class EventLog:
142
+ """events.ndjson (F.5): append-only, `seq` strictly increasing from 1."""
143
+
144
+ def __init__(self, path: Path, on_stopped: Callable[[], None] | None = None) -> None:
145
+ self.path = path
146
+ self.on_stopped = on_stopped # told after a `lifecycle stopped` event is written
147
+ self.redact = redactor([])
148
+ self.seq = 0
149
+ self.open_turn = False # the last `turn` event is a start
150
+ self.had_turn = False # a conversation the agent may keep: something to load (F.6)
151
+ if path.exists():
152
+ for event in self._read():
153
+ self.seq = event["seq"]
154
+ if event["kind"] == "turn":
155
+ self.open_turn = event["phase"] == "start"
156
+ self.had_turn = True
157
+
158
+ def _read(self) -> list[dict[str, Any]]:
159
+ events = []
160
+ for line in self.path.read_text().splitlines():
161
+ with contextlib.suppress(ValueError, KeyError):
162
+ events.append(json.loads(line))
163
+ return events
164
+
165
+ def append(self, kind: str, **fields: Any) -> None:
166
+ self.seq += 1
167
+ if kind == "turn":
168
+ self.open_turn = fields["phase"] == "start"
169
+ self.had_turn = True
170
+ line = json.dumps({"seq": self.seq, "t": now().isoformat(), "kind": kind} | fields)
171
+ with self.path.open("a") as f:
172
+ f.write(self.redact(line) + "\n")
173
+ if kind == "lifecycle" and fields.get("event") == "stopped" and self.on_stopped:
174
+ self.on_stopped()
175
+
176
+ def updates(self, after: int = 0) -> list[tuple[int, Any]]:
177
+ if not self.path.exists():
178
+ return []
179
+ return [
180
+ (e["seq"], e["update"])
181
+ for e in self._read()
182
+ if e.get("kind") == "update" and e["seq"] > after
183
+ ]
184
+
185
+ def answer(self) -> str | None:
186
+ """The agent's message after the last prompt."""
187
+ text: list[str] = []
188
+ for _, update in self.updates():
189
+ kind = update.get("sessionUpdate")
190
+ if kind == "user_message_chunk":
191
+ text = []
192
+ elif kind == "agent_message_chunk":
193
+ content = update.get("content") or {}
194
+ if isinstance(content, dict) and isinstance(content.get("text"), str):
195
+ text.append(content["text"])
196
+ return "".join(text).strip() or None
197
+
198
+
199
+ class Attachment:
200
+ """One attached client (F.7). Messages are queued and written by the attachment's own task:
201
+ putting never blocks the agent, and a client that falls `QUEUE_SIZE` messages behind is
202
+ closed (4003) — it re-attaches and gets the log from where it was (`afterSeq`)."""
203
+
204
+ def __init__(
205
+ self,
206
+ send: Callable[[str], Awaitable[None]],
207
+ close: Callable[[int, str], Awaitable[None]],
208
+ kind: str = "websocket",
209
+ ) -> None:
210
+ self._send = send
211
+ self._close = close
212
+ self.kind = kind # "websocket" | "remote"
213
+ self.ready = False # the client has its replay: live messages may follow
214
+ self.closed = False
215
+ self.queue: asyncio.Queue[Message | tuple[int, str]] = asyncio.Queue(QUEUE_SIZE)
216
+ self.writer = asyncio.create_task(self._write())
217
+
218
+ @classmethod
219
+ def of(cls, ws: WebSocket) -> "Attachment":
220
+ async def close(code: int, reason: str) -> None:
221
+ await ws.close(code=code, reason=reason)
222
+
223
+ return cls(ws.send_text, close)
224
+
225
+ def put(self, msg: Message) -> None:
226
+ if self.closed:
227
+ return
228
+ try:
229
+ self.queue.put_nowait(msg)
230
+ except asyncio.QueueFull:
231
+ self.closed = True
232
+ self._close_now(AcpCloseCode.TOO_SLOW, "too slow")
233
+
234
+ def close(self, code: int, reason: str) -> None:
235
+ """Close after what is already queued has been sent."""
236
+ if self.closed:
237
+ return
238
+ self.closed = True
239
+ try:
240
+ self.queue.put_nowait((code, reason[:120]))
241
+ except asyncio.QueueFull:
242
+ self._close_now(code, reason[:120])
243
+
244
+ def _close_now(self, code: int, reason: str) -> None:
245
+ """Drop what is queued and close (the writer task becomes the closing one)."""
246
+ self.writer.cancel()
247
+ self.writer = asyncio.create_task(self._close_quietly(code, reason))
248
+
249
+ def discard(self) -> None:
250
+ """The client is gone: stop writing to it (a close already queued is still sent)."""
251
+ if not self.closed:
252
+ self.closed = True
253
+ self.writer.cancel()
254
+
255
+ async def _write(self) -> None:
256
+ while True:
257
+ item = await self.queue.get()
258
+ if isinstance(item, tuple):
259
+ await self._close_quietly(*item)
260
+ return
261
+ with contextlib.suppress(Exception): # a closing client: it will re-attach
262
+ await self._send(json.dumps(item))
263
+
264
+ async def _close_quietly(self, code: int, reason: str) -> None:
265
+ with contextlib.suppress(Exception):
266
+ await self._close(code, reason)
267
+
268
+
269
+ class LiveSession:
270
+ """One running agent. Every method runs on the event loop; `out` orders what the client gets."""
271
+
272
+ def __init__(
273
+ self, store: "SessionStore", record: SessionRecord, agent: AgentRecord, runs: RunsAs
274
+ ) -> None:
275
+ self.store = store
276
+ self.record = record
277
+ self.agent = agent
278
+ self.runs = runs # the agent's model it runs with
279
+ self.log = store.event_log(record.id)
280
+ self.proc: asyncio.subprocess.Process | None = None
281
+ self.policy: AcpPolicy | None = None
282
+ self.init_result: Message = {}
283
+ self.modes: Any = None
284
+ self.config_options: Any = None
285
+ self.clients: list[Attachment] = [] # oldest first
286
+ self.remote = False # reachable through the remote connector (docs/dev/design/remote.md)
287
+ self.agent_lock = asyncio.Lock()
288
+ self.ids = itertools.count(1)
289
+ self.own: dict[str, asyncio.Future[Message]] = {} # the daemon's own requests
290
+ self.forwarded: dict[str, Forwarded] = {} # our id → the client's request
291
+ self.pending: dict[Any, Message] = {} # agent→client requests awaiting an answer
292
+ self.prompt_id: str | None = None # the turn in progress (our id of its session/prompt)
293
+ self.turn_started_at: datetime | None = None
294
+ self.stop_reason: str | None = None # why the daemon stopped it, if it did
295
+ self.stop_how: StopHow = "exited" # how, for the log (F.5)
296
+ self.replaying = False # updates are the agent's replay of history we have: not relayed
297
+ self.translate = False # the agent's session id is not the clients': translated (F.6)
298
+ self.idle: asyncio.TimerHandle | None = None
299
+ self.tasks: list[asyncio.Task[None]] = []
300
+
301
+ @property
302
+ def busy(self) -> bool:
303
+ return self.prompt_id is not None
304
+
305
+ # ── start ───────────────────────────────────────────────────────────
306
+
307
+ async def start(self) -> None:
308
+ rec, settings = self.record, self.store.settings
309
+ state_dir = self.store.folder(rec.id) / "agent"
310
+ state_dir.mkdir(parents=True, exist_ok=True)
311
+ self.log.redact = self.store.redactor(self.agent, self.runs)
312
+ subject = None
313
+ if rec.input is not None and (given := SessionInput.model_validate(rec.input)).subject:
314
+ try:
315
+ subject = interfaces.subject_mount(settings.skills_dir, given.subject)
316
+ except skills.SkillError as e:
317
+ raise SessionError(str(e)) from None
318
+ started = await launcher.start(
319
+ settings,
320
+ self.agent,
321
+ self.runs,
322
+ rec.id,
323
+ str(state_dir),
324
+ folder=rec.scope,
325
+ subject=subject,
326
+ )
327
+ session_files.record_mounts(self.store.mounts(rec.id), started.spec, now())
328
+ rec.harness_version = await launcher.harness_version(rec.harness)
329
+ self.proc = started.process # ty: ignore[invalid-assignment]
330
+ self.policy = AcpPolicy(
331
+ scope=rec.scope,
332
+ network=started.spec.network,
333
+ session_defaults=started.launch.session_defaults,
334
+ on_event=lambda text: log.info("[%s] policy: %s", rec.id, text),
335
+ )
336
+ self.agent.connections[rec.id] = LiveConnection(
337
+ id=rec.id,
338
+ sandbox=started.spec.name,
339
+ since=now(),
340
+ process=self.proc,
341
+ egress_base=started.egress_base,
342
+ )
343
+ assert self.proc is not None
344
+ registry = self.store.registry
345
+ self.tasks = [
346
+ asyncio.create_task(self.agent_pump()),
347
+ asyncio.create_task(
348
+ read_stderr(self.proc, rec.id, lambda e: registry.add_event(self.agent, rec.id, e))
349
+ ),
350
+ ]
351
+ caps = {"fs": {"readTextFile": False, "writeTextFile": False}, "terminal": False}
352
+ info = {"name": "handlerd", "version": __version__}
353
+ self.init_result = await self.request(
354
+ "initialize", {"protocolVersion": 1, "clientCapabilities": caps, "clientInfo": info}
355
+ )
356
+ rec.can_resume = bool((self.init_result.get("agentCapabilities") or {}).get("loadSession"))
357
+ params = {"cwd": rec.scope, "mcpServers": []}
358
+ if rec.acp_session_id and self.log.had_turn:
359
+ if not rec.can_resume:
360
+ raise SessionError("this agent cannot resume sessions")
361
+ # the agent replays its history: already in our log, unless we have none (F.5)
362
+ replay = not self.log.updates()
363
+ self.translate = rec.agent_session_id is not None
364
+ loaded = rec.agent_session_id or rec.acp_session_id
365
+ result = await self.request(
366
+ "session/load", params | {"sessionId": loaded}, replay=replay
367
+ )
368
+ else: # also a session stopped before its first turn: the agent kept nothing to load
369
+ self.translate = rec.acp_session_id is not None
370
+ result = await self.request("session/new", params)
371
+ if self.translate:
372
+ rec.agent_session_id = result["sessionId"]
373
+ else:
374
+ rec.acp_session_id = result["sessionId"]
375
+ self.modes, self.config_options = result.get("modes"), result.get("configOptions")
376
+ rec.state, rec.error = "running", None
377
+ self.log.append("lifecycle", event="started")
378
+ self.store.save(rec)
379
+ self.schedule_idle()
380
+
381
+ async def request(self, method: str, params: Message, *, replay: bool = True) -> Message:
382
+ """A request of the daemon itself, through the policy (P1, P2, P7 apply)."""
383
+ rid = f"handler-d-{next(self.ids)}"
384
+ future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
385
+ self.own[rid] = future
386
+ self.replaying = not replay
387
+ try:
388
+ await self.execute({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
389
+ response = await asyncio.wait_for(future, START_TIMEOUT)
390
+ finally:
391
+ self.own.pop(rid, None)
392
+ self.replaying = False
393
+ if "error" in response:
394
+ raise SessionError(f"{method}: {response['error'].get('message', response['error'])}")
395
+ return response.get("result") or {}
396
+
397
+ # ── agent side ──────────────────────────────────────────────────────
398
+
399
+ async def execute(self, msg: Message) -> None:
400
+ """Client-side message (the daemon's or the client's) → policy → agent / back."""
401
+ assert self.policy is not None
402
+ for action in self.policy.from_client(msg):
403
+ await self.dispatch(action)
404
+
405
+ async def dispatch(self, action: ToAgent | ToClient) -> None:
406
+ if isinstance(action, ToAgent):
407
+ await self.to_agent(action.msg)
408
+ else:
409
+ await self.route(action.msg)
410
+
411
+ async def to_agent(self, msg: Message) -> None:
412
+ assert self.proc is not None and self.proc.stdin is not None
413
+ if self.translate and self.record.agent_session_id is not None:
414
+ msg = with_session_id(msg, self.record.acp_session_id, self.record.agent_session_id)
415
+ async with self.agent_lock:
416
+ with contextlib.suppress(ConnectionError):
417
+ self.proc.stdin.write(json.dumps(msg).encode() + b"\n")
418
+ await self.proc.stdin.drain()
419
+
420
+ async def agent_pump(self) -> None:
421
+ assert self.proc is not None and self.proc.stdout is not None and self.policy is not None
422
+ try:
423
+ while True:
424
+ try:
425
+ line = await self.proc.stdout.readline()
426
+ except (ValueError, asyncio.LimitOverrunError):
427
+ log.warning("[%s] agent message too big: stopping", self.record.id)
428
+ self.stop_reason, self.stop_how = "agent message too big", "error"
429
+ break
430
+ if not line:
431
+ break
432
+ msg = parse(line.decode(errors="replace"))
433
+ if msg is None:
434
+ log.warning("[%s] dropped a non-object agent message", self.record.id)
435
+ continue
436
+ if self.translate: # its one session, whatever id the agent gave it
437
+ msg = with_session_id(msg, None, self.record.acp_session_id)
438
+ for action in self.policy.from_agent(msg):
439
+ await self.dispatch(action)
440
+ finally:
441
+ await self.ended()
442
+
443
+ def responded(self, m: Message) -> None:
444
+ """The agent's answer to a request: the daemon's own, or a client's (ids restored)."""
445
+ if (future := self.own.get(m["id"])) is not None:
446
+ if not future.done():
447
+ future.set_result(m)
448
+ return
449
+ entry = self.forwarded.pop(m["id"], None)
450
+ if entry is None:
451
+ return
452
+ if entry.client in self.clients: # else: detached, the turn goes on
453
+ entry.client.put(m | {"id": entry.id})
454
+ if "error" not in m:
455
+ self.mirror(entry, m.get("result"))
456
+ if entry.method == "session/prompt":
457
+ stop = (m.get("result") or {}).get("stopReason", "error")
458
+ self.end_turn(stop)
459
+ self.broadcast(notification("_handler/turn_ended", stopReason=stop), entry.client)
460
+ self.broadcast_state()
461
+
462
+ async def route(self, m: Message) -> None:
463
+ """An agent message for the client side, after the policy."""
464
+ if is_response(m):
465
+ self.responded(m)
466
+ return
467
+ if m.get("method") == "session/update":
468
+ update = (m.get("params") or {}).get("update") or {}
469
+ if self.replaying:
470
+ return
471
+ self.observe_update(update)
472
+ # user messages are logged from the prompts themselves (the agent may echo them)
473
+ if not (self.busy and update.get("sessionUpdate") == "user_message_chunk"):
474
+ self.log.append("update", update=update)
475
+ self.broadcast(m)
476
+ elif is_request(m) and self.record.interface is not None: # a run: no one to ask
477
+ await self.to_agent(self.auto_answer(m))
478
+ elif is_request(m): # session/request_permission (P4 answered fs/*, terminal/*)
479
+ self.pending[m["id"]] = m
480
+ self.broadcast(m)
481
+ self.broadcast_state()
482
+ else:
483
+ self.broadcast(m)
484
+
485
+ def auto_answer(self, request: Message) -> Message:
486
+ """A run is in mode `bypass` (D.18): a request its harness still sends gets its first
487
+ allow option; `cancelled` when there is none."""
488
+ params = request.get("params") or {}
489
+ options = params.get("options") or []
490
+ kinds = ("allow_once", "allow_always")
491
+ chosen = next((o for k in kinds for o in options if o.get("kind") == k), None)
492
+ outcome: Message = {"outcome": "cancelled"}
493
+ if chosen is not None:
494
+ outcome = {"outcome": "selected", "optionId": chosen["optionId"]}
495
+ title = (params.get("toolCall") or {}).get("title")
496
+ self.log.append("lifecycle", event="permission answered", detail=f"{title}: {outcome}")
497
+ return {"jsonrpc": "2.0", "id": request["id"], "result": {"outcome": outcome}}
498
+
499
+ def observe_update(self, update: Message) -> None:
500
+ kind = update.get("sessionUpdate")
501
+ if kind == "session_info_update" and "title" in update:
502
+ self.record.title = update["title"]
503
+ elif kind == "current_mode_update" and isinstance(self.modes, dict):
504
+ self.modes = self.modes | {"currentModeId": update.get("currentModeId")}
505
+ elif kind == "config_option_update":
506
+ self.config_options = update.get("configOptions")
507
+
508
+ def mirror(self, entry: "Forwarded", result: Any) -> None:
509
+ """A client changed the mode or an option: keep it for later `session/load` answers
510
+ (F.4), and show it to the other clients (F.7)."""
511
+ update: Message | None = None
512
+ if entry.method == "session/set_mode" and isinstance(self.modes, dict):
513
+ mode = entry.params.get("modeId")
514
+ self.modes = self.modes | {"currentModeId": mode}
515
+ update = {"sessionUpdate": "current_mode_update", "currentModeId": mode}
516
+ elif entry.method == "session/set_config_option" and isinstance(result, dict):
517
+ if (options := result.get("configOptions")) is not None:
518
+ self.config_options = options
519
+ update = {"sessionUpdate": "config_option_update", "configOptions": options}
520
+ if update is not None:
521
+ params = {"sessionId": self.record.acp_session_id, "update": update}
522
+ self.broadcast(notification("session/update", **params), entry.client)
523
+
524
+ def end_turn(self, stop_reason: str) -> None:
525
+ self.prompt_id = self.turn_started_at = None
526
+ self.record.last_stop_reason = stop_reason
527
+ self.log.append("turn", phase="end", stopReason=stop_reason)
528
+ self.touch()
529
+ self.schedule_idle()
530
+
531
+ async def ended(self) -> None:
532
+ """The agent is gone (exit, stop, idle timeout): the session becomes `stopped`."""
533
+ rec = self.record
534
+ code = await self.proc.wait() if self.proc else None
535
+ for task in self.tasks:
536
+ if task is not asyncio.current_task():
537
+ task.cancel()
538
+ self.cancel_idle()
539
+ for future in self.own.values():
540
+ if not future.done():
541
+ future.set_exception(SessionError(f"agent exited ({code})"))
542
+ reason = self.stop_reason or f"agent exited ({code})"
543
+ if self.busy:
544
+ self.log.append("turn", phase="end", stopReason="stopped")
545
+ self.prompt_id = self.turn_started_at = None
546
+ self.forwarded.clear()
547
+ self.pending.clear()
548
+ self.remote = False # reset: switched on again by hand after a restart
549
+ self.agent.connections.pop(rec.id, None)
550
+ self.store.live.pop(rec.id, None)
551
+ if rec.state == "running":
552
+ rec.state = "stopped"
553
+ rec.error = None if self.stop_reason or code == 0 else reason
554
+ self.log.append("lifecycle", event="stopped", detail=reason, how=self.stop_how)
555
+ self.store.save(rec)
556
+ clients, self.clients = self.clients, []
557
+ for client in clients:
558
+ client.close(AcpCloseCode.SESSION_STOPPED, reason)
559
+ self.store.changed()
560
+
561
+ async def stop(self, reason: str, how: StopHow) -> None:
562
+ self.stop_reason, self.stop_how = reason, how
563
+ if self.proc is not None:
564
+ await stop(self.proc)
565
+
566
+ # ── client side (F.4) ───────────────────────────────────────────────
567
+
568
+ async def attach(self, ws: WebSocket) -> None:
569
+ """Serve one attached WebSocket until it closes, or the daemon closes it."""
570
+ client = self.add(Attachment.of(ws))
571
+ try:
572
+ while not client.closed:
573
+ event = await ws.receive()
574
+ if event["type"] == "websocket.disconnect":
575
+ return
576
+ if event.get("bytes") is not None:
577
+ client.close(AcpCloseCode.UNSUPPORTED_DATA, "binary frame")
578
+ return
579
+ for line in (event.get("text") or "").splitlines():
580
+ if client.closed:
581
+ return
582
+ if line.strip() and (msg := parse(line)) is not None:
583
+ await self.from_client(client, msg)
584
+ finally:
585
+ self.remove(client)
586
+
587
+ def add(self, client: Attachment) -> Attachment:
588
+ """A new attachment; beyond `HANDLER_SESSION_MAX_ATTACH`, the oldest is closed (4001)."""
589
+ while len(self.clients) >= self.store.settings.session_max_attach:
590
+ oldest = self.clients.pop(0)
591
+ oldest.close(AcpCloseCode.ATTACHED_ELSEWHERE, "attached elsewhere")
592
+ self.clients.append(client)
593
+ self.cancel_idle()
594
+ self.broadcast_state()
595
+ return client
596
+
597
+ def remove(self, client: Attachment) -> None:
598
+ """The client has gone (or was closed): the agent and its turn go on."""
599
+ if client in self.clients:
600
+ self.clients.remove(client)
601
+ client.discard()
602
+ self.broadcast_state()
603
+ self.schedule_idle()
604
+
605
+ async def from_client(self, client: Attachment, m: Message) -> None:
606
+ method = m.get("method")
607
+ if is_response(m): # an answer to a permission request: the first one wins (F.7)
608
+ if (request := self.pending.pop(m["id"], None)) is not None:
609
+ await self.to_agent(m)
610
+ self.resolved(request, m, client)
611
+ return # an answer to a request already answered, or unknown: dropped
612
+ request = is_request(m)
613
+ raw = m.get("params")
614
+ params: Message = raw if isinstance(raw, dict) else {}
615
+ if method == "initialize" and request:
616
+ client.put({"jsonrpc": "2.0", "id": m["id"], "result": self.init_result})
617
+ return
618
+ if method == "session/load" and request:
619
+ self.load(client, m, params)
620
+ return
621
+ session_id = params.get("sessionId")
622
+ other = session_id is not None and session_id != self.record.acp_session_id
623
+ if request and (method in ("session/new", "session/resume") or other):
624
+ code = SessionErrorCode.ONE_SESSION_PER_ATTACHMENT
625
+ client.put(error(m["id"], code, "one session per attachment"))
626
+ return
627
+ if method == "session/prompt" and request:
628
+ if self.record.interface is not None:
629
+ code = SessionErrorCode.UNATTENDED
630
+ client.put(error(m["id"], code, "unattended: a run takes no prompt"))
631
+ return
632
+ if self.busy:
633
+ code = SessionErrorCode.TURN_IN_PROGRESS
634
+ client.put(error(m["id"], code, "turn in progress"))
635
+ return
636
+ if self.record.instructions:
637
+ params = params | {"prompt": instructed(self.record.instructions, params)}
638
+ m = m | {"params": params}
639
+ self.record.instructions = None
640
+ self.store.save(self.record)
641
+ self.begin_turn(params.get("prompt") or [], client)
642
+ if request:
643
+ ours = f"handler-c-{next(self.ids)}"
644
+ self.forwarded[ours] = Forwarded(client, m["id"], str(method), params)
645
+ if method == "session/prompt":
646
+ self.prompt_id = ours
647
+ m = m | {"id": ours}
648
+ await self.execute(m)
649
+
650
+ def resolved(self, request: Message, answer: Message, by: Attachment) -> None:
651
+ """Tell the other clients a permission request was answered, so they drop it."""
652
+ tool_call = (request.get("params") or {}).get("toolCall") or {}
653
+ outcome = (answer.get("result") or {}).get("outcome")
654
+ self.broadcast(
655
+ notification(
656
+ "_handler/permission_resolved",
657
+ requestId=request["id"],
658
+ toolCallId=tool_call.get("toolCallId"),
659
+ outcome=outcome,
660
+ ),
661
+ by,
662
+ )
663
+ self.broadcast_state()
664
+ self.schedule_idle()
665
+
666
+ async def first_prompt(self, blocks: list[Message], timeout: float | None = None) -> None:
667
+ """The request a session was created with, sent by the daemon itself as its first turn:
668
+ the attached clients see it like another client's prompt (F.7). After `timeout` seconds
669
+ the turn is cancelled (and ends as `timed_out`)."""
670
+ rid = f"handler-d-{next(self.ids)}"
671
+ future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
672
+ self.own[rid] = future
673
+ self.begin_turn(blocks, None)
674
+ self.prompt_id = rid
675
+ params = {"sessionId": self.record.acp_session_id, "prompt": blocks}
676
+ timed_out = False
677
+ try:
678
+ await self.execute(
679
+ {"jsonrpc": "2.0", "id": rid, "method": "session/prompt", "params": params}
680
+ )
681
+ try:
682
+ response = await asyncio.wait_for(asyncio.shield(future), timeout)
683
+ except TimeoutError:
684
+ timed_out = True
685
+ await self.cancel_turn()
686
+ response = await asyncio.wait_for(future, CANCEL_GRACE)
687
+ except (SessionError, TimeoutError):
688
+ return # the agent is gone (`ended` closed the turn), or ignored the cancel
689
+ finally:
690
+ self.own.pop(rid, None)
691
+ if self.prompt_id != rid:
692
+ return
693
+ stop = (response.get("result") or {}).get("stopReason", "error")
694
+ stop = "timed_out" if timed_out else stop
695
+ self.end_turn(stop)
696
+ self.broadcast(notification("_handler/turn_ended", stopReason=stop))
697
+ self.broadcast_state()
698
+
699
+ async def cancel_turn(self) -> None:
700
+ """session/cancel for the turn in progress, whoever started it."""
701
+ if self.busy:
702
+ params = {"sessionId": self.record.acp_session_id}
703
+ await self.execute({"jsonrpc": "2.0", "method": "session/cancel", "params": params})
704
+
705
+ def begin_turn(self, prompt: list[Any], by: Attachment | None) -> None:
706
+ self.cancel_idle()
707
+ for block in prompt:
708
+ update = {"sessionUpdate": "user_message_chunk", "content": block}
709
+ self.log.append("update", update=update)
710
+ params = {"sessionId": self.record.acp_session_id, "update": update}
711
+ self.broadcast(
712
+ notification("session/update", **params, _meta={"handler.dev/seq": self.log.seq}),
713
+ by,
714
+ )
715
+ self.log.append("turn", phase="start")
716
+ self.touch()
717
+ self.prompt_id = "starting" # busy from now on; set to the forwarded id right after
718
+ self.turn_started_at = now()
719
+ self.broadcast_state()
720
+
721
+ def load(self, client: Attachment, m: Message, params: Message) -> None:
722
+ """The attach proper: replay the log, answer with the current state, re-send pending
723
+ permission requests; from then on the client gets live traffic."""
724
+ assert self.policy is not None
725
+ if params.get("sessionId") != self.record.acp_session_id:
726
+ code = SessionErrorCode.ONE_SESSION_PER_ATTACHMENT
727
+ client.put(error(m["id"], code, "one session per attachment"))
728
+ return
729
+ if refusal := self.policy.check_session_params(m): # P2 / P3
730
+ client.put(refusal)
731
+ return
732
+ meta = params.get("_meta")
733
+ after = meta.get("handler.dev/afterSeq", 0) if isinstance(meta, dict) else 0
734
+ result = {
735
+ k: v
736
+ for k, v in (("modes", self.modes), ("configOptions", self.config_options))
737
+ if v is not None
738
+ }
739
+ # no await from here on: nothing can be broadcast between the replay and `ready`
740
+ for seq, update in self.log.updates(after if isinstance(after, int) else 0):
741
+ params_ = {"sessionId": self.record.acp_session_id, "update": update}
742
+ client.put(notification("session/update", **params_, _meta={"handler.dev/seq": seq}))
743
+ client.put({"jsonrpc": "2.0", "id": m["id"], "result": result})
744
+ client.put(notification("_handler/session_state", **self.state()))
745
+ for pending in self.pending.values():
746
+ client.put(pending)
747
+ client.ready = True
748
+
749
+ def state(self) -> Message:
750
+ return {
751
+ "state": self.record.state,
752
+ "busy": self.busy,
753
+ "turnStartedAt": self.turn_started_at.isoformat() if self.turn_started_at else None,
754
+ "pendingPermissions": len(self.pending),
755
+ "attached": len(self.clients),
756
+ }
757
+
758
+ def broadcast(self, msg: Message, but: Attachment | None = None) -> None:
759
+ for client in self.clients:
760
+ if client.ready and client is not but:
761
+ client.put(msg)
762
+
763
+ def broadcast_state(self) -> None:
764
+ self.broadcast(notification("_handler/session_state", **self.state()))
765
+
766
+ # ── idle stop (F.6.3) ───────────────────────────────────────────────
767
+
768
+ def touch(self) -> None:
769
+ self.record.last_activity_at = now()
770
+ self.store.save(self.record)
771
+
772
+ def schedule_idle(self) -> None:
773
+ self.cancel_idle()
774
+ if not self.clients and not self.busy and not self.pending:
775
+ loop = asyncio.get_running_loop()
776
+ self.idle = loop.call_later(
777
+ self.store.settings.session_idle_timeout,
778
+ lambda: self.store.spawn(self.stop("idle timeout", "idle")),
779
+ )
780
+
781
+ def cancel_idle(self) -> None:
782
+ if self.idle is not None:
783
+ self.idle.cancel()
784
+ self.idle = None
785
+
786
+
787
+ def title_of(prompt: str) -> str:
788
+ line = next((x.strip() for x in prompt.splitlines() if x.strip()), "")
789
+ return line if len(line) <= TITLE_LENGTH else line[: TITLE_LENGTH - 1].rstrip() + "…"
790
+
791
+
792
+ def notification(method: str, **params: Any) -> Message:
793
+ return {"jsonrpc": "2.0", "method": method, "params": params}
794
+
795
+
796
+ @dataclass
797
+ class Forwarded:
798
+ """A client's request, forwarded to the agent under our own id."""
799
+
800
+ client: Attachment
801
+ id: Any # the client's id, restored on the response
802
+ method: str
803
+ params: Message
804
+
805
+
806
+ class SessionStore:
807
+ def __init__(self, settings: Settings, registry: AgentRegistry) -> None:
808
+ self.settings = settings
809
+ self.registry = registry
810
+ self.dir = settings.state_dir / "sessions"
811
+ self.dir.mkdir(parents=True, exist_ok=True)
812
+ self.records: dict[str, SessionRecord] = {}
813
+ self.live: dict[str, LiveSession] = {}
814
+ self.starting: dict[str, asyncio.Task[LiveSession]] = {}
815
+ self.background: set[asyncio.Task[Any]] = set()
816
+ self.listeners: list[Callable[[], None]] = [] # told when sessions stop or are shared
817
+ # told with its id when a session's `lifecycle stopped` event is written (automations)
818
+ self.stop_listeners: list[Callable[[str], None]] = []
819
+ self._load()
820
+
821
+ # ── persistence ─────────────────────────────────────────────────────
822
+
823
+ def folder(self, session_id: str) -> Path:
824
+ return self.dir / session_id
825
+
826
+ def event_log(self, session_id: str) -> EventLog:
827
+ def stopped() -> None:
828
+ for listener in self.stop_listeners:
829
+ listener(session_id)
830
+
831
+ return EventLog(self.folder(session_id) / "events.ndjson", stopped)
832
+
833
+ def _load(self) -> None:
834
+ """F.6.5: sessions that were live when the daemon stopped are `stopped` now."""
835
+ for path in self.dir.glob("*/session.json"):
836
+ try:
837
+ rec = SessionRecord.from_json(json.loads(path.read_text()))
838
+ except (ValueError, TypeError, KeyError) as e:
839
+ log.warning("ignoring %s: %s", path, e)
840
+ continue
841
+ self.records[rec.id] = rec
842
+ if rec.state in ("starting", "running"):
843
+ events = self.event_log(rec.id)
844
+ if events.open_turn:
845
+ events.append("turn", phase="end", stopReason="daemon_restarted")
846
+ events.append(
847
+ "lifecycle", event="stopped", detail="daemon restarted", how="daemon_restarted"
848
+ )
849
+ rec.state = "stopped" if rec.acp_session_id else "failed"
850
+ self.save(rec)
851
+
852
+ def save(self, rec: SessionRecord) -> None:
853
+ folder = self.folder(rec.id)
854
+ folder.mkdir(mode=0o700, parents=True, exist_ok=True)
855
+ tmp = folder / "session.json.tmp"
856
+ tmp.write_text(json.dumps(rec.to_json(), indent=2))
857
+ tmp.replace(folder / "session.json")
858
+
859
+ def redactor(self, agent: AgentRecord, runs: RunsAs) -> Any:
860
+ """Invariant 4: its account's or provider's and bindings' secret values never land in the
861
+ event log."""
862
+ return redactor(launcher.secret_values(self.settings, agent, runs))
863
+
864
+ def spawn(self, coro: Any) -> None:
865
+ task = asyncio.ensure_future(coro)
866
+ self.background.add(task)
867
+ task.add_done_callback(self.background.discard)
868
+
869
+ # ── API ─────────────────────────────────────────────────────────────
870
+
871
+ def to_contract(self, rec: SessionRecord) -> Session:
872
+ live = self.live.get(rec.id)
873
+ agent = self.registry.get(rec.agent_id)
874
+ return Session(
875
+ id=rec.id,
876
+ agent_id=rec.agent_id,
877
+ project_id=agent.project_id if agent else None,
878
+ harness=rec.harness,
879
+ account=rec.account,
880
+ provider=rec.provider,
881
+ model=rec.model,
882
+ scope=rec.scope,
883
+ acp_session_id=rec.acp_session_id,
884
+ title=rec.title,
885
+ state=rec.state,
886
+ busy=bool(live and live.busy),
887
+ pending_permissions=len(live.pending) if live else 0,
888
+ attached=len(live.clients) if live else 0,
889
+ remote=bool(live and live.remote),
890
+ can_resume=rec.can_resume,
891
+ error=rec.error,
892
+ harness_version=rec.harness_version,
893
+ origin=rec.origin,
894
+ last_stop_reason=rec.last_stop_reason,
895
+ answer=rec.answer,
896
+ input=SessionInput.model_validate(rec.input) if rec.input else None,
897
+ output=SessionOutput.model_validate(rec.output) if rec.output else None,
898
+ created_at=rec.created_at,
899
+ last_activity_at=rec.last_activity_at,
900
+ acp_url=self.settings.base_url.replace("http://", "ws://") + session_acp_path(rec.id),
901
+ )
902
+
903
+ def list(self, agent_id: str | None = None) -> list[SessionRecord]:
904
+ # a copy: sync routes read it in a worker thread, the event loop changes it
905
+ records = list(self.records.values())
906
+ records = [r for r in records if agent_id is None or r.agent_id == agent_id]
907
+ return sorted(records, key=lambda r: r.last_activity_at, reverse=True)
908
+
909
+ def mounts(self, session_id: str) -> Path:
910
+ """What its sandbox saw of the host at its last start (F.8)."""
911
+ return self.folder(session_id) / "mounts.json"
912
+
913
+ def baseline(self, session_id: str) -> Path:
914
+ """Its folder as it started (F.8)."""
915
+ return self.folder(session_id) / "baseline.json"
916
+
917
+ def work_folder(self, session_id: str) -> Path:
918
+ """A folderless agent's session's own folder (D.18)."""
919
+ return self.folder(session_id) / "work"
920
+
921
+ def create(
922
+ self,
923
+ agent: AgentRecord,
924
+ runs: RunsAs,
925
+ acp_session_id: str | None = None,
926
+ prompt: str | None = None,
927
+ *,
928
+ origin: SessionOrigin = "ui",
929
+ run: SessionInput | None = None,
930
+ from_skill: str | None = None,
931
+ ) -> SessionRecord:
932
+ """A new session, started in the background (F.3). With a `prompt` (the request), the
933
+ daemon sends it once the agent runs, after the agent's instructions if it has any (a
934
+ blank line between them, in one text block); without, the instructions go the same way
935
+ with the first prompt a client sends. The
936
+ request's first line is the title until the agent gives one. `runs`: the agent's model it
937
+ runs with (one of its own).
938
+
939
+ `run`: an agent with an interface's checked input (D.18): an unattended run. A
940
+ folderless agent's session works in its own folder, a copy of `from_skill` if given."""
941
+ sid = "s_" + pysecrets.token_hex(8)
942
+ scope = agent.scope
943
+ if scope is None:
944
+ work = self.work_folder(sid)
945
+ work.mkdir(parents=True)
946
+ work.parent.chmod(0o700)
947
+ if from_skill is not None:
948
+ skills.copy(self.settings.skills_dir / from_skill, work)
949
+ scope = str(work)
950
+ interface = agent.interface if run is not None else None
951
+ title = title_of(prompt) if prompt else None
952
+ timeout = None
953
+ if interface is not None and run is not None:
954
+ timeout = interface.timeout_sec
955
+ subject = run.subject.skill if run.subject else None
956
+ title = f"{agent.name or runs.harness}: {subject}" if subject else agent.name
957
+ rec = SessionRecord(
958
+ id=sid,
959
+ agent_id=agent.id,
960
+ harness=runs.harness,
961
+ account=runs.account,
962
+ provider=runs.provider,
963
+ model=runs.model,
964
+ scope=scope,
965
+ created_at=now(),
966
+ last_activity_at=now(),
967
+ acp_session_id=acp_session_id,
968
+ title=title,
969
+ origin=origin,
970
+ interface=interface.model_dump(mode="json", by_alias=True) if interface else None,
971
+ input=run.model_dump(mode="json", by_alias=True) if run is not None else None,
972
+ # with no request to go with, they go with the first prompt (a resumed one has had
973
+ # its first turn)
974
+ instructions=None if prompt or run or acp_session_id else agent.instructions,
975
+ )
976
+ self.records[rec.id] = rec
977
+ self.save(rec)
978
+ text = None
979
+ if interface is not None and run is not None:
980
+ notes = self.subject_notes(run)
981
+ text = interfaces.request(agent.instructions, interface, run, notes)
982
+ elif prompt:
983
+ text = f"{agent.instructions}\n\n{prompt}" if agent.instructions else prompt
984
+ # one block: every adapter reads it (some would drop a second one)
985
+ blocks = [{"type": "text", "text": text}] if text else []
986
+ self.spawn(self._start_quietly(rec.id, blocks, timeout))
987
+ return rec
988
+
989
+ def subject_notes(self, run: SessionInput) -> str | None:
990
+ """What handlerd tells a run about its subject besides where it is: a skill's scan
991
+ (D.17)."""
992
+ if run.subject is None:
993
+ return None
994
+ return skill_scan.notes(skill_scan.scan(self.settings.skills_dir / run.subject.skill))
995
+
996
+ async def _start_quietly(
997
+ self, session_id: str, prompt: Sequence[Message] = (), timeout: float | None = None
998
+ ) -> None:
999
+ with contextlib.suppress(SessionError):
1000
+ live = await self.ensure_running(session_id)
1001
+ if prompt:
1002
+ await live.first_prompt(list(prompt), timeout)
1003
+ if live.record.interface is not None: # a run: its result, then no one to talk to
1004
+ await self.read_output(live, timeout)
1005
+ self.keep_answer(live.record)
1006
+ await self.stop(session_id, "run ended", "run_ended")
1007
+
1008
+ async def read_output(self, live: LiveSession, timeout: float | None) -> None:
1009
+ """A run's result, from its final message (D.18); asked once more when it is missing or
1010
+ invalid. None when its turn did not end (timed out, cancelled, failed)."""
1011
+ rec = live.record
1012
+ assert rec.interface is not None
1013
+ schema = AgentInterface.model_validate(rec.interface).result
1014
+ output: SessionOutput | None = None
1015
+ for attempt in range(2):
1016
+ if rec.last_stop_reason != "end_turn":
1017
+ break
1018
+ output = interfaces.parse(self.event_log(rec.id).answer(), schema)
1019
+ if output.valid or attempt == 1 or live.record.state != "running":
1020
+ break
1021
+ await live.first_prompt([{"type": "text", "text": interfaces.repair(output)}], timeout)
1022
+ rec.output = output.model_dump(mode="json", by_alias=True) if output else None
1023
+ self.save(rec)
1024
+
1025
+ def keep_answer(self, rec: SessionRecord) -> None:
1026
+ rec.answer = self.event_log(rec.id).answer()
1027
+ self.save(rec)
1028
+
1029
+ async def cancel(self, session_id: str) -> None:
1030
+ if (live := self.live.get(session_id)) is not None:
1031
+ await live.cancel_turn()
1032
+
1033
+ async def ensure_running(self, session_id: str) -> LiveSession:
1034
+ """The live session, started if needed — at most one start at a time (F.6.1)."""
1035
+ if (live := self.live.get(session_id)) is not None and live.record.state == "running":
1036
+ return live
1037
+ task = self.starting.get(session_id)
1038
+ if task is None:
1039
+ task = asyncio.create_task(self._start(self.records[session_id]))
1040
+ self.starting[session_id] = task
1041
+ task.add_done_callback(lambda _: self.starting.pop(session_id, None))
1042
+ return await asyncio.shield(task)
1043
+
1044
+ async def _start(self, rec: SessionRecord) -> LiveSession:
1045
+ if rec.state == "failed":
1046
+ raise SessionError(rec.error or "session failed")
1047
+ agent = self.registry.get(rec.agent_id)
1048
+ if agent is None:
1049
+ raise SessionError("its agent was deleted")
1050
+ # its history belongs to the harness, login and folder it was started with
1051
+ # (its project's context is checked when it starts, launcher.prepare)
1052
+ runs = agent.resumes_as(rec.harness, rec.account, rec.provider, rec.model)
1053
+ if (scope := agent.scope or str(self.work_folder(rec.id))) != rec.scope:
1054
+ raise SessionError(
1055
+ f"the agent now works in {scope}; this conversation was in {rec.scope}: "
1056
+ "start a new session"
1057
+ )
1058
+ self.registry.touch(agent)
1059
+ first = rec.state == "starting"
1060
+ rec.state = "starting"
1061
+ self.save(rec)
1062
+ if first: # what its folder holds before the agent runs (F.8)
1063
+ try:
1064
+ await asyncio.to_thread(
1065
+ session_files.record_baseline, Path(rec.scope), self.baseline(rec.id)
1066
+ )
1067
+ except OSError as e:
1068
+ log.warning("session %s: its folder not recorded: %s", rec.id, e)
1069
+ live = LiveSession(self, rec, agent, runs)
1070
+ self.live[rec.id] = live
1071
+ try:
1072
+ await live.start()
1073
+ except (SessionError, launcher.AgentStartError, OSError) as e:
1074
+ log.warning("session %s: start failed: %s", rec.id, e)
1075
+ rec.state, rec.error = ("failed" if first else "stopped"), str(e)
1076
+ live.stop_reason = str(e)
1077
+ self.save(rec)
1078
+ await live.stop(str(e), "error")
1079
+ self.live.pop(rec.id, None)
1080
+ raise SessionError(str(e)) from None
1081
+ return live
1082
+
1083
+ def changed(self) -> None:
1084
+ for listener in self.listeners:
1085
+ listener()
1086
+
1087
+ def set_remote(self, session_id: str, enabled: bool) -> None:
1088
+ """Share a running session with paired devices, or stop sharing it
1089
+ (docs/dev/design/remote.md §5). Off closes the remote attachments at once (4004)."""
1090
+ live = self.live.get(session_id)
1091
+ if live is None or live.record.state != "running":
1092
+ raise SessionError("the session is not running")
1093
+ live.remote = enabled
1094
+ if not enabled:
1095
+ for client in list(live.clients):
1096
+ if client.kind == "remote":
1097
+ client.close(AcpCloseCode.REMOTE_DISABLED, "remote access disabled")
1098
+ self.changed()
1099
+
1100
+ async def stop(self, session_id: str, reason: str, how: StopHow) -> None:
1101
+ if (task := self.starting.get(session_id)) is not None:
1102
+ with contextlib.suppress(SessionError):
1103
+ await asyncio.shield(task)
1104
+ if (live := self.live.get(session_id)) is not None:
1105
+ await live.stop(reason, how)
1106
+ with contextlib.suppress(asyncio.CancelledError):
1107
+ await asyncio.gather(*live.tasks, return_exceptions=True)
1108
+
1109
+ async def delete(self, session_id: str) -> None:
1110
+ await self.stop(session_id, "deleted", "deleted")
1111
+ self.records.pop(session_id, None)
1112
+ shutil.rmtree(self.folder(session_id), ignore_errors=True)
1113
+
1114
+ async def stop_agent(self, agent_id: str) -> None:
1115
+ for rec in self.list(agent_id):
1116
+ await self.stop(rec.id, "workspace deleted", "deleted")