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,128 @@
1
+ """`handler addon …` — addons (contract G.5): a tool for the agent's sandbox (Playwright's browser,
2
+ the Shopify CLI) with the hosts, secrets and skills it needs. An agent gets one through a binding
3
+ (`handler binding list`).
4
+
5
+ handler addon list the installed addons: where from, commands, image, the settings
6
+ and secrets they need, errors, updates
7
+ handler addon catalog the addons handler ships, installed or not
8
+ handler addon add NAME|FOLDER|URL [--yes]
9
+ what it brings (its hosts, secrets, commands, MCP server,
10
+ skills), then, once you agree, install it and build its image:
11
+ NAME from the catalog, a FOLDER, an https:// .tar.gz archive
12
+ handler addon rm NAME an installed addon (refused while a binding uses it)
13
+ """
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from . import image_commands
21
+ from .client import Daemon
22
+ from .output import table
23
+
24
+
25
+ def add_parser(sub: Any) -> None:
26
+ addon = sub.add_parser("addon", help="install and remove addons", description=__doc__,
27
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
28
+ s = addon.add_subparsers(dest="addon_cmd", required=True)
29
+ s.add_parser("list")
30
+ s.add_parser("catalog")
31
+ add = s.add_parser("add")
32
+ add.add_argument("source", metavar="NAME|FOLDER|URL")
33
+ add.add_argument("--yes", action="store_true", help="install without asking")
34
+ s.add_parser("rm").add_argument("name")
35
+
36
+
37
+ def source(arg: str) -> str:
38
+ """What the API calls the source: catalog:NAME, an absolute folder path, or the URL."""
39
+ if arg.startswith(("https://", "http://")):
40
+ return arg
41
+ if "/" in arg or arg.startswith(".") or Path(arg).is_dir():
42
+ return str(Path(arg).resolve())
43
+ return f"catalog:{arg}"
44
+
45
+
46
+ def describe(p: dict[str, Any]) -> str:
47
+ """The trust screen, as text: everything the addon would bring."""
48
+ lines = [f"{p['name']}: {p.get('description') or ''}".rstrip(": "), f" from {p['source']}"]
49
+ if replaces := p.get("replaces"):
50
+ lines.append(f" replaces the {replaces} addon '{p['name']}'")
51
+ rows = [
52
+ ("hosts", ", ".join(p.get("egress", []))),
53
+ ("secrets", ", ".join(s["name"] for s in p.get("secretSlots", []))),
54
+ ("settings", ", ".join(s["name"] for s in p.get("settings", []))),
55
+ ("env", ", ".join(p.get("env", []))),
56
+ ("commands", ", ".join(p.get("commands", []))),
57
+ ("MCP", " ".join(p.get("mcp") or [])),
58
+ ("skills", ", ".join(p.get("skills", []))),
59
+ ]
60
+ lines += [f" {k:<8}{v}" for k, v in rows if v]
61
+ if image := p.get("image"):
62
+ lines.append(f" image {image}: its Containerfile runs code on this machine when built")
63
+ return "\n".join(lines)
64
+
65
+
66
+ def dispatch(args: argparse.Namespace) -> int: # noqa: PLR0911 — one branch per subcommand
67
+ d = Daemon()
68
+ if args.addon_cmd == "list":
69
+ print_addons(d)
70
+ return 0
71
+ if args.addon_cmd == "catalog":
72
+ rows = [["NAME", "INSTALLED", "DESCRIPTION"]]
73
+ for e in d.call("GET", "/v1/addon-catalog"):
74
+ rows.append([e["name"], "yes" if e["installed"] else "-", e.get("description", "")])
75
+ table(rows)
76
+ return 0
77
+ if args.addon_cmd == "rm":
78
+ d.call("DELETE", f"/v1/addons/{args.name}")
79
+ print(f"removed {args.name}")
80
+ return 0
81
+ seen = d.call("POST", "/v1/addons/preview", {"source": source(args.source)})
82
+ print(describe(seen))
83
+ if not args.yes:
84
+ if not sys.stdin.isatty():
85
+ print("not installed: run again with --yes to install it")
86
+ return 1
87
+ if input("\nInstall it? [y/N] ").strip().lower() not in ("y", "yes"):
88
+ return 1
89
+ installed = d.call(
90
+ "POST", "/v1/addons/install", {"source": seen["source"], "digest": seen["digest"]}
91
+ )
92
+ print(f"installed {installed['addon']['name']} in {installed['addon']['directory']}")
93
+ if build := installed.get("build"):
94
+ return image_commands.watch(d, build)
95
+ if seen.get("image"):
96
+ print(
97
+ "another build is running: build it later with "
98
+ f"`handler image rebuild addon:{seen['name']}`"
99
+ )
100
+ return 0
101
+
102
+
103
+ def print_addons(d: Daemon) -> None:
104
+ rows = [["NAME", "FROM", "COMMANDS", "VERSION", "BUILT", "SLOTS", "ERROR"]]
105
+ for a in d.call("GET", "/v1/addons"):
106
+ if a.get("image"):
107
+ version = a.get("version", "-") if a["imagePresent"] else "not built"
108
+ else:
109
+ version = "(no image)"
110
+ slots = [
111
+ *(s["name"] for s in a.get("settings", [])),
112
+ *(f"{s['name']} (secret)" for s in a.get("secretSlots", [])),
113
+ ]
114
+ origin = "built-in" if a.get("builtin") else a.get("source", "yours")
115
+ if a.get("updateAvailable"):
116
+ origin += " (update: handler addon add " + a["name"] + ")"
117
+ rows.append(
118
+ [
119
+ a["name"],
120
+ origin,
121
+ ", ".join(a.get("commands", [])) or "-",
122
+ version,
123
+ a.get("builtAt", "-")[:16].replace("T", " "),
124
+ ", ".join(slots) or "-",
125
+ a.get("error", "")[:80],
126
+ ]
127
+ )
128
+ table(rows)
@@ -0,0 +1,139 @@
1
+ """`handler agent …` — every agent, of projects and outside them (contract D.1, D.8). Agents of
2
+ projects are created and edited in the web UI; agents outside projects are made by `handler run`,
3
+ `terminal` and `acp` in a folder.
4
+
5
+ handler agent list every agent, most recently used first, with its id
6
+ handler agent show ID its settings, models, folder and live sandboxes
7
+ handler agent rm ID delete an agent outside projects (stops its sandboxes)
8
+ handler agent network ID [log-only|enforce]
9
+ its network mode and recent connections (allowed, denied,
10
+ unlisted: let through in log-only, enforcing would deny); with
11
+ a mode, switch a filtered agent to it, live (log-only: every
12
+ host goes through and is logged, to learn which it needs)
13
+ """
14
+
15
+ import argparse
16
+ from typing import Any
17
+
18
+ from .client import Daemon
19
+ from .output import table
20
+
21
+
22
+ def add_parser(sub: Any) -> None:
23
+ agent = sub.add_parser("agent", help="agents", description=__doc__,
24
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
25
+ a = agent.add_subparsers(dest="agent_cmd", required=True)
26
+ a.add_parser("list")
27
+ a.add_parser("show").add_argument("id")
28
+ a.add_parser("rm").add_argument("id")
29
+ network = a.add_parser("network")
30
+ network.add_argument("id")
31
+ network.add_argument("mode", nargs="?", choices=["log-only", "enforce"])
32
+
33
+
34
+ def dispatch(args: argparse.Namespace) -> int:
35
+ d = Daemon()
36
+ if args.agent_cmd == "list":
37
+ print_agents(d)
38
+ elif args.agent_cmd == "show":
39
+ show(d, d.call("GET", f"/v1/agents/{args.id}"))
40
+ elif args.agent_cmd == "rm":
41
+ d.call("DELETE", f"/v1/agents/{args.id}")
42
+ print(f"{args.id}: removed")
43
+ elif args.mode:
44
+ w = d.call("PATCH", f"/v1/agents/{args.id}", {"egress": args.mode})
45
+ print(f"agent {w['id']}: network {network_mode(w)}")
46
+ else:
47
+ print_network(d, d.call("GET", f"/v1/agents/{args.id}"))
48
+ return 0
49
+
50
+
51
+ def print_agents(d: Daemon) -> None:
52
+ projects = {p["id"]: p["name"] for p in d.call("GET", "/v1/projects")}
53
+ rows = [["ID", "PROJECT", "NAME", "RUNS", "SETTINGS", "LIVE", "FOLDER"]]
54
+ listed = d.call("GET", "/v1/agents")
55
+ listed.sort(key=lambda a: a.get("lastUsedAt") or a["createdAt"], reverse=True)
56
+ for a in listed:
57
+ rows.append(
58
+ [
59
+ a["id"],
60
+ projects.get(a.get("projectId") or "", "(ad hoc)"),
61
+ (a.get("name") or "-") + (f" (built-in {a['origin']})" if a.get("origin") else ""),
62
+ runs(a),
63
+ describe_agent(a, runs_too=False),
64
+ str(len(a["connections"])),
65
+ a.get("scope") or "(a folder per session)",
66
+ ]
67
+ )
68
+ table(rows)
69
+
70
+
71
+ def show(d: Daemon, a: dict[str, Any]) -> None:
72
+ projects = {p["id"]: p["name"] for p in d.call("GET", "/v1/projects")}
73
+ project = projects.get(a.get("projectId") or "", "ad hoc")
74
+ print(f"{a.get('name') or a['models'][0]['harness']} · {a['id']} · {project}")
75
+ if a.get("description"):
76
+ print(f" {a['description']}")
77
+ print(f" folder {a.get('scope') or '(a folder per session)'}")
78
+ for i, m in enumerate(a["models"]):
79
+ print(f" {'runs' if i == 0 else '':<12} {model_label(m)}")
80
+ print(f" network {network_mode(a)}")
81
+ if a.get("allow"):
82
+ print(f" its hosts {', '.join(a['allow'])}")
83
+ print(f" permissions {a['permissions']}")
84
+ for key, label in (("bindings", "bindings"), ("knowledge", "knowledge"), ("skills", "skills")):
85
+ if a.get(key):
86
+ print(f" {label:<12} {', '.join(a[key])}")
87
+ if a.get("confinement", "strict") != "strict":
88
+ print(f" containers {'yes' if a['confinement'] == 'nested' else a['confinement']}")
89
+ if a.get("display"):
90
+ print(" display yes")
91
+ print(f" live {len(a['connections'])} sandbox(es)")
92
+
93
+
94
+ def network_mode(a: dict[str, Any]) -> str:
95
+ """`filtered, log-only` / `filtered, enforce` / `open` / `none`."""
96
+ if a["network"] != "filtered":
97
+ return a["network"]
98
+ return f"filtered, {a.get('egress') or 'enforce'}"
99
+
100
+
101
+ def print_network(d: Daemon, a: dict[str, Any]) -> None:
102
+ print(f"network {network_mode(a)}" + (f" + {', '.join(a['allow'])}" if a.get("allow") else ""))
103
+ events = d.call("GET", f"/v1/agents/{a['id']}/egress-events")
104
+ if not events:
105
+ print("no connection yet")
106
+ return
107
+ rows = [["TIME", "DECISION", "HOST", "CONNECTION"]]
108
+ rows += [[e["t"][11:19], e["decision"], e["host"], e["connection"]] for e in events]
109
+ table(rows)
110
+
111
+
112
+ def runs(w: dict[str, Any]) -> str:
113
+ """Its models, the default first: `claude-code · account claude-pro`, `codex · provider
114
+ llamacpp · qwen3`."""
115
+ return " | ".join(model_label(m) for m in w["models"])
116
+
117
+
118
+ def model_label(m: dict[str, Any]) -> str:
119
+ access = f"account {m['account']}" if m.get("account") else f"provider {m.get('provider')}"
120
+ return " · ".join([m["harness"], access, *([m["model"]] if m.get("model") else [])])
121
+
122
+
123
+ def describe_agent(w: dict[str, Any], *, runs_too: bool = True) -> str:
124
+ """Its settings in one line, the defaults left out: `runs=… network=filtered/log-only
125
+ +2 hosts bindings=… knowledge=… containers display`."""
126
+ network = w["network"] + (f"/{w['egress']}" if w.get("egress") == "log-only" else "")
127
+ parts = [f"runs={runs(w)}"] if runs_too else []
128
+ parts.append(f"network={network}" + (f" +{len(w['allow'])} hosts" if w.get("allow") else ""))
129
+ if w["permissions"] != "bypass":
130
+ parts.append(f"permissions={w['permissions']}")
131
+ if w.get("bindings"):
132
+ parts.append(f"bindings={','.join(w['bindings'])}")
133
+ if w.get("knowledge"):
134
+ parts.append(f"knowledge={','.join(w['knowledge'])}")
135
+ if w.get("confinement", "strict") != "strict":
136
+ parts.append("containers" if w["confinement"] == "nested" else w["confinement"])
137
+ if w.get("display"):
138
+ parts.append("display")
139
+ return " ".join(parts)
@@ -0,0 +1,175 @@
1
+ """`handler app …` — mini apps (contract H, docs/dev/design/miniapps.md): small web apps written by
2
+ an agent, run by handlerd on demand, each at its own origin (http://<name>.localhost:<port>/).
3
+
4
+ handler app list registered apps, their versions and state
5
+ handler app add [DIR] register the app whose app.toml is in DIR (default: here)
6
+ handler app deploy NAME [--yes] install what its folder holds now: tests, health check on a
7
+ copy of the data, then switch (asks first when it may reach
8
+ more than before)
9
+ handler app rollback NAME back to the previous version and its data as it was then
10
+ handler app open NAME [--print] open it in its own window (a one-time link): Firefox with
11
+ the handler-apps profile, else Chromium's app mode, else the
12
+ default browser (HANDLER_APP_BROWSER=firefox|chromium|default)
13
+ handler app shortcut NAME KEYS|--none the GNOME keyboard shortcut that opens it, e.g. "<Super>n"
14
+ handler app stop NAME stop its backend now (it starts again when used)
15
+ handler app log NAME its last deploys and its backend's output
16
+ handler app secret NAME SECRET [--unset]
17
+ set a secret the app declares (asked for, or read from stdin);
18
+ the backend restarts with it
19
+ handler app rm NAME unregister it; its data folder is kept
20
+ """
21
+
22
+ import argparse
23
+ import getpass
24
+ import os
25
+ import sys
26
+ from collections.abc import Callable
27
+ from typing import Any
28
+
29
+ from . import app_window
30
+ from .client import ClientError, Daemon
31
+ from .output import table
32
+
33
+
34
+ def add_parser(sub: Any) -> None:
35
+ app = sub.add_parser("app", help="mini apps", description=__doc__,
36
+ formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
37
+ a = app.add_subparsers(dest="app_cmd", required=True)
38
+ a.add_parser("list")
39
+ a.add_parser("add").add_argument("dir", nargs="?", default=".")
40
+ d = a.add_parser("deploy")
41
+ d.add_argument("name")
42
+ d.add_argument("--yes", action="store_true", help="do not ask about new needs")
43
+ o = a.add_parser("open")
44
+ o.add_argument("name")
45
+ o.add_argument("--print", action="store_true", help="only print the link")
46
+ sc = a.add_parser("shortcut")
47
+ sc.add_argument("name")
48
+ keys = sc.add_mutually_exclusive_group(required=True)
49
+ keys.add_argument("keys", nargs="?", help='GNOME accelerator, e.g. "<Super>n"')
50
+ keys.add_argument("--none", action="store_true", help="remove its shortcut")
51
+ sec = a.add_parser("secret")
52
+ sec.add_argument("name")
53
+ sec.add_argument("secret")
54
+ sec.add_argument("--unset", action="store_true")
55
+ for name in ("rollback", "stop", "log", "rm"):
56
+ a.add_parser(name).add_argument("name")
57
+
58
+
59
+ def describe(app: dict[str, Any]) -> str:
60
+ current = app.get("current")
61
+ installed = current["version"] if current else "not deployed"
62
+ lines = [f"{app['name']} {installed} ({app['state']}) — {app['url']}"]
63
+ source = app["source"]
64
+ if source.get("error"):
65
+ lines.append(f" source: {source['error']}")
66
+ elif not current or source.get("version") != current["version"]:
67
+ lines.append(f" source: {source.get('version')} ready to deploy")
68
+ if missing := [s["name"] for s in app.get("secrets", []) if not s["set"]]:
69
+ lines.append(f" secrets not set: {', '.join(missing)} (handler app secret)")
70
+ if app.get("shortcut"):
71
+ lines.append(f" shortcut: {app['shortcut']}")
72
+ if app.get("error"):
73
+ lines.append(f" error: {app['error']}")
74
+ return "\n".join(lines)
75
+
76
+
77
+ def list_apps(d: Daemon, _: argparse.Namespace) -> int:
78
+ rows = [["NAME", "VERSION", "STATE", "SOURCE", "URL"]]
79
+ for app in d.call("GET", "/v1/apps"):
80
+ current = app.get("current")
81
+ source = app["source"].get("version") or "invalid"
82
+ rows.append(
83
+ [
84
+ app["name"],
85
+ current["version"] if current else "-",
86
+ app["state"],
87
+ source if not current or source != current["version"] else "=",
88
+ app["url"],
89
+ ]
90
+ )
91
+ table(rows)
92
+ return 0
93
+
94
+
95
+ def add(d: Daemon, args: argparse.Namespace) -> int:
96
+ print(describe(d.call("POST", "/v1/apps", {"sourceDir": os.path.abspath(args.dir)})))
97
+ return 0
98
+
99
+
100
+ def deploy(d: Daemon, args: argparse.Namespace) -> int:
101
+ app = d.call("GET", f"/v1/apps/{args.name}")
102
+ if app["source"].get("error"):
103
+ raise ClientError(app["source"]["error"])
104
+ if (added := app["source"].get("addedNeeds")) and not args.yes:
105
+ print(f"{args.name} {app['source']['version']} may reach more than before:")
106
+ for need in added:
107
+ print(f" + {need}")
108
+ if input("Deploy? [y/N] ").strip().lower() != "y":
109
+ return 1
110
+ print(f"deploying {args.name} {app['source']['version']} (tests, health check)…")
111
+ print(describe(d.call("POST", f"/v1/apps/{args.name}/deploy")))
112
+ return 0
113
+
114
+
115
+ def open_app(d: Daemon, args: argparse.Namespace) -> int:
116
+ url = d.call("POST", f"/v1/apps/{args.name}/open")["url"]
117
+ print(url)
118
+ if not args.print:
119
+ app_window.open_window(url)
120
+ return 0
121
+
122
+
123
+ def shortcut(d: Daemon, args: argparse.Namespace) -> int:
124
+ keys = None if args.none else args.keys
125
+ app = d.call("PUT", f"/v1/apps/{args.name}/shortcut", {"shortcut": keys})
126
+ print(f"{args.name}: {app.get('shortcut') or 'no shortcut'}")
127
+ return 0
128
+
129
+
130
+ def log(d: Daemon, args: argparse.Namespace) -> int:
131
+ print(d.call("GET", f"/v1/apps/{args.name}/log")["text"], end="")
132
+ return 0
133
+
134
+
135
+ def secret(d: Daemon, args: argparse.Namespace) -> int:
136
+ path = f"/v1/apps/{args.name}/secrets/{args.secret}"
137
+ if args.unset:
138
+ app = d.call("DELETE", path)
139
+ else:
140
+ prompt = f"{args.secret} for {args.name}: "
141
+ value = getpass.getpass(prompt) if sys.stdin.isatty() else sys.stdin.readline()
142
+ app = d.call("PUT", path, {"value": value.strip()})
143
+ state = {s["name"]: s["set"] for s in app.get("secrets", [])}
144
+ print(f"{args.secret}: {'set' if state.get(args.secret) else 'not set'}")
145
+ return 0
146
+
147
+
148
+ def remove(d: Daemon, args: argparse.Namespace) -> int:
149
+ d.call("DELETE", f"/v1/apps/{args.name}")
150
+ print(f"{args.name}: removed (its data folder is kept)")
151
+ return 0
152
+
153
+
154
+ def action(d: Daemon, args: argparse.Namespace) -> int:
155
+ """rollback, stop: a POST that answers the app."""
156
+ print(describe(d.call("POST", f"/v1/apps/{args.name}/{args.app_cmd}")))
157
+ return 0
158
+
159
+
160
+ COMMANDS: dict[str, Callable[[Daemon, argparse.Namespace], int]] = {
161
+ "list": list_apps,
162
+ "add": add,
163
+ "deploy": deploy,
164
+ "open": open_app,
165
+ "log": log,
166
+ "secret": secret,
167
+ "shortcut": shortcut,
168
+ "rm": remove,
169
+ "rollback": action,
170
+ "stop": action,
171
+ }
172
+
173
+
174
+ def dispatch(args: argparse.Namespace) -> int:
175
+ return COMMANDS[args.app_cmd](Daemon(), args)
@@ -0,0 +1,85 @@
1
+ """Opening a mini app in its own window (docs/dev/design/miniapps.md §5.1).
2
+
3
+ Firefox first (a dedicated profile, `handler-apps`: apps' cookies and storage stay out of normal
4
+ browsing, and its toolbars are hidden, since Firefox has no `--app` mode), then Chromium's app
5
+ mode, then the default browser. `HANDLER_APP_BROWSER` (firefox · chromium · default) forces one.
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import webbrowser
12
+ from pathlib import Path
13
+
14
+ # the window's app id: GNOME groups app windows apart from the browser
15
+ WINDOW_CLASS = "handler-apps"
16
+
17
+ USER_JS = """\
18
+ // handler: the profile mini apps open in (handler app open). Rewritten by handler.
19
+ user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
20
+ user_pref("browser.shell.checkDefaultBrowser", false);
21
+ user_pref("browser.startup.homepage_override.mstone", "ignore");
22
+ user_pref("browser.aboutwelcome.enabled", false);
23
+ user_pref("browser.startup.page", 0);
24
+ user_pref("browser.sessionstore.resume_from_crash", false);
25
+ user_pref("browser.tabs.warnOnClose", false);
26
+ user_pref("datareporting.policy.dataSubmissionEnabled", false);
27
+ user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
28
+ """
29
+
30
+ USER_CHROME = """\
31
+ /* handler: an app window, not a browser window. Rewritten by handler. */
32
+ #TabsToolbar, #nav-bar, #PersonalToolbar, #titlebar { visibility: collapse !important; }
33
+ """
34
+
35
+ CHROMIUMS = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable")
36
+
37
+
38
+ def profiles_dir() -> Path:
39
+ xdg = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
40
+ return Path(os.environ.get("HANDLER_DATA_DIR", xdg / "handler-ai"))
41
+
42
+
43
+ def firefox_profile(root: Path) -> Path:
44
+ """The `handler-apps` Firefox profile, created or refreshed (only handler's two files)."""
45
+ profile = root / "firefox-apps"
46
+ (profile / "chrome").mkdir(parents=True, exist_ok=True)
47
+ (profile / "user.js").write_text(USER_JS)
48
+ (profile / "chrome" / "userChrome.css").write_text(USER_CHROME)
49
+ return profile
50
+
51
+
52
+ def argv(url: str, browser: str, root: Path) -> list[str] | None:
53
+ """The command opening `url` in an app window; None: the default browser."""
54
+ if browser == "firefox" and (firefox := shutil.which("firefox")):
55
+ profile = firefox_profile(root)
56
+ return [firefox, "--profile", str(profile), "--name", WINDOW_CLASS, "--new-window", url]
57
+ if browser == "chromium" and (
58
+ chromium := next(filter(None, map(shutil.which, CHROMIUMS)), None)
59
+ ):
60
+ data = root / "chromium-apps"
61
+ return [chromium, f"--user-data-dir={data}", f"--class={WINDOW_CLASS}", f"--app={url}"]
62
+ return None
63
+
64
+
65
+ def choose() -> str:
66
+ forced = os.environ.get("HANDLER_APP_BROWSER")
67
+ if forced:
68
+ return forced
69
+ if shutil.which("firefox"):
70
+ return "firefox"
71
+ return "chromium" if any(shutil.which(c) for c in CHROMIUMS) else "default"
72
+
73
+
74
+ def open_window(url: str) -> None:
75
+ command = argv(url, choose(), profiles_dir())
76
+ if command is None:
77
+ webbrowser.open(url)
78
+ return
79
+ subprocess.Popen(
80
+ command,
81
+ stdin=subprocess.DEVNULL,
82
+ stdout=subprocess.DEVNULL,
83
+ stderr=subprocess.DEVNULL,
84
+ start_new_session=True, # outlives `handler` (a keyboard shortcut runs it and returns)
85
+ )