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,280 @@
1
+ """The terminal client of `handler run` and `handler attach`: a daemon-held session
2
+ (F) in the terminal, as one more attachment (F.7) — the web UI and a paired phone can show the same
3
+ session at the same time.
4
+
5
+ Plain ACP over the session's WebSocket: `initialize`, `session/load` (the daemon replays the
6
+ conversation), then prompts typed on stdin. The agent's text streams to stdout; tool calls are one
7
+ line each; a permission request lists its options, the next line answers it (the first answer, from
8
+ any client, wins). Ctrl-C cancels the turn in progress, or detaches when there is none; Ctrl-D and
9
+ `/detach` detach: the agent keeps running (`handler attach` comes back to it). `/stop` stops it.
10
+
11
+ stdin is read by a daemon thread (a blocked read must not keep the process alive after detaching).
12
+ """
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import itertools
17
+ import json
18
+ import signal
19
+ import sys
20
+ import threading
21
+ from typing import Any, TextIO
22
+
23
+ with contextlib.suppress(ImportError): # not on every platform
24
+ import readline # noqa: F401 — line editing and history for input()
25
+
26
+ from websockets.asyncio.client import ClientConnection, connect
27
+ from websockets.exceptions import ConnectionClosed
28
+ from websockets.typing import Subprotocol
29
+
30
+ from handler_contracts import ACP_SUBPROTOCOL, BEARER_SUBPROTOCOL_PREFIX
31
+
32
+ from .client import Daemon
33
+
34
+ Message = dict[str, Any]
35
+ HELP = "Enter: send · Ctrl-C: cancel the turn (or detach) · Ctrl-D, /detach: detach · /stop: stop"
36
+
37
+
38
+ class Screen:
39
+ """What is printed: streamed text, one line per event, colors when stdout is a terminal."""
40
+
41
+ def __init__(self, out: TextIO) -> None:
42
+ self.out = out
43
+ self.color = out.isatty()
44
+ self.midline = False # the last write did not end a line
45
+ self.stream: str | None = None # the kind of chunk being streamed
46
+
47
+ def paint(self, text: str, code: str) -> str:
48
+ return f"\033[{code}m{text}\033[0m" if self.color else text
49
+
50
+ def line(self, text: str) -> None:
51
+ if self.midline:
52
+ self.out.write("\n")
53
+ self.out.write(text + "\n")
54
+ self.out.flush()
55
+ self.midline, self.stream = False, None
56
+
57
+ def chunk(self, kind: str, text: str) -> None:
58
+ if self.stream != kind and self.midline:
59
+ self.out.write("\n")
60
+ if kind == "thought":
61
+ text = self.paint(text, "2")
62
+ self.out.write(text)
63
+ self.out.flush()
64
+ self.midline, self.stream = not text.endswith("\n"), kind
65
+
66
+ def update(self, u: Message) -> None:
67
+ kind = u.get("sessionUpdate")
68
+ content = u.get("content") or {}
69
+ text = content.get("text", "") if isinstance(content, dict) else ""
70
+ if kind == "agent_message_chunk":
71
+ self.chunk("agent", text)
72
+ elif kind == "agent_thought_chunk":
73
+ self.chunk("thought", text)
74
+ elif kind == "user_message_chunk":
75
+ self.line(self.paint("> ", "1;36") + text)
76
+ elif kind == "tool_call":
77
+ self.line(self.paint(f"• {u.get('title') or u.get('kind') or 'tool'}", "33"))
78
+ elif kind == "tool_call_update" and u.get("status") == "failed":
79
+ self.line(self.paint(f" ✗ {u.get('title') or 'failed'}", "31"))
80
+ elif kind == "plan":
81
+ for entry in u.get("entries") or []:
82
+ mark = {"completed": "✓", "in_progress": "…"}.get(entry.get("status"), "·")
83
+ self.line(self.paint(f" {mark} {entry.get('content', '')}", "2"))
84
+
85
+
86
+ class Terminal:
87
+ def __init__(self, daemon: Daemon, session: Message, out: TextIO = sys.stdout) -> None:
88
+ self.daemon = daemon
89
+ self.session = session
90
+ self.screen = Screen(out)
91
+ self.ids = itertools.count(1)
92
+ self.waiting: dict[int, asyncio.Future[Message]] = {}
93
+ self.permission: Message | None = None # the request the next line answers
94
+ self.busy = False
95
+ self.prompt_id: int | None = None
96
+ self.lines: asyncio.Queue[str | None] = asyncio.Queue()
97
+ self.ws: ClientConnection | None = None
98
+ self.done = asyncio.Event()
99
+ self.result = 0
100
+ self.background: set[asyncio.Task[None]] = set()
101
+
102
+ # ── wire ────────────────────────────────────────────────────────────
103
+
104
+ async def send(self, msg: Message) -> None:
105
+ assert self.ws is not None
106
+ await self.ws.send(json.dumps({"jsonrpc": "2.0"} | msg))
107
+
108
+ async def request(self, method: str, params: Message) -> Message:
109
+ rid = next(self.ids)
110
+ future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
111
+ self.waiting[rid] = future
112
+ await self.send({"id": rid, "method": method, "params": params})
113
+ response = await future
114
+ if "error" in response:
115
+ raise RuntimeError(f"{method}: {response['error'].get('message')}")
116
+ return response.get("result") or {}
117
+
118
+ async def receive(self) -> None:
119
+ assert self.ws is not None
120
+ try:
121
+ async for raw in self.ws:
122
+ for line in str(raw).splitlines():
123
+ if line.strip():
124
+ self.handle(json.loads(line))
125
+ except ConnectionClosed:
126
+ pass
127
+ reason = self.ws.close_reason or "closed"
128
+ if not self.done.is_set():
129
+ self.screen.line(self.screen.paint(f"[session closed: {reason}]", "31"))
130
+ self.result = 1
131
+ self.done.set()
132
+
133
+ def handle(self, m: Message) -> None:
134
+ method, mid = m.get("method"), m.get("id")
135
+ if method is None and mid in self.waiting:
136
+ self.waiting.pop(mid).set_result(m)
137
+ return
138
+ if method is None and mid == self.prompt_id:
139
+ self.end_turn((m.get("result") or {}).get("stopReason", "error"))
140
+ return
141
+ params = m.get("params") or {}
142
+ if method == "session/update":
143
+ self.screen.update(params.get("update") or {})
144
+ elif method == "session/request_permission" and mid is not None:
145
+ self.ask(m)
146
+ elif method == "_handler/permission_resolved":
147
+ if self.permission and self.permission.get("id") == params.get("requestId"):
148
+ self.permission = None
149
+ self.screen.line(self.screen.paint(" (answered elsewhere)", "2"))
150
+ elif method == "_handler/turn_ended":
151
+ self.end_turn(params.get("stopReason", "end_turn"))
152
+ elif method == "_handler/session_state":
153
+ self.busy = bool(params.get("busy"))
154
+
155
+ def ask(self, request: Message) -> None:
156
+ self.permission = request
157
+ params = request.get("params") or {}
158
+ title = (params.get("toolCall") or {}).get("title") or "the agent asks"
159
+ options = params.get("options") or []
160
+ choices = " ".join(f"{i}) {o.get('name')}" for i, o in enumerate(options, 1))
161
+ self.screen.line(self.screen.paint(f"? {title}", "1;33"))
162
+ self.screen.line(f" {choices} (type a number)")
163
+
164
+ def end_turn(self, stop: str) -> None:
165
+ self.busy, self.prompt_id = False, None
166
+ if stop != "end_turn":
167
+ self.screen.line(self.screen.paint(f"[{stop}]", "2"))
168
+ elif self.screen.midline:
169
+ self.screen.line("")
170
+
171
+ # ── you ─────────────────────────────────────────────────────────────
172
+
173
+ async def typed(self, line: str) -> None:
174
+ text = line.strip()
175
+ if self.permission is not None:
176
+ options = (self.permission.get("params") or {}).get("options") or []
177
+ if text.isdigit() and 1 <= int(text) <= len(options):
178
+ answer = {
179
+ "outcome": {
180
+ "outcome": "selected",
181
+ "optionId": options[int(text) - 1]["optionId"],
182
+ }
183
+ }
184
+ request, self.permission = self.permission, None
185
+ await self.send({"id": request["id"], "result": answer})
186
+ else:
187
+ self.screen.line(f" type a number from 1 to {len(options)}")
188
+ return
189
+ if not text:
190
+ return
191
+ if text in ("/detach", "/quit", "/exit"):
192
+ self.detach()
193
+ elif text == "/stop":
194
+ self.daemon.call("POST", f"/v1/sessions/{self.session['id']}/stop")
195
+ self.done.set()
196
+ elif text == "/help":
197
+ self.screen.line(HELP)
198
+ elif self.busy:
199
+ self.screen.line(" a turn is in progress (Ctrl-C cancels it)")
200
+ else:
201
+ self.busy = True
202
+ self.prompt_id = rid = next(self.ids)
203
+ params = {
204
+ "sessionId": self.session["acpSessionId"],
205
+ "prompt": [{"type": "text", "text": line}],
206
+ }
207
+ await self.send({"id": rid, "method": "session/prompt", "params": params})
208
+
209
+ def interrupt(self) -> None:
210
+ if self.busy:
211
+ cancel = {
212
+ "method": "session/cancel",
213
+ "params": {"sessionId": self.session["acpSessionId"]},
214
+ }
215
+ task = asyncio.ensure_future(self.send(cancel))
216
+ self.background.add(task)
217
+ task.add_done_callback(self.background.discard)
218
+ self.screen.line(self.screen.paint("[cancelling…]", "2"))
219
+ else:
220
+ self.detach()
221
+
222
+ def detach(self) -> None:
223
+ self.screen.line(
224
+ self.screen.paint(
225
+ f"[detached: the agent keeps running — handler attach {self.session['id']}]", "2"
226
+ )
227
+ )
228
+ self.done.set()
229
+
230
+ def read_stdin(self, loop: asyncio.AbstractEventLoop) -> None:
231
+ while True:
232
+ try:
233
+ line = input()
234
+ except (EOFError, OSError):
235
+ loop.call_soon_threadsafe(self.lines.put_nowait, None)
236
+ return
237
+ except KeyboardInterrupt:
238
+ continue
239
+ loop.call_soon_threadsafe(self.lines.put_nowait, line)
240
+
241
+ async def keyboard(self) -> None:
242
+ while not self.done.is_set():
243
+ line = await self.lines.get()
244
+ if line is None:
245
+ self.detach()
246
+ return
247
+ await self.typed(line)
248
+
249
+ # ── run ─────────────────────────────────────────────────────────────
250
+
251
+ async def run(self, url: str, token: str) -> int:
252
+ protocols = [Subprotocol(ACP_SUBPROTOCOL), Subprotocol(BEARER_SUBPROTOCOL_PREFIX + token)]
253
+ async with connect(url, subprotocols=protocols, max_size=None) as ws:
254
+ self.ws = ws
255
+ receiver = asyncio.create_task(self.receive())
256
+ await self.request(
257
+ "initialize", {"protocolVersion": 1, "clientCapabilities": {}}
258
+ ) # once it runs
259
+ self.session = self.daemon.call("GET", f"/v1/sessions/{self.session['id']}")
260
+ params = {
261
+ "sessionId": self.session["acpSessionId"],
262
+ "cwd": self.session["scope"],
263
+ "mcpServers": [],
264
+ }
265
+ await self.request("session/load", params)
266
+ self.screen.line(self.screen.paint(HELP, "2"))
267
+ loop = asyncio.get_running_loop()
268
+ with contextlib.suppress(NotImplementedError):
269
+ loop.add_signal_handler(signal.SIGINT, self.interrupt)
270
+ threading.Thread(target=self.read_stdin, args=(loop,), daemon=True).start()
271
+ keyboard = asyncio.create_task(self.keyboard())
272
+ await self.done.wait()
273
+ keyboard.cancel()
274
+ receiver.cancel()
275
+ return self.result
276
+
277
+
278
+ def attach(daemon: Daemon, session: Message) -> int:
279
+ """Attach the terminal to a session until you detach or it ends."""
280
+ return asyncio.run(Terminal(daemon, session).run(session["acpUrl"], daemon.token))
@@ -0,0 +1,37 @@
1
+ """`handler terminal`: this terminal attached to a harness's own terminal program in its sandbox
2
+ (D.16). handlerd starts the sandbox, then this process becomes the sandbox's `attach` command: raw
3
+ while it runs (every key goes to the program, Ctrl-C included), resized with this terminal, its
4
+ exit code the program's; closing the terminal stops the sandbox. handlerd is not in between, so it
5
+ can restart meanwhile.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ from typing import Any, NoReturn
11
+
12
+ from handler_contracts import TerminalStart, TerminalStarted, terminal_path
13
+
14
+ from .client import Daemon
15
+
16
+ _TERM = re.compile(r"^[A-Za-z0-9._+-]{1,64}$")
17
+ START_TIMEOUT = 600.0 # seconds: handlerd's own limit (a first start may build the proxy's image)
18
+
19
+
20
+ def _start(args: list[str], model: dict[str, Any] | None) -> TerminalStart:
21
+ term = os.environ.get("TERM", "")
22
+ colorterm = os.environ.get("COLORTERM", "")
23
+ extra: dict[str, Any] = {"term": term} if _TERM.match(term) else {}
24
+ if _TERM.match(colorterm):
25
+ extra["colorterm"] = colorterm
26
+ if model is not None:
27
+ extra["model"] = model
28
+ return TerminalStart(args=args, **extra)
29
+
30
+
31
+ def run(d: Daemon, agent_id: str, args: list[str], model: dict[str, Any] | None = None) -> NoReturn:
32
+ """The agent's terminal, with `model` (one of the agent's; default: its first); ClientError
33
+ when it cannot start."""
34
+ body = _start(args, model).model_dump(mode="json", by_alias=True, exclude_none=True)
35
+ raw = d.call("POST", terminal_path(agent_id), body, timeout=START_TIMEOUT)
36
+ started = TerminalStarted.model_validate(raw)
37
+ os.execvp(started.attach[0], started.attach)