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,485 @@
1
+ """Sessions from the command line — the agent runs in the daemon (contract F), the terminal is one
2
+ more attachment, so the web UI and a paired phone see the same session.
3
+
4
+ handler run [AGENT|ACCOUNT|PROVIDER] [-p REQUEST] [agent options] [--remote] [--detach]
5
+ [--anyway]
6
+ a session in the current directory. In a project's folder (or below): the project's
7
+ agent of that name, or the one listing that account or provider among its models
8
+ (the default agent first; --harness, --model: which of its models), else its
9
+ default agent with that account or provider this time; one not made for the
10
+ project's context is refused unless --anyway. No name: the default agent.
11
+ Elsewhere, or with --anyway: the ad-hoc agent of the folder and --name, with that
12
+ account or provider this time (default: the only one ready; a provider's harness:
13
+ --harness, or the only one speaking its protocol). Agent options that differ from
14
+ the agent's settings are saved to it once you say yes (--save without a terminal),
15
+ never ignored
16
+ handler attach [SESSION] [--stdio]
17
+ back to a session in the terminal: a running one is joined, a stopped one restarted
18
+ with its conversation (when its harness can reload it). Default: the last active
19
+ session of the current directory, running ones first. --stdio: as a plain stdio ACP
20
+ agent, for other ACP clients
21
+ handler session list [--all] running sessions (--all: stopped ones too), most recently
22
+ active first
23
+ handler session show SESSION its agent, model, folder, state, sharing
24
+ handler session stop SESSION stop its sandbox (the conversation is kept: attach resumes it)
25
+ handler session share SESSION [--off]
26
+ share a running session with your paired devices, or stop
27
+
28
+ The terminal client is built in. To use another ACP client instead, set HANDLER_ACP_CLIENT to its
29
+ command, `{bridge}` standing for the command of the stdio bridge, e.g.
30
+ HANDLER_ACP_CLIENT='toad acp "{bridge}"'
31
+ """
32
+
33
+ import argparse
34
+ import os
35
+ import shlex
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import time
40
+ from typing import Any
41
+
42
+ from . import terminal
43
+ from .client import ClientError, Daemon
44
+ from .output import table
45
+ from .session_bridge import run_session_bridge
46
+
47
+ START_TIMEOUT = 300.0
48
+
49
+
50
+ def add_parsers(sub: Any, add_agent_options: Any) -> None:
51
+ run = sub.add_parser(
52
+ "run",
53
+ help="a session in the current directory",
54
+ description=__doc__,
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ )
57
+ run.add_argument("access", nargs="?", metavar="AGENT|ACCOUNT|PROVIDER")
58
+ run.add_argument("-p", "--prompt", metavar="REQUEST", help="the first request")
59
+ run.add_argument("--remote", action="store_true", help="share it with your paired devices")
60
+ run.add_argument("--detach", action="store_true", help="start it, do not attach")
61
+ add_agent_options(run)
62
+ add_anyway_option(run)
63
+ attach = sub.add_parser("attach", help="back to a session (a stopped one is resumed)")
64
+ attach.add_argument("session", nargs="?")
65
+ attach.add_argument("--stdio", action="store_true", help="as a stdio ACP agent (for clients)")
66
+ session = sub.add_parser("session", help="sessions", description=__doc__,
67
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
68
+ s = session.add_subparsers(dest="session_cmd", required=True)
69
+ s.add_parser("list").add_argument("--all", action="store_true", help="stopped ones too")
70
+ for name in ("show", "stop"):
71
+ s.add_parser(name).add_argument("session")
72
+ share = s.add_parser("share")
73
+ share.add_argument("session")
74
+ share.add_argument("--off", action="store_true", help="stop sharing it")
75
+
76
+
77
+ def project_of(d: Daemon, folder: str) -> dict[str, Any] | None:
78
+ """The project whose folder is `folder` or contains it (the innermost one)."""
79
+ found = [
80
+ p
81
+ for p in d.call("GET", "/v1/projects")
82
+ if folder == p["folder"] or folder.startswith(p["folder"].rstrip("/") + "/")
83
+ ]
84
+ found.sort(key=lambda p: len(p["folder"]))
85
+ return found[-1] if found else None
86
+
87
+
88
+ def add_anyway_option(p: argparse.ArgumentParser) -> None:
89
+ p.add_argument(
90
+ "--anyway",
91
+ action="store_true",
92
+ help="in a project's folder: run that account or provider ad hoc, outside the project",
93
+ )
94
+
95
+
96
+ def project_agent(
97
+ d: Daemon, name: str | None, folder: str, args: Any
98
+ ) -> tuple[dict[str, Any], dict[str, Any] | None] | None:
99
+ """In a project's folder (or below), the agent of that project to use and the model it runs
100
+ with (None: its first): the agent named `name`, else the one listing account or provider
101
+ `name` among its models (the default agent first), with that model (narrowed by --harness and
102
+ --model), else the default agent on `name`, for this session only (D.19). None outside
103
+ projects and with --anyway. ClientError when `name` is not made for the project's context:
104
+ the folder decides which account works in it; --anyway, acknowledged, runs it outside."""
105
+ project = project_of(d, folder)
106
+ if project is None:
107
+ return None
108
+ if args.anyway:
109
+ acknowledge_outside(d, project, name, args)
110
+ return None
111
+ agents = sorted(project["agents"], key=lambda a: a["id"] != project.get("defaultAgent"))
112
+ if args.name:
113
+ raise ClientError(
114
+ f"--name: in a project's folder, name one of its agents: handler {args.cmd} AGENT"
115
+ )
116
+ if (agent := agent_named(project, name)) is not None:
117
+ return settle_flags(d, agent, args, project), None
118
+ harness, model = getattr(args, "harness", None), getattr(args, "model", None)
119
+ for agent in agents:
120
+ for m in agent["models"]:
121
+ chosen = harness in (None, m["harness"]) and model in (None, m.get("model"))
122
+ if uses(m) == name and chosen:
123
+ return settle_flags(d, agent, args, project), m
124
+ assert name is not None # else the default agent, above
125
+ access = [*d.call("GET", "/v1/accounts"), *d.call("GET", "/v1/providers")]
126
+ if name not in {x["name"] for x in access}:
127
+ names = ", ".join(agent_label(a) for a in agents)
128
+ raise ClientError(f"no agent, account or provider '{name}' in {project['name']} "
129
+ f"(its agents: {names})") # fmt: skip
130
+ if (refused := not_for(d, project, name)) is not None:
131
+ raise ClientError(
132
+ f"{refused}: not started. To run it here outside the project: "
133
+ f"handler {args.cmd} {described(name, harness, model)} --anyway"
134
+ )
135
+ agent = settle_flags(d, agents[0], args, project)
136
+ access = model_access(d, name, harness) | ({"model": model} if model else {})
137
+ return agent, access
138
+
139
+
140
+ def not_for(d: Daemon, project: dict[str, Any], name: str) -> str | None:
141
+ """Why that account or provider may not run in the project (its context, D.19), or None."""
142
+ context = project.get("context")
143
+ if context is None:
144
+ return None
145
+ found = [*d.call("GET", "/v1/accounts"), *d.call("GET", "/v1/providers")]
146
+ made = next((x.get("contexts", []) for x in found if x["name"] == name), [])
147
+ if context in made or "any" in made:
148
+ return None
149
+ return (
150
+ f"{name} is for {', '.join(made) or 'no context'}; {project['name']} is a {context} project"
151
+ )
152
+
153
+
154
+ def acknowledge_outside(d: Daemon, project: dict[str, Any], name: str | None, args: Any) -> None:
155
+ """--anyway in a project's folder with an account or provider not made for its context: the
156
+ person confirms it in a terminal, typing yes (D.19); without a terminal, refused."""
157
+ name = name or default_access(d)
158
+ if (refused := not_for(d, project, name)) is None:
159
+ return
160
+ warning = f"{refused}. It will work in {project['folder']}, outside the project."
161
+ if not sys.stdin.isatty():
162
+ raise ClientError(f"{warning} Not started: confirm it in a terminal (handler {args.cmd})")
163
+ print(f"handler: ⚠ {warning}", file=sys.stderr)
164
+ if input("Type yes to start: ").strip().lower() != "yes":
165
+ raise ClientError("not started")
166
+
167
+
168
+ def described(name: str | None, harness: str | None, model: str | None) -> str:
169
+ return " ".join([str(name), *([f"--harness {harness}"] if harness else [])]) + (
170
+ f" --model {model}" if model else ""
171
+ )
172
+
173
+
174
+ def flag_changes(agent: dict[str, Any], args: Any) -> tuple[dict[str, Any], list[str]]:
175
+ """What the agent flags typed (cli.add_agent_options) change in the agent's settings: those
176
+ settings, and each change in words. A flag not typed changes nothing; --allow, --binding and
177
+ --knowledge add to its lists. (--name says which agent; the account or provider, --harness
178
+ and --model, the model this time only, D.19.)"""
179
+ changed: dict[str, Any] = {}
180
+ said: list[str] = []
181
+ wanted = {
182
+ "network": args.network,
183
+ "egress": "log-only" if args.log_only else None,
184
+ "confinement": "nested" if args.containers else None,
185
+ "permissions": args.permissions,
186
+ "display": True if args.display else None,
187
+ }
188
+ for key, value in wanted.items():
189
+ now = agent.get(key, False if key == "display" else None)
190
+ if value is not None and now != value:
191
+ changed[key] = value
192
+ said.append(f"{key} {shown(now)} → {shown(value)}")
193
+ lists = {"allow": args.allow, "bindings": args.binding, "knowledge": args.knowledge}
194
+ for key, given in lists.items():
195
+ if added := sorted(set(given or []) - set(agent.get(key, []))):
196
+ changed[key] = sorted({*agent.get(key, []), *added})
197
+ said.append(f"{key} + {', '.join(added)}")
198
+ return changed, said
199
+
200
+
201
+ def shown(value: Any) -> str:
202
+ return {True: "on", False: "off", None: "none"}.get(value, str(value))
203
+
204
+
205
+ def settle_flags(
206
+ d: Daemon, agent: dict[str, Any], args: Any, project: dict[str, Any] | None
207
+ ) -> dict[str, Any]:
208
+ """The agent once the flags that differ from its settings are saved to it: never ignored.
209
+ In a terminal, the person says yes (the confirmation of what they widen, D.8); without one,
210
+ --save; else refused, saying what differs. Returns the agent as it is now."""
211
+ changed, said = flag_changes(agent, args)
212
+ if not changed:
213
+ return agent
214
+ differs = f"agent {agent_label(agent)}: {'; '.join(said)} (its settings: not this)"
215
+ if not args.save:
216
+ if not sys.stdin.isatty():
217
+ raise ClientError(
218
+ f"{differs}. Not started: --save saves it to the agent, "
219
+ "--name NAME makes another agent"
220
+ )
221
+ print(f"handler: {differs}", file=sys.stderr)
222
+ if input("Save this to the agent? [y/N] ").strip().lower() not in ("y", "yes"):
223
+ raise ClientError("not started (--name NAME makes another agent)")
224
+ body = request_of(agent, project) | changed
225
+ path = (
226
+ f"/v1/projects/{project['id']}/agents/{agent['id']}"
227
+ if project is not None
228
+ else f"/v1/agents/{agent['id']}"
229
+ )
230
+ try:
231
+ return d.call("PUT", path, body)
232
+ except ClientError as e:
233
+ if (e.status, e.title) != (409, "risks not confirmed"):
234
+ raise
235
+ print(f"handler: this agent gets {e.detail}", file=sys.stderr) # saying yes confirmed it
236
+ return d.call("PUT", path, body | {"confirmRisks": True})
237
+
238
+
239
+ SETTINGS = ("network", "egress", "allow", "confinement", "permissions", "bindings", "knowledge")
240
+ SETTINGS += ("display", "interface")
241
+
242
+
243
+ def request_of(agent: dict[str, Any], project: dict[str, Any] | None) -> dict[str, Any]:
244
+ """The request that saves the agent as it is (D.1 `AdHocAgentRequest`, D.8 `AgentRequest`)."""
245
+ own = ("name", "description", "instructions", "skills")
246
+ own += ("remoteStart",) if project is not None else ("scope", "origin")
247
+ body = {k: agent[k] for k in (*SETTINGS, *own) if agent.get(k) is not None}
248
+ body["models"] = [access_of(m) for m in agent["models"]]
249
+ if project is not None:
250
+ body["default"] = agent["id"] == project.get("defaultAgent")
251
+ return body
252
+
253
+
254
+ def open_ad_hoc(d: Daemon, args: Any) -> tuple[dict[str, Any], dict[str, Any]]:
255
+ """Outside projects (or --anyway): the ad-hoc agent of the folder and --name, made with the
256
+ flags typed when it is new (D.1: the same command in the same folder gives the same agent),
257
+ and the model it runs with this time: the account or provider named."""
258
+ access = model_access(d, args.access, args.harness) | (
259
+ {"model": args.model} if args.model else {}
260
+ )
261
+ scope = os.path.abspath(args.scope)
262
+ name = (args.name or "").strip().lower()
263
+ agent = next(
264
+ (
265
+ a
266
+ for a in d.call("GET", "/v1/agents")
267
+ if a.get("projectId") is None
268
+ and a.get("scope") == scope
269
+ and (a.get("name") or "").lower() == name
270
+ ),
271
+ None,
272
+ )
273
+ if agent is not None:
274
+ return settle_flags(d, agent, args, None), access
275
+ body: dict[str, Any] = {"models": [access], "scope": scope}
276
+ if args.name:
277
+ body["name"] = args.name
278
+ changed, _ = flag_changes({}, args) # every flag typed
279
+ try:
280
+ agent = d.call("POST", "/v1/agents", body | changed)
281
+ except ClientError as e:
282
+ if (e.status, e.title) != (409, "risks not confirmed"):
283
+ raise
284
+ # the flags that widen it were typed: they are the confirmation (D.8), said on stderr
285
+ print(f"handler: this agent gets {e.detail}", file=sys.stderr)
286
+ agent = d.call("POST", "/v1/agents", body | changed | {"confirmRisks": True})
287
+ return agent, access
288
+
289
+
290
+ def agent_named(project: dict[str, Any], name: str | None) -> dict[str, Any] | None:
291
+ agents = project["agents"]
292
+ if name is None:
293
+ return next((a for a in agents if a["id"] == project.get("defaultAgent")), agents[0])
294
+ return next((a for a in agents if agent_label(a).lower() == name.lower()), None)
295
+
296
+
297
+ def agent_label(agent: dict[str, Any]) -> str:
298
+ return agent.get("name") or agent["models"][0]["harness"]
299
+
300
+
301
+ def uses(model: dict[str, Any]) -> str:
302
+ """The account or provider a model's calls use."""
303
+ return model.get("account") or model.get("provider") or ""
304
+
305
+
306
+ def default_access(d: Daemon) -> str:
307
+ """The only account or provider ready."""
308
+ ready = [a["name"] for a in d.call("GET", "/v1/accounts") if a["status"] == "ready"]
309
+ ready += [p["name"] for p in d.call("GET", "/v1/providers") if p["status"] == "ready"]
310
+ if len(ready) == 1:
311
+ return ready[0]
312
+ raise ClientError(f"which account or provider? {', '.join(ready) or 'none ready'}")
313
+
314
+
315
+ def model_access(d: Daemon, name: str, harness: str | None) -> dict[str, Any]:
316
+ """What an ad-hoc agent runs as, for an account or provider name: the account (its harness), or
317
+ the provider with `harness`, else the only harness speaking its protocol."""
318
+ if any(a["name"] == name for a in d.call("GET", "/v1/accounts")):
319
+ return {"account": name} | ({"harness": harness} if harness else {})
320
+ provider = next((p for p in d.call("GET", "/v1/providers") if p["name"] == name), None)
321
+ if provider is None:
322
+ raise ClientError(
323
+ f"no account or provider '{name}' (handler account list; Settings → Model access)"
324
+ )
325
+ if harness is None:
326
+ speaking = provider.get("harnesses", [])
327
+ if len(speaking) != 1:
328
+ raise ClientError(
329
+ f"which harness for provider '{name}'? --harness {' | '.join(speaking)}"
330
+ )
331
+ harness = speaking[0]
332
+ return {"provider": name, "harness": harness}
333
+
334
+
335
+ def access_of(model: dict[str, Any]) -> dict[str, Any]:
336
+ """One of an agent's models as a request names it (without what handlerd adds: its problem, the
337
+ hosts it requires)."""
338
+ return {k: model[k] for k in ("harness", "account", "provider", "model") if k in model}
339
+
340
+
341
+ def labels(d: Daemon) -> dict[str, str]:
342
+ """agent id → "project · agent", for the agents of projects."""
343
+ return {
344
+ a["id"]: f"{p['name']} · {agent_label(a)}"
345
+ for p in d.call("GET", "/v1/projects")
346
+ for a in p["agents"]
347
+ }
348
+
349
+
350
+ def wait_running(d: Daemon, session: dict[str, Any]) -> dict[str, Any]:
351
+ deadline = time.monotonic() + START_TIMEOUT
352
+ while session["state"] == "starting" and time.monotonic() < deadline:
353
+ time.sleep(0.2)
354
+ session = d.call("GET", f"/v1/sessions/{session['id']}")
355
+ if session["state"] != "running":
356
+ raise ClientError(f"the session did not start: {session.get('error') or session['state']}")
357
+ return session
358
+
359
+
360
+ def open_session(
361
+ d: Daemon,
362
+ agent_id: str,
363
+ args: argparse.Namespace,
364
+ prompt: str | None,
365
+ model: dict[str, Any] | None = None,
366
+ ) -> int:
367
+ body: dict[str, Any] = {"agentId": agent_id}
368
+ if prompt:
369
+ body["prompt"] = prompt
370
+ if model is not None:
371
+ body["model"] = access_of(model)
372
+ session = d.call("POST", "/v1/sessions", body)
373
+ print(f"session {session['id']} — in the web UI: {d.url}/#/sessions/{session['id']}")
374
+ if args.remote:
375
+ session = wait_running(d, session)
376
+ d.call("PUT", f"/v1/sessions/{session['id']}/remote", {"enabled": True})
377
+ print("shared with your paired devices")
378
+ if args.detach:
379
+ return 0
380
+ return use(d, session)
381
+
382
+
383
+ def use(d: Daemon, session: dict[str, Any]) -> int:
384
+ """The terminal client, or the one HANDLER_ACP_CLIENT names."""
385
+ if command := os.environ.get("HANDLER_ACP_CLIENT"):
386
+ handler = shutil.which("handler") or sys.argv[0]
387
+ bridge = f"{shlex.quote(handler)} attach --stdio {session['id']}"
388
+ return subprocess.call(
389
+ command.replace("{bridge}", bridge), shell=True
390
+ ) # the user's own command
391
+ return terminal.attach(d, session)
392
+
393
+
394
+ def dispatch(d: Daemon, args: argparse.Namespace) -> int:
395
+ here = os.path.abspath(".")
396
+ if args.cmd == "run":
397
+ if (found := project_agent(d, args.access, here, args)) is not None:
398
+ agent, model = found
399
+ return open_session(d, agent["id"], args, args.prompt, model)
400
+ args.access = args.access or default_access(d)
401
+ args.scope = here
402
+ agent, model = open_ad_hoc(d, args)
403
+ return open_session(d, agent["id"], args, args.prompt, model)
404
+ if args.cmd == "attach":
405
+ session = find_session(d, args.session, here)
406
+ if args.stdio:
407
+ return run_session_bridge(d, session)
408
+ return use(d, session)
409
+ if args.session_cmd == "list":
410
+ print_sessions(d, args.all)
411
+ elif args.session_cmd == "show":
412
+ show_session(d, d.call("GET", f"/v1/sessions/{args.session}"))
413
+ elif args.session_cmd == "stop":
414
+ d.call("POST", f"/v1/sessions/{args.session}/stop")
415
+ print(f"session {args.session}: stopped (handler attach {args.session} resumes it)")
416
+ else:
417
+ body = {"enabled": not args.off}
418
+ d.call("PUT", f"/v1/sessions/{args.session}/remote", body)
419
+ shared = "not shared" if args.off else "shared with your paired devices"
420
+ print(f"session {args.session}: {shared}")
421
+ return 0
422
+
423
+
424
+ def find_session(d: Daemon, session_id: str | None, here: str) -> dict[str, Any]:
425
+ """The session named, else this directory's last active one, running ones first; a stopped
426
+ one only when it can resume (its harness reloads a conversation)."""
427
+ if session_id:
428
+ session = d.call("GET", f"/v1/sessions/{session_id}")
429
+ else:
430
+ mine = [s for s in d.call("GET", "/v1/sessions") if s["scope"] == here] # most recent first
431
+ live = [s for s in mine if s["state"] in ("running", "starting")]
432
+ resumable = [s for s in mine if s.get("canResume")]
433
+ if not (live or resumable):
434
+ raise ClientError(f"no session to come back to in {here} (handler session list --all)")
435
+ session = (live or resumable)[0]
436
+ if session["state"] in ("running", "starting"):
437
+ return session
438
+ if not session.get("canResume"):
439
+ raise ClientError(
440
+ f"session {session['id']} is {session['state']} and cannot be resumed: its harness "
441
+ "cannot reload a conversation"
442
+ )
443
+ print(f"resuming session {session['id']}…", file=sys.stderr)
444
+ return session
445
+
446
+
447
+ def show_session(d: Daemon, s: dict[str, Any]) -> None:
448
+ access = f"account {s['account']}" if s.get("account") else f"provider {s.get('provider')}"
449
+ model = f" · {s['model']}" if s.get("model") else ""
450
+ state = s["state"]
451
+ if s.get("pendingPermissions"):
452
+ state = "needs you"
453
+ elif s.get("busy"):
454
+ state = "working"
455
+ print(f"{s['id']} · {labels(d).get(s['agentId'], s['harness'])} · {state}")
456
+ if s.get("title"):
457
+ print(f" {s['title']}")
458
+ print(f" runs {s['harness']} · {access}{model}")
459
+ print(f" folder {s['scope']}")
460
+ print(f" started {s['createdAt'][:16].replace('T', ' ')} ({s.get('origin', 'ui')})")
461
+ print(f" active {s['lastActivityAt'][:16].replace('T', ' ')}")
462
+ shared = ", shared with your paired devices" if s.get("remote") else ""
463
+ print(f" attached {s.get('attached', 0)} client(s){shared}")
464
+ if s["state"] not in ("running", "starting"):
465
+ resume = f"handler attach {s['id']}" if s.get("canResume") else "not possible"
466
+ print(f" resume {resume}")
467
+ if s.get("error"):
468
+ print(f" error {s['error']}")
469
+ print(f" web UI {d.url}/#/sessions/{s['id']}")
470
+
471
+
472
+ def print_sessions(d: Daemon, everything: bool) -> None:
473
+ names = labels(d)
474
+ rows = [["SESSION", "STATE", "AGENT", "TITLE", "FOLDER"]]
475
+ for s in d.call("GET", "/v1/sessions"):
476
+ if not everything and s["state"] not in ("running", "starting"):
477
+ continue
478
+ state = s["state"]
479
+ if s.get("pendingPermissions"):
480
+ state = "needs you"
481
+ elif s.get("busy"):
482
+ state = "working"
483
+ agent = names.get(s["agentId"], s["harness"])
484
+ rows.append([s["id"], state, agent, (s.get("title") or "-")[:50], s["scope"]])
485
+ table(rows)
handler_cli/setup.py ADDED
@@ -0,0 +1,120 @@
1
+ """`handler setup`: from an installed handler to a working one, safe to run again.
2
+
3
+ 1. handlerd as a systemd user service (`handler daemon install`), started; on its first start it
4
+ writes the token and copies the default toolbox to ~/.config/handler-ai/toolbox/
5
+ 2. what the container runtime finds missing on this machine (GET /v1/runtime, A.8)
6
+ 3. the network filter (D.4), when it is not built: handlerd builds it as it starts, this follows
7
+ that build (or starts it)
8
+ 4. the images, when the toolbox is not built yet: the toolbox then every harness image,
9
+ checked (`handler image rebuild toolbox`); skipped with --no-build
10
+ 5. where the web UI is (`handler ui`)
11
+ """
12
+
13
+ import argparse
14
+ import sys
15
+ import time
16
+ from typing import Any
17
+
18
+ from . import daemon_commands, image_commands
19
+ from .client import ClientError, Daemon, base_url
20
+
21
+ START_TIMEOUT = 60.0
22
+
23
+
24
+ def add_parser(sub: Any) -> None:
25
+ s = sub.add_parser("setup", help="install and start handlerd, check this machine, build images")
26
+ s.add_argument("--no-build", action="store_true", help="do not build the images now")
27
+ s.add_argument("--yes", action="store_true", help="build the images without asking")
28
+
29
+
30
+ def _daemon() -> Daemon:
31
+ """handlerd, once it answers (it writes its token on its first start)."""
32
+ deadline = time.monotonic() + START_TIMEOUT
33
+ while True:
34
+ try:
35
+ d = Daemon()
36
+ d.call("GET", "/v1/health")
37
+ return d
38
+ except ClientError:
39
+ if time.monotonic() > deadline:
40
+ raise ClientError(
41
+ f"handlerd did not start within {START_TIMEOUT:.0f} s: "
42
+ "journalctl --user -u handler-ai shows why"
43
+ ) from None
44
+ time.sleep(1)
45
+
46
+
47
+ def _runtime(d: Daemon) -> bool:
48
+ check = d.call("GET", "/v1/runtime")
49
+ print(f"\n{check['runtime']}")
50
+ for item in check["items"]:
51
+ mark = "ok " if item["ok"] else ("FAIL" if item["required"] else "warn")
52
+ print(f" {mark} {item['name']}: {item['detail']}")
53
+ if not check["ok"]:
54
+ print("\nAgents cannot run here yet: fix what FAIL says, then run `handler setup` again.")
55
+ return bool(check["ok"])
56
+
57
+
58
+ def _network_filter(d: Daemon) -> int:
59
+ """Follow handlerd's build of the network filter, or start it; 0 once it is built."""
60
+ if d.call("GET", "/v1/network-filter")["imagePresent"]:
61
+ return 0
62
+ print(
63
+ "\nBuilding the network filter: it checks where agents connect and adds your secrets to "
64
+ "their requests (about 240 MB, a minute or two)."
65
+ )
66
+ while True:
67
+ for build in d.call("GET", "/v1/builds"):
68
+ if build["target"] == "network-filter" and build["state"] == "running":
69
+ return image_commands.watch(d, build)
70
+ try:
71
+ return image_commands.watch(d, d.call("POST", "/v1/network-filter/rebuild"))
72
+ except ClientError as e: # handlerd started its own meanwhile, or another build runs
73
+ if e.status != 409:
74
+ raise
75
+ time.sleep(1)
76
+
77
+
78
+ def _wants_build(args: argparse.Namespace) -> bool:
79
+ if args.yes:
80
+ return True
81
+ if not sys.stdin.isatty():
82
+ print("Images not built: run `handler setup --yes` or `handler image rebuild toolbox`.")
83
+ return False
84
+ answer = input(
85
+ "\nBuild the images now? The toolbox, then every harness on it: a large download "
86
+ "(about 2.5 GB) and 10 to 20 minutes. [Y/n] "
87
+ )
88
+ return answer.strip().lower() in ("", "y", "yes")
89
+
90
+
91
+ def dispatch(args: argparse.Namespace) -> int:
92
+ print("Installing handlerd as a systemd user service…")
93
+ try:
94
+ code = daemon_commands.install(dry_run=False)
95
+ except OSError as e: # no systemctl
96
+ code = 1
97
+ print(e)
98
+ if code:
99
+ print("systemctl failed: without a systemd user session, run `handlerd` yourself.")
100
+ return code
101
+ d = _daemon()
102
+ print(f"handlerd {d.call('GET', '/v1/health')['version']} is running at {base_url()}")
103
+ if not _runtime(d):
104
+ return 1
105
+ if code := _network_filter(d):
106
+ print(
107
+ "The network filter could not be built: "
108
+ "`handler image rebuild network-filter` tries again."
109
+ )
110
+ return code
111
+ toolbox = d.call("GET", "/v1/toolbox")
112
+ print(f"\nYour toolbox (the tools every agent gets): {toolbox['directory']}")
113
+ if not toolbox["imagePresent"] and not args.no_build and _wants_build(args):
114
+ build = image_commands.rebuild(d, "toolbox")
115
+ if build:
116
+ return build
117
+ print(
118
+ "\nDone. Open the web UI with `handler ui`: first Settings → Model access, then a project."
119
+ )
120
+ return 0