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,309 @@
1
+ """`handler automation …` — automations (docs/user/automations.md): when a
2
+ session stops or at a time you set, handler runs your script in a sandbox (it may call the models
3
+ you chose, what it writes is added to a knowledge note), one of your agents (its result goes to your
4
+ inbox: `handler inbox`), or a route of one of your apps. Each is a folder of
5
+ ~/.config/handler-ai/automations/<name>/: edit its files in your editor.
6
+
7
+ handler automation list every automation: on or off, when, last run, problems
8
+ handler automation show NAME what it may touch, its files, its problems, its next run
9
+ handler automation new NAME --from EXAMPLE copy an example handler ships (env-issues), off
10
+ handler automation examples the examples handler ships
11
+ handler automation try NAME [--session ID|last]
12
+ run it now, on or off (one on a session that stopped:
13
+ on that session's whole conversation, default the one
14
+ that stopped last; a script's try adds nothing)
15
+ handler automation enable NAME [--yes] turn it on: shows what it may touch and asks first
16
+ handler automation disable NAME turn it off
17
+ handler automation delete NAME [--yes] delete it: its folder (its files) and its runs; asks first
18
+ handler automation runs NAME its runs, most recent first
19
+ handler automation log RUN a script run's output
20
+ handler automation cancel RUN stop a run
21
+ """
22
+
23
+ import argparse
24
+ import sys
25
+ import time
26
+ from collections.abc import Callable
27
+ from datetime import datetime
28
+ from typing import Any
29
+
30
+ from .client import ClientError, Daemon
31
+ from .output import one_line, table
32
+
33
+ WATCH_INTERVAL = 0.5
34
+
35
+
36
+ def add_parser(sub: Any) -> None:
37
+ auto = sub.add_parser("automation", help="automations", description=__doc__,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
39
+ a = auto.add_subparsers(dest="automation_cmd", required=True)
40
+ a.add_parser("list")
41
+ a.add_parser("examples")
42
+ new = a.add_parser("new")
43
+ new.add_argument("name")
44
+ new.add_argument("--from", dest="example", required=True, help="an example (env-issues)")
45
+ t = a.add_parser("try")
46
+ t.add_argument("name")
47
+ t.add_argument("--session", default="last", help="a session's id, or last (the default)")
48
+ e = a.add_parser("enable")
49
+ e.add_argument("name")
50
+ e.add_argument("--yes", action="store_true", help="do not ask")
51
+ rm = a.add_parser("delete")
52
+ rm.add_argument("name")
53
+ rm.add_argument("--yes", action="store_true", help="do not ask")
54
+ for name in ("show", "disable", "runs"):
55
+ a.add_parser(name).add_argument("name")
56
+ for name in ("log", "cancel"):
57
+ a.add_parser(name).add_argument("run")
58
+
59
+
60
+ def sentence(definition: dict[str, Any] | None) -> str:
61
+ """When it runs, in words."""
62
+ if not definition:
63
+ return "-"
64
+ when = definition["when"]
65
+ if isinstance(when, dict):
66
+ if when.get("every"):
67
+ return f"every {when['every']}"
68
+ days = f" on {','.join(when['days'])}" if when.get("days") else ""
69
+ return f"at {when['at']}{days}"
70
+ only = definition.get("only") or {}
71
+ who = [f"project {', '.join(only['project'])}"] if only.get("project") else []
72
+ who += [f"agent {', '.join(only['agent'])}"] if only.get("agent") else []
73
+ return "when a session stops" + (f" ({'; '.join(who)})" if who else "")
74
+
75
+
76
+ def what(definition: dict[str, Any] | None) -> str:
77
+ """What it runs, in a few words."""
78
+ if not definition:
79
+ return "-"
80
+ run = definition["run"]
81
+ if isinstance(run, str):
82
+ return "a script"
83
+ if "agent" in run:
84
+ return f"agent {run['agent']}" + (f" ({run['project']})" if run.get("project") else "")
85
+ return f"app {run['app']}"
86
+
87
+
88
+ def last_run(view: dict[str, Any]) -> str:
89
+ run = view.get("lastRun")
90
+ if not run:
91
+ return "-"
92
+ return f"{run['state']} {run['createdAt'][:16].replace('T', ' ')}"
93
+
94
+
95
+ def list_automations(d: Daemon, _: argparse.Namespace) -> int:
96
+ views = d.call("GET", "/v1/automations")
97
+ if not views:
98
+ print("no automation: handler automation new NAME --from env-issues")
99
+ return 0
100
+ rows = [["NAME", "", "WHEN", "RUNS", "LAST RUN", "PROBLEMS"]]
101
+ for v in views:
102
+ state = "on" if v.get("enabled") else "off"
103
+ problems = one_line("; ".join(v.get("problems") or []), 60) or "-"
104
+ definition = v.get("definition")
105
+ rows.append([v["name"], state, sentence(definition), what(definition), last_run(v),
106
+ problems]) # fmt: skip
107
+ table(rows)
108
+ return 0
109
+
110
+
111
+ def show(d: Daemon, args: argparse.Namespace) -> int:
112
+ v = d.call("GET", f"/v1/automations/{args.name}")
113
+ print(f"{v['name']}: {'on' if v.get('enabled') else 'off'}"
114
+ + (f" — {v['description']}" if v.get("description") else "")) # fmt: skip
115
+ if permissions := v.get("permissions"):
116
+ print("What it does:")
117
+ for line in permissions:
118
+ print(f" {line}")
119
+ for warning in v.get("warnings") or []:
120
+ print(f"warning: {warning}")
121
+ for problem in v.get("problems") or []:
122
+ print(f"problem: {problem}")
123
+ print(f"Files, in {v['directory']}:")
124
+ for file in v.get("files") or []:
125
+ print(f" {file}")
126
+ if v.get("pending"):
127
+ capped = " (daily cap reached)" if v.get("capped") else ""
128
+ behind = f", behind {v['behind']} (one run at a time)" if v.get("behind") else ""
129
+ print(f"{v['pending']} run(s) waiting{capped}{behind}")
130
+ if v.get("nextAt"):
131
+ print(f"Next run: {when_at(v['nextAt'])}")
132
+ print(f"Last run: {last_run(v)}")
133
+ return 0
134
+
135
+
136
+ def when_at(t: str) -> str:
137
+ """A time of the API (UTC) as the local time, to the minute."""
138
+ return datetime.fromisoformat(t).astimezone().strftime("%Y-%m-%d %H:%M")
139
+
140
+
141
+ def examples(d: Daemon, _: argparse.Namespace) -> int:
142
+ table([["EXAMPLE", "DESCRIPTION"]] + [[e["name"], e.get("description") or ""]
143
+ for e in d.call("GET", "/v1/automation-examples")]) # fmt: skip
144
+ return 0
145
+
146
+
147
+ def new(d: Daemon, args: argparse.Namespace) -> int:
148
+ v = d.call("POST", "/v1/automations", {"name": args.name, "from": args.example})
149
+ print(f"{args.name}: created, off, in {v['directory']}")
150
+ for problem in v.get("problems") or []:
151
+ print(f"problem: {problem}")
152
+ print(f"Edit its files, try it (handler automation try {args.name}), then turn it on "
153
+ f"(handler automation enable {args.name}).") # fmt: skip
154
+ return 0
155
+
156
+
157
+ def last_session(d: Daemon) -> str:
158
+ sessions = d.call("GET", "/v1/sessions")
159
+ stopped = [s for s in sessions if s["state"] == "stopped" and not s.get("input")]
160
+ if not stopped:
161
+ raise ClientError("no stopped session to try it on (--session ID)")
162
+ return max(stopped, key=lambda s: s["lastActivityAt"])["id"]
163
+
164
+
165
+ def wait(d: Daemon, name: str, run: dict[str, Any]) -> dict[str, Any]:
166
+ while run["state"] in ("queued", "running"):
167
+ time.sleep(WATCH_INTERVAL)
168
+ run = d.call("GET", f"/v1/automations/{name}/runs/{run['id']}")
169
+ return run
170
+
171
+
172
+ def run_for(run: dict[str, Any]) -> str:
173
+ """What a run was for: the session that stopped, or the schedule's slot (and those missed)."""
174
+ if run.get("sessionId"):
175
+ return f"session {run['sessionId']}, events {run['seqFrom']} to {run['seqTo']}"
176
+ if run.get("slot"):
177
+ missed = run.get("missed") or 0
178
+ return f"slot {when_at(run['slot'])}" + (f", {missed} missed before" if missed else "")
179
+ return "now"
180
+
181
+
182
+ def describe_run(run: dict[str, Any]) -> list[str]:
183
+ llm = run.get("llm") or {}
184
+ lines = [f"{run['id']} {run['state']}{' (try)' if run.get('trial') else ''}: {run_for(run)}"]
185
+ if run.get("agentSessionId"):
186
+ lines.append(f" the agent's session: {run['agentSessionId']}")
187
+ if run.get("error"):
188
+ lines.append(f" error: {run['error']}")
189
+ if llm.get("calls"):
190
+ lines.append(f" {llm['calls']} model call(s), {llm['inTokens']} tokens in, "
191
+ f"{llm['outTokens']} out") # fmt: skip
192
+ for appended in run.get("appended") or []:
193
+ verb = "would add" if run.get("trial") else "added"
194
+ lines.append(f" {verb} {appended['bytes']} bytes to {appended['to']}")
195
+ return lines
196
+
197
+
198
+ def try_it(d: Daemon, args: argparse.Namespace) -> int:
199
+ v = d.call("GET", f"/v1/automations/{args.name}")
200
+ if (v.get("definition") or {}).get("when") == "session.stopped":
201
+ session = last_session(d) if args.session == "last" else args.session
202
+ run = d.call("POST", f"/v1/automations/{args.name}/try", {"sessionId": session})
203
+ print(f"trying {args.name} on session {session} ({run['id']})…", file=sys.stderr)
204
+ else:
205
+ run = d.call("POST", f"/v1/automations/{args.name}/try", {})
206
+ print(f"trying {args.name} ({run['id']})…", file=sys.stderr)
207
+ run = wait(d, args.name, run)
208
+ print("\n".join(describe_run(run)))
209
+ if run.get("agentSessionId") and run["state"] == "succeeded":
210
+ output = d.call("GET", f"/v1/sessions/{run['agentSessionId']}").get("output") or {}
211
+ value = output.get("value") or {}
212
+ print(f" {value.get('status')}: {value.get('summary')} (in your inbox: handler inbox)")
213
+ for appended in run.get("appended") or []:
214
+ print(f"\n--- {appended['to']} would get:\n{appended.get('text', '')}", end="")
215
+ if run["state"] != "succeeded":
216
+ print(f"(its output: handler automation log {run['id']})", file=sys.stderr)
217
+ return 1
218
+ return 0
219
+
220
+
221
+ def enable(d: Daemon, args: argparse.Namespace) -> int:
222
+ v = d.call("GET", f"/v1/automations/{args.name}")
223
+ if v.get("problems"):
224
+ raise ClientError(f"{args.name} has problems: {'; '.join(v['problems'])}")
225
+ print(f"{args.name}, once on:")
226
+ for line in v.get("permissions") or []:
227
+ print(f" {line}")
228
+ for warning in v.get("warnings") or []:
229
+ print(f"warning: {warning}")
230
+ if not args.yes and input("Turn it on? [y/N] ").strip().lower() != "y":
231
+ return 1
232
+ v = d.call("POST", f"/v1/automations/{args.name}/enable")
233
+ if v.get("nextAt"):
234
+ print(f"{args.name}: on, next run {when_at(v['nextAt'])}")
235
+ else:
236
+ print(f"{args.name}: on (sessions that stop from now on)")
237
+ return 0
238
+
239
+
240
+ def disable(d: Daemon, args: argparse.Namespace) -> int:
241
+ d.call("POST", f"/v1/automations/{args.name}/disable")
242
+ print(f"{args.name}: off")
243
+ return 0
244
+
245
+
246
+ def delete(d: Daemon, args: argparse.Namespace) -> int:
247
+ v = d.call("GET", f"/v1/automations/{args.name}")
248
+ count = len(d.call("GET", f"/v1/automations/{args.name}/runs"))
249
+ print(f"{args.name}: {'on' if v.get('enabled') else 'off'}; deleting it removes")
250
+ print(f" its folder, with every file in it: {v['directory']}")
251
+ runs_of = f"{count} run{'' if count == 1 else 's'}"
252
+ print(f" its {runs_of} and their logs (what it told your inbox stays)")
253
+ if v.get("enabled"):
254
+ print(" and stops it (a run in progress is cancelled)")
255
+ if not args.yes and input("Delete it? [y/N] ").strip().lower() != "y":
256
+ return 1
257
+ d.call("DELETE", f"/v1/automations/{args.name}")
258
+ print(f"{args.name}: deleted")
259
+ return 0
260
+
261
+
262
+ def runs(d: Daemon, args: argparse.Namespace) -> int:
263
+ rows = [["RUN", "STATE", "FOR", "STARTED", "ADDED"]]
264
+ for run in d.call("GET", f"/v1/automations/{args.name}/runs"):
265
+ state = run["state"] + (" (try)" if run.get("trial") else "")
266
+ added = ", ".join(a["to"] for a in run.get("appended") or []) or "-"
267
+ rows.append([run["id"], state, run_for(run), when_at(run["createdAt"]), added])
268
+ table(rows)
269
+ return 0
270
+
271
+
272
+ def find_run(d: Daemon, run_id: str) -> str:
273
+ """The automation `run_id` is a run of."""
274
+ for v in d.call("GET", "/v1/automations"):
275
+ if any(r["id"] == run_id for r in d.call("GET", f"/v1/automations/{v['name']}/runs")):
276
+ return v["name"]
277
+ raise ClientError(f"no run {run_id}")
278
+
279
+
280
+ def log(d: Daemon, args: argparse.Namespace) -> int:
281
+ name = find_run(d, args.run)
282
+ print(d.text(f"/v1/automations/{name}/runs/{args.run}/log"), end="")
283
+ return 0
284
+
285
+
286
+ def cancel(d: Daemon, args: argparse.Namespace) -> int:
287
+ name = find_run(d, args.run)
288
+ run = d.call("POST", f"/v1/automations/{name}/runs/{args.run}/cancel")
289
+ print("\n".join(describe_run(wait(d, name, run))))
290
+ return 0
291
+
292
+
293
+ COMMANDS: dict[str, Callable[[Daemon, argparse.Namespace], int]] = {
294
+ "list": list_automations,
295
+ "show": show,
296
+ "examples": examples,
297
+ "new": new,
298
+ "try": try_it,
299
+ "enable": enable,
300
+ "disable": disable,
301
+ "runs": runs,
302
+ "log": log,
303
+ "cancel": cancel,
304
+ "delete": delete,
305
+ }
306
+
307
+
308
+ def dispatch(args: argparse.Namespace) -> int:
309
+ return COMMANDS[args.automation_cmd](Daemon(), args)
handler_cli/bridge.py ADDED
@@ -0,0 +1,42 @@
1
+ """`handler acp`: bridge stdio (an editor such as Zed, or any ACP client) to an agent's ACP
2
+ WebSocket.
3
+
4
+ The editor talks NDJSON on our stdin/stdout; handlerd applies the policy (P1-P7) and runs the agent
5
+ in its sandbox. One JSON-RPC message per line one way, per frame the other way.
6
+ """
7
+
8
+ import sys
9
+ import threading
10
+
11
+ from websockets.exceptions import ConnectionClosed
12
+ from websockets.sync.client import ClientConnection, connect
13
+ from websockets.typing import Subprotocol
14
+
15
+ from handler_contracts import ACP_SUBPROTOCOL, BEARER_SUBPROTOCOL_PREFIX, AcpCloseCode
16
+
17
+
18
+ def _stdin_to_ws(ws: ClientConnection) -> None:
19
+ try:
20
+ for line in sys.stdin.buffer:
21
+ if line.strip():
22
+ ws.send(line.decode().rstrip("\n"))
23
+ except ConnectionClosed:
24
+ return
25
+ ws.close() # the editor closed our stdin: end the connection (the sandbox stops)
26
+
27
+
28
+ def run_bridge(acp_url: str, token: str) -> int:
29
+ protocols = [Subprotocol(ACP_SUBPROTOCOL), Subprotocol(BEARER_SUBPROTOCOL_PREFIX + token)]
30
+ with connect(acp_url, subprotocols=protocols, max_size=None) as ws:
31
+ threading.Thread(target=_stdin_to_ws, args=(ws,), daemon=True).start()
32
+ try:
33
+ for message in ws:
34
+ text = message if isinstance(message, str) else message.decode()
35
+ sys.stdout.write(text + "\n")
36
+ sys.stdout.flush()
37
+ except ConnectionClosed:
38
+ pass
39
+ if ws.close_code in (None, AcpCloseCode.NORMAL):
40
+ return 0
41
+ print(f"handler acp: connection closed ({ws.close_code}) {ws.close_reason}", file=sys.stderr)
42
+ return 1
handler_cli/cli.py ADDED
@@ -0,0 +1,326 @@
1
+ """handler — run coding agents in sandboxes on your machine. Each command talks to handlerd,
2
+ the daemon that keeps the agents running (`handler setup` installs it).
3
+
4
+ Working with agents:
5
+ handler run [AGENT|ACCOUNT|PROVIDER] [-p REQUEST] [agent options] [--remote] [--detach]
6
+ [--anyway] a session in the current directory, in the terminal
7
+ (also shown in the web UI): the project's agent in a
8
+ project's folder, else ad hoc (`handler run -h`)
9
+ handler attach [SESSION] [--stdio] back to a session: a running one is joined, a stopped
10
+ one resumed
11
+ handler terminal AGENT|ACCOUNT|PROVIDER [FOLDER] [agent options] [--anyway] [-- ARGS …]
12
+ the harness's own terminal UI (e.g. Claude Code's) in
13
+ its sandbox, ARGS given to it; not a session: one
14
+ sandbox, gone when it exits
15
+ handler acp AGENT|ACCOUNT|PROVIDER [FOLDER] [agent options] [--anyway]
16
+ ACP on stdio, for editors (e.g. Zed): an agent of the
17
+ folder's project (a session shown in the web UI), else
18
+ an account or a provider (one sandbox for this
19
+ connection)
20
+
21
+ What exists (`handler NOUN -h` for each):
22
+ handler session list | show | stop | share sessions
23
+ handler agent list | show | rm | network agents, their network (log-only, enforce)
24
+ handler project list | show projects (created and edited in the web UI)
25
+ handler account list | add | token | login | import | rm
26
+ subscriptions and their login
27
+ handler provider list | templates | add | rm API keys and local model servers
28
+ handler secret list | set | rm the secret store
29
+ handler addon list | catalog | add | rm addons an agent can get
30
+ handler binding list addons with their settings and secrets
31
+ handler knowledge list knowledge folders an agent can get
32
+ handler image list | rebuild the images agents run in
33
+ handler app … mini apps: register, deploy, open
34
+ handler automation … when a session stops or at a time, run a script,
35
+ an agent or an app's route
36
+ handler inbox what handler told you: apps' notifications,
37
+ automations' results and failures
38
+
39
+ Handler itself:
40
+ handler setup [--no-build] [--yes] after installing: start handlerd as a service,
41
+ check this machine, build the images
42
+ handler daemon status | install | uninstall | log
43
+ handlerd as a systemd user service
44
+ handler ui [--print] open the web UI (token passed in the URL fragment)
45
+
46
+ FOLDER defaults to the current directory, inside one of the work folders (set in the web UI,
47
+ Settings → Folders). A knowledge FOLDER is a path relative to the knowledge root (see `handler
48
+ knowledge list`; set in the web UI), or an absolute path inside it; the agent sees it read-only at
49
+ /knowledge/<folder name>. Outside projects the sandbox starts from a preset: filtered network,
50
+ enforced (--log-only lets every host through and logs it, to learn which it needs), strict
51
+ confinement, permissions bypass. A flag that widens it (--network open, --log-only, --containers)
52
+ is your confirmation: handler says on stderr what it gives the agent.
53
+ --allow adds hosts to a filtered agent's (on top of those its account or provider and its
54
+ addons require). --permissions sets the agent's starting mode: ask (you answer each request),
55
+ accept-edits (file edits allowed) or bypass (unsupervised); the session can change it.
56
+ --containers lets the agent run podman itself (nested confinement). --display gives the agent's
57
+ sandbox your Wayland display, e.g. to watch the browser of the playwright addon, and its
58
+ clipboard: pasting images in `handler terminal` needs it (the agent can then read what you copy);
59
+ nothing else of your desktop.
60
+
61
+ An ACCOUNT runs its own harness; a PROVIDER runs the harness --harness names (or the only one
62
+ that speaks its protocol). --model picks the model (default: the harness's or the provider's).
63
+ The account or provider and the model are for this time only: never saved to the agent.
64
+
65
+ Outside projects, the agent is the one of the folder and its --name (none: the unnamed one):
66
+ the same command in the same folder always gives the same agent, made with the flags typed
67
+ the first time. In a project's folder (or below), `run`, `acp` and `terminal` use the project's
68
+ agent named AGENT, or the one using that ACCOUNT or PROVIDER (default agent first); an account
69
+ or provider none of its agents uses runs the default agent, unless it is not made for the
70
+ project's context (refused; --anyway runs it there outside the project). A flag that differs
71
+ from the agent's settings is never ignored: handler says what differs and asks "Save this to the
72
+ agent?" (yes saves it, no starts nothing); without a terminal, --save saves it, else it is
73
+ refused. Something to try once is another agent: --name NAME (outside projects).
74
+ Elsewhere, `handler run` without a name takes the only account or provider ready.
75
+ The terminal client is built in; HANDLER_ACP_CLIENT names another one (see `handler run -h`).
76
+ """
77
+
78
+ import argparse
79
+ import os
80
+ import sys
81
+ import webbrowser
82
+ from importlib.metadata import PackageNotFoundError, version
83
+ from typing import Any
84
+ from urllib.parse import urlencode
85
+
86
+ from . import (
87
+ account_commands,
88
+ addon_commands,
89
+ agent_commands,
90
+ app_commands,
91
+ automation_commands,
92
+ bridge,
93
+ daemon_commands,
94
+ image_commands,
95
+ inbox_commands,
96
+ project_commands,
97
+ provider_commands,
98
+ secret_commands,
99
+ session_commands,
100
+ setup,
101
+ terminal_client,
102
+ )
103
+ from .client import ClientError, Daemon, base_url, read_token
104
+ from .output import table
105
+ from .session_bridge import run_session_bridge
106
+
107
+
108
+ def add_agent_options(s: argparse.ArgumentParser) -> None:
109
+ s.add_argument(
110
+ "--harness",
111
+ help="the harness, for a provider (an account has its own); in a project's folder: "
112
+ "which of the agent's models",
113
+ )
114
+ s.add_argument(
115
+ "--model",
116
+ help="the model (default: the harness's or the provider's); in a project's folder: "
117
+ "which of the agent's models",
118
+ )
119
+ s.add_argument("--name", help="a name, e.g. the use case (several per folder are fine)")
120
+ s.add_argument("--network", choices=["none", "filtered", "open"], help="default: filtered")
121
+ s.add_argument(
122
+ "--log-only",
123
+ action="store_true",
124
+ help="filtered: let every host through and log it, to learn which it needs "
125
+ "(default: deny what is not allowed)",
126
+ )
127
+ s.add_argument(
128
+ "--allow",
129
+ action="append",
130
+ metavar="HOST:PORT",
131
+ help="a host to allow (filtered network), on top of the required ones; repeatable",
132
+ )
133
+ s.add_argument(
134
+ "--binding",
135
+ action="append",
136
+ metavar="NAME",
137
+ help="a binding (addon + instance + credentials) to give the agent; repeatable",
138
+ )
139
+ s.add_argument(
140
+ "--knowledge",
141
+ action="append",
142
+ metavar="FOLDER",
143
+ help="a folder of the knowledge root, mounted read-only at /knowledge/<name>; repeatable",
144
+ )
145
+ s.add_argument(
146
+ "--permissions",
147
+ choices=["ask", "accept-edits", "bypass"],
148
+ help="the agent's starting permission mode (default: bypass)",
149
+ )
150
+ s.add_argument(
151
+ "--containers",
152
+ action="store_true",
153
+ help="let the agent run containers itself (podman in podman)",
154
+ )
155
+ s.add_argument(
156
+ "--display",
157
+ action="store_true",
158
+ help="give the sandbox your Wayland display and clipboard (watch a browser, paste images)",
159
+ )
160
+ s.add_argument(
161
+ "--save",
162
+ action="store_true",
163
+ help="save the flags that differ from the agent's settings to it, without being asked "
164
+ "(needed without a terminal)",
165
+ )
166
+
167
+
168
+ def acp(d: Daemon, args: argparse.Namespace) -> int:
169
+ """An agent of the folder's project: a daemon session (shown in the web UI) as a stdio agent.
170
+ Otherwise (or --anyway) an account or provider: one sandbox for this connection only."""
171
+ found = session_commands.project_agent(d, args.access, os.path.abspath(args.scope), args)
172
+ if found is not None:
173
+ agent, model = found
174
+ body: dict[str, Any] = {"agentId": agent["id"]}
175
+ if model is not None:
176
+ body["model"] = session_commands.access_of(model)
177
+ return run_session_bridge(d, d.call("POST", "/v1/sessions", body))
178
+ agent, model = session_commands.open_ad_hoc(d, args)
179
+ return bridge.run_bridge(f"{agent['acpUrl']}?{urlencode(model)}", d.token)
180
+
181
+
182
+ def terminal(d: Daemon, args: argparse.Namespace) -> int:
183
+ """D.16: an agent of the folder's project, else (or --anyway) an account or provider; one
184
+ sandbox for this terminal."""
185
+ found = session_commands.project_agent(d, args.access, os.path.abspath(args.scope), args)
186
+ agent, model = found if found is not None else session_commands.open_ad_hoc(d, args)
187
+ model = model or agent["models"][0]
188
+ print(terminal_identity(d, model), file=sys.stderr)
189
+ return terminal_client.run(d, agent["id"], args.args, session_commands.access_of(model))
190
+
191
+
192
+ def terminal_identity(d: Daemon, model: dict[str, Any]) -> str:
193
+ """Who the harness runs as (the model of this terminal), from handler's own files: the harness's
194
+ terminal cannot tell (a token login says neither the email nor the plan)."""
195
+ if provider := model.get("provider"):
196
+ harness = model["harness"]
197
+ return f"handler: {harness} · provider {provider} (its endpoint and key, no subscription)"
198
+ name = model.get("account")
199
+ account = next((a for a in d.call("GET", "/v1/accounts") if a["name"] == name), {})
200
+ harness = model.get("harness") or account.get("harness") # a model it does not list: none
201
+ described = f" ({account['description']})" if account.get("description") else ""
202
+ plan = account.get("plan")
203
+ subscription = f"{plan} subscription" if plan else "subscription, plan not set in the account"
204
+ return f"handler: {harness} · account {name}{described} · {subscription}"
205
+
206
+
207
+ def print_knowledge(d: Daemon) -> None:
208
+ listing = d.call("GET", "/v1/knowledge")
209
+ if not listing["exists"]:
210
+ print(f"no knowledge root: {listing['root']} does not exist (set it in the web UI)")
211
+ return
212
+ print(f"knowledge root: {listing['root']} ({listing['source']})")
213
+ for folder in listing["folders"]:
214
+ print(f" {folder}")
215
+
216
+
217
+ def print_bindings(d: Daemon) -> None:
218
+ rows = [["NAME", "ADDON", "SETTINGS", "SECRETS", "ERROR"]]
219
+ for b in d.call("GET", "/v1/bindings"):
220
+ settings = ", ".join(f"{k}={v}" for k, v in b.get("settings", {}).items())
221
+ rows.append(
222
+ [
223
+ b["name"],
224
+ b.get("addon", "?"),
225
+ settings or "-",
226
+ ", ".join(b.get("secrets", [])) or "-",
227
+ b.get("error", "")[:80],
228
+ ]
229
+ )
230
+ table(rows)
231
+
232
+
233
+ def open_ui(*, print_only: bool) -> int:
234
+ """The token travels in the URL fragment: browsers never send it to the server."""
235
+ url = f"{base_url()}/#token={read_token()}"
236
+ print(url)
237
+ if not print_only:
238
+ webbrowser.open(url)
239
+ return 0
240
+
241
+
242
+ def installed_version() -> str:
243
+ """This client's version, read without handlerd (a bug report needs it when handlerd is
244
+ down): the `handler-ai` wheel users install, else this package in a checkout."""
245
+ for distribution in ("handler-ai", "handler-cli"):
246
+ try:
247
+ return version(distribution)
248
+ except PackageNotFoundError:
249
+ pass
250
+ return "unknown"
251
+
252
+
253
+ def parser() -> argparse.ArgumentParser:
254
+ p = argparse.ArgumentParser(
255
+ prog="handler", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
256
+ )
257
+ p.add_argument("--version", action="version", version=f"handler {installed_version()}")
258
+ sub = p.add_subparsers(dest="cmd", required=True)
259
+ session_commands.add_parsers(sub, add_agent_options)
260
+ for name in ("terminal", "acp"):
261
+ s = sub.add_parser(name)
262
+ s.add_argument("access", metavar="AGENT|ACCOUNT|PROVIDER")
263
+ s.add_argument("scope", nargs="?", default=".", metavar="FOLDER")
264
+ add_agent_options(s)
265
+ session_commands.add_anyway_option(s)
266
+ for module in NOUNS.values():
267
+ module.add_parser(sub)
268
+ for name, help_text in (("binding", "addons with their settings and secrets"),
269
+ ("knowledge", "knowledge folders")): # fmt: skip
270
+ noun = sub.add_parser(name, help=help_text)
271
+ noun.add_subparsers(dest=f"{name}_cmd", required=True).add_parser("list")
272
+ sub.add_parser("ui").add_argument("--print", action="store_true", help="only print the URL")
273
+ return p
274
+
275
+
276
+ # nouns whose module parses and runs its own subcommands
277
+ NOUNS: dict[str, Any] = {
278
+ "agent": agent_commands,
279
+ "project": project_commands,
280
+ "account": account_commands,
281
+ "provider": provider_commands,
282
+ "secret": secret_commands,
283
+ "addon": addon_commands,
284
+ "image": image_commands,
285
+ "app": app_commands,
286
+ "automation": automation_commands,
287
+ "inbox": inbox_commands,
288
+ "daemon": daemon_commands,
289
+ "setup": setup,
290
+ }
291
+
292
+
293
+ def dispatch(args: argparse.Namespace) -> int:
294
+ if args.cmd in NOUNS:
295
+ return NOUNS[args.cmd].dispatch(args)
296
+ if args.cmd == "ui":
297
+ return open_ui(print_only=args.print)
298
+ d = Daemon()
299
+ if args.cmd == "binding":
300
+ print_bindings(d)
301
+ elif args.cmd == "knowledge":
302
+ print_knowledge(d)
303
+ elif args.cmd in ("acp", "terminal"):
304
+ return (acp if args.cmd == "acp" else terminal)(d, args)
305
+ else:
306
+ return session_commands.dispatch(d, args)
307
+ return 0
308
+
309
+
310
+ def main(argv: list[str] | None = None) -> int:
311
+ argv = sys.argv[1:] if argv is None else argv
312
+ rest: list[str] = []
313
+ if argv[:1] == ["terminal"] and "--" in argv: # what follows `--` is the harness's
314
+ at = argv.index("--")
315
+ argv, rest = argv[:at], argv[at + 1 :]
316
+ args = parser().parse_args(argv)
317
+ args.args = rest
318
+ try:
319
+ return dispatch(args)
320
+ except ClientError as e:
321
+ print(f"handler: {e}", file=sys.stderr)
322
+ return 1
323
+
324
+
325
+ if __name__ == "__main__":
326
+ sys.exit(main())