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.
- handler_ai-0.1.0.dist-info/METADATA +60 -0
- handler_ai-0.1.0.dist-info/RECORD +234 -0
- handler_ai-0.1.0.dist-info/WHEEL +4 -0
- handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
- handler_cli/__init__.py +1 -0
- handler_cli/account_commands.py +314 -0
- handler_cli/addon_commands.py +128 -0
- handler_cli/agent_commands.py +139 -0
- handler_cli/app_commands.py +175 -0
- handler_cli/app_window.py +85 -0
- handler_cli/automation_commands.py +309 -0
- handler_cli/bridge.py +42 -0
- handler_cli/cli.py +326 -0
- handler_cli/client.py +81 -0
- handler_cli/daemon_commands.py +108 -0
- handler_cli/image_commands.py +102 -0
- handler_cli/inbox_commands.py +33 -0
- handler_cli/output.py +12 -0
- handler_cli/project_commands.py +63 -0
- handler_cli/provider_commands.py +113 -0
- handler_cli/secret_commands.py +64 -0
- handler_cli/session_bridge.py +93 -0
- handler_cli/session_commands.py +485 -0
- handler_cli/setup.py +120 -0
- handler_cli/terminal.py +280 -0
- handler_cli/terminal_client.py +37 -0
- handler_contracts/__init__.py +642 -0
- handler_contracts/account.py +36 -0
- handler_contracts/acp_endpoint.py +69 -0
- handler_contracts/addon.py +147 -0
- handler_contracts/agent_spec.py +75 -0
- handler_contracts/automation.py +285 -0
- handler_contracts/common.py +229 -0
- handler_contracts/control.py +1208 -0
- handler_contracts/harness.py +300 -0
- handler_contracts/miniapp.py +141 -0
- handler_contracts/provider.py +82 -0
- handler_contracts/remote.py +167 -0
- handler_contracts/sandbox.py +199 -0
- handler_contracts/schemas.py +313 -0
- handler_contracts/terminal.py +32 -0
- handler_control/__init__.py +7 -0
- handler_control/access.py +62 -0
- handler_control/accounts.py +279 -0
- handler_control/addon_install.py +268 -0
- handler_control/addons.py +118 -0
- handler_control/agent_state.py +46 -0
- handler_control/agents.py +257 -0
- handler_control/api/__init__.py +137 -0
- handler_control/api/accounts.py +231 -0
- handler_control/api/acp.py +116 -0
- handler_control/api/addons.py +95 -0
- handler_control/api/agents.py +150 -0
- handler_control/api/apps.py +104 -0
- handler_control/api/automations.py +128 -0
- handler_control/api/bindings.py +105 -0
- handler_control/api/builtin_agents.py +22 -0
- handler_control/api/caches.py +79 -0
- handler_control/api/catalog.py +197 -0
- handler_control/api/common.py +205 -0
- handler_control/api/contexts.py +88 -0
- handler_control/api/folders.py +51 -0
- handler_control/api/images.py +164 -0
- handler_control/api/inbox.py +21 -0
- handler_control/api/projects.py +170 -0
- handler_control/api/providers.py +107 -0
- handler_control/api/remote.py +45 -0
- handler_control/api/secrets.py +177 -0
- handler_control/api/sessions.py +192 -0
- handler_control/api/skills.py +270 -0
- handler_control/app_api.py +84 -0
- handler_control/app_http.py +214 -0
- handler_control/app_remote.py +206 -0
- handler_control/apps.py +867 -0
- handler_control/auth.py +48 -0
- handler_control/automation_config.py +255 -0
- handler_control/automations.py +1134 -0
- handler_control/bindings.py +149 -0
- handler_control/builds.py +341 -0
- handler_control/builtin_agents.py +82 -0
- handler_control/caches.py +113 -0
- handler_control/checks.py +287 -0
- handler_control/compose.py +317 -0
- handler_control/config_files.py +41 -0
- handler_control/contexts.py +75 -0
- handler_control/daemon.py +64 -0
- handler_control/data/automation/bin/handler-llm +85 -0
- handler_control/data/automations/env-issues/automation.toml +17 -0
- handler_control/data/automations/env-issues/extract.py +73 -0
- handler_control/data/automations/env-issues/findings.schema.json +28 -0
- handler_control/data/automations/env-issues/prompt.md +19 -0
- handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
- handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
- handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
- handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
- handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
- handler_control/data/builtin-agents/skill-review/interface.json +83 -0
- handler_control/data/interfaces/llm/v1/interface.toml +17 -0
- handler_control/data/interfaces/llm/v1/tools.json +138 -0
- handler_control/data/templates/README.md +10 -0
- handler_control/data/templates/providers/anthropic-api.toml +18 -0
- handler_control/data/templates/providers/llamacpp.toml +210 -0
- handler_control/data/templates/providers/mistral-api.toml +14 -0
- handler_control/data/templates/providers/ollama.toml +10 -0
- handler_control/data/templates/providers/openai-api.toml +13 -0
- handler_control/data/templates/providers/openrouter.toml +10 -0
- handler_control/desktop.py +68 -0
- handler_control/display.py +62 -0
- handler_control/files.py +23 -0
- handler_control/folders.py +68 -0
- handler_control/git_read.py +39 -0
- handler_control/image_check.py +322 -0
- handler_control/image_updates.py +174 -0
- handler_control/inbox.py +70 -0
- handler_control/interfaces.py +151 -0
- handler_control/knowledge.py +237 -0
- handler_control/launcher.py +356 -0
- handler_control/llm.py +194 -0
- handler_control/migrations/__init__.py +62 -0
- handler_control/policy.py +305 -0
- handler_control/projects.py +266 -0
- handler_control/providers.py +180 -0
- handler_control/relay.py +133 -0
- handler_control/remote.py +888 -0
- handler_control/remote_crypto.py +97 -0
- handler_control/runtime.py +140 -0
- handler_control/sandbox_process.py +224 -0
- handler_control/schedules.py +58 -0
- handler_control/secrets.py +242 -0
- handler_control/service_router.py +180 -0
- handler_control/services.py +92 -0
- handler_control/session_files.py +429 -0
- handler_control/sessions.py +1116 -0
- handler_control/settings.py +129 -0
- handler_control/shortcuts.py +80 -0
- handler_control/skill_scan.py +88 -0
- handler_control/skill_sources.py +61 -0
- handler_control/skills.py +378 -0
- handler_control/terminal.py +79 -0
- handler_control/terminals.py +108 -0
- handler_control/transcript.py +174 -0
- handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
- handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
- handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- handler_control/web-ui/favicon.svg +1 -0
- handler_control/web-ui/index.html +15 -0
- handler_control/webpush.py +90 -0
- handler_control/webui.py +105 -0
- handler_harness/__init__.py +13 -0
- handler_harness/addons.py +129 -0
- handler_harness/catalog.py +116 -0
- handler_harness/cli.py +109 -0
- handler_harness/data/addons/README.md +44 -0
- handler_harness/data/addons/miniapp/Containerfile +20 -0
- handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
- handler_harness/data/addons/miniapp/addon.toml +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
- handler_harness/data/catalog/README.md +12 -0
- handler_harness/data/catalog/playwright/Containerfile +28 -0
- handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
- handler_harness/data/catalog/playwright/addon.toml +25 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
- handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
- handler_harness/data/catalog/shopify/Containerfile +22 -0
- handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
- handler_harness/data/catalog/shopify/addon.toml +39 -0
- handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
- handler_harness/data/handler-sandbox-env +30 -0
- handler_harness/data/harnesses/README.md +47 -0
- handler_harness/data/harnesses/claude-code/Containerfile +27 -0
- handler_harness/data/harnesses/claude-code/harness.toml +95 -0
- handler_harness/data/harnesses/codex/Containerfile +26 -0
- handler_harness/data/harnesses/codex/harness.toml +103 -0
- handler_harness/data/harnesses/pi/Containerfile +27 -0
- handler_harness/data/harnesses/pi/harness.toml +65 -0
- handler_harness/data/harnesses/vibe/Containerfile +19 -0
- handler_harness/data/harnesses/vibe/harness.toml +96 -0
- handler_harness/data/toolbox/Containerfile +56 -0
- handler_harness/data/toolbox/README.md +24 -0
- handler_harness/data/toolbox/toolbox.toml +41 -0
- handler_harness/images.py +62 -0
- handler_harness/providers.py +300 -0
- handler_harness/render.py +472 -0
- handler_sandbox_podman/__init__.py +1 -0
- handler_sandbox_podman/caches.py +97 -0
- handler_sandbox_podman/check.py +121 -0
- handler_sandbox_podman/cli.py +239 -0
- handler_sandbox_podman/egress.py +118 -0
- handler_sandbox_podman/images.py +199 -0
- handler_sandbox_podman/launch.py +474 -0
- handler_sandbox_podman/podman.py +108 -0
- handler_sandbox_podman/proxy/Containerfile +6 -0
- handler_sandbox_podman/proxy/addon.py +255 -0
handler_cli/client.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Talking to handlerd: the Control API (contract D) over HTTP, ACP (contract C) over WebSocket.
|
|
2
|
+
|
|
3
|
+
Discovery: `$HANDLER_URL` (default http://127.0.0.1:<DEFAULT_PORT>) and the token file
|
|
4
|
+
`$HANDLER_CONFIG_DIR/token` (default ~/.config/handler-ai/token), written by the daemon on first
|
|
5
|
+
start.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from handler_contracts import DEFAULT_PORT, TOKEN_FILE_NAME
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClientError(RuntimeError):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
status: int | None = None,
|
|
22
|
+
title: str | None = None,
|
|
23
|
+
detail: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status, self.title, self.detail = status, title, detail # handlerd's answer, if any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def base_url() -> str:
|
|
30
|
+
port = os.environ.get("HANDLER_PORT", str(DEFAULT_PORT))
|
|
31
|
+
return os.environ.get("HANDLER_URL", f"http://127.0.0.1:{port}").rstrip("/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def token_file() -> Path:
|
|
35
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
36
|
+
return Path(os.environ.get("HANDLER_CONFIG_DIR", xdg / "handler-ai")) / TOKEN_FILE_NAME
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_token() -> str:
|
|
40
|
+
try:
|
|
41
|
+
return token_file().read_text().strip()
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
raise ClientError(
|
|
44
|
+
f"no token at {token_file()}: is handlerd installed and started?"
|
|
45
|
+
) from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Daemon:
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self.url = base_url()
|
|
51
|
+
self.token = read_token()
|
|
52
|
+
self.http = httpx.Client(
|
|
53
|
+
base_url=self.url, headers={"Authorization": f"Bearer {self.token}"}, timeout=60
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def call(self, method: str, path: str, json: Any = None, timeout: float | None = None) -> Any:
|
|
57
|
+
r = self.request(method, path, json, timeout)
|
|
58
|
+
return r.json() if r.content else None
|
|
59
|
+
|
|
60
|
+
def text(self, path: str) -> str:
|
|
61
|
+
"""A GET answering plain text (a log, a file)."""
|
|
62
|
+
return self.request("GET", path).text
|
|
63
|
+
|
|
64
|
+
def request(
|
|
65
|
+
self, method: str, path: str, json: Any = None, timeout: float | None = None
|
|
66
|
+
) -> httpx.Response:
|
|
67
|
+
try:
|
|
68
|
+
r = self.http.request(method, path, json=json, timeout=timeout or self.http.timeout)
|
|
69
|
+
except httpx.ConnectError:
|
|
70
|
+
raise ClientError(
|
|
71
|
+
f"handlerd is not reachable at {self.url} (handler daemon status)"
|
|
72
|
+
) from None
|
|
73
|
+
if r.status_code >= 400:
|
|
74
|
+
try:
|
|
75
|
+
problem = r.json()
|
|
76
|
+
except ValueError:
|
|
77
|
+
raise ClientError(f"{r.status_code} {r.text}", r.status_code) from None
|
|
78
|
+
title, detail = problem.get("title"), problem.get("detail")
|
|
79
|
+
message = f"{r.status_code} {title}: {detail or ''}".rstrip(": ")
|
|
80
|
+
raise ClientError(message, r.status_code, title, detail)
|
|
81
|
+
return r
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`handler daemon …` — handlerd, the daemon that runs your agents, as a systemd user service
|
|
2
|
+
(started with your session). `handler setup` installs it.
|
|
3
|
+
|
|
4
|
+
handler daemon status is it up (its version, its address); if not, the service's
|
|
5
|
+
state
|
|
6
|
+
handler daemon install [--dry-run] write the unit, enable and start it (--dry-run: print it)
|
|
7
|
+
handler daemon uninstall stop and disable it, remove the unit (configuration and state
|
|
8
|
+
are kept)
|
|
9
|
+
handler daemon log [-f] its log (journalctl; -f: follow it)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .client import base_url
|
|
23
|
+
|
|
24
|
+
UNIT_NAME = "handler-ai.service"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def unit_path() -> Path:
|
|
28
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
29
|
+
return xdg / "systemd" / "user" / UNIT_NAME
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def handlerd_path() -> str:
|
|
33
|
+
sibling = Path(sys.executable).parent / "handlerd" # same installation as this CLI
|
|
34
|
+
return str(sibling) if sibling.exists() else (shutil.which("handlerd") or "handlerd")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unit_text() -> str:
|
|
38
|
+
handlerd = handlerd_path()
|
|
39
|
+
bin_dir = str(Path(handlerd).parent)
|
|
40
|
+
return f"""[Unit]
|
|
41
|
+
Description=Handler control plane
|
|
42
|
+
|
|
43
|
+
[Service]
|
|
44
|
+
ExecStart={handlerd}
|
|
45
|
+
# handlerd finds handler-sandbox-* next to itself; podman must be on PATH.
|
|
46
|
+
Environment=PATH={bin_dir}:/usr/local/bin:/usr/bin:/bin
|
|
47
|
+
Restart=on-failure
|
|
48
|
+
|
|
49
|
+
[Install]
|
|
50
|
+
WantedBy=default.target
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _systemctl(*args: str) -> int:
|
|
55
|
+
return subprocess.run(["systemctl", "--user", *args], check=False).returncode
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def install(dry_run: bool) -> int:
|
|
59
|
+
if dry_run:
|
|
60
|
+
print(f"# {unit_path()}\n{unit_text()}", end="")
|
|
61
|
+
return 0
|
|
62
|
+
path = unit_path()
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
path.write_text(unit_text())
|
|
65
|
+
print(f"wrote {path}")
|
|
66
|
+
return _systemctl("daemon-reload") or _systemctl("enable", "--now", UNIT_NAME)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def uninstall() -> int:
|
|
70
|
+
_systemctl("disable", "--now", UNIT_NAME)
|
|
71
|
+
unit_path().unlink(missing_ok=True)
|
|
72
|
+
return _systemctl("daemon-reload")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def status() -> int:
|
|
76
|
+
try:
|
|
77
|
+
health = httpx.get(f"{base_url()}/v1/health", timeout=5).json()
|
|
78
|
+
except httpx.HTTPError:
|
|
79
|
+
print(f"handlerd is not reachable at {base_url()}")
|
|
80
|
+
_systemctl("status", "--no-pager", UNIT_NAME)
|
|
81
|
+
return 1
|
|
82
|
+
print(f"handlerd {health['version']} up at {base_url()}")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def log(follow: bool) -> int:
|
|
87
|
+
command = ["journalctl", "--user", "-u", UNIT_NAME, "--no-pager", "-n", "200"]
|
|
88
|
+
return subprocess.run([*command, *(["-f"] if follow else [])], check=False).returncode
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def add_parser(sub: Any) -> None:
|
|
92
|
+
daemon = sub.add_parser("daemon", help="handlerd as a user service", description=__doc__,
|
|
93
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
94
|
+
a = daemon.add_subparsers(dest="daemon_cmd", required=True)
|
|
95
|
+
a.add_parser("status")
|
|
96
|
+
a.add_parser("install").add_argument("--dry-run", action="store_true")
|
|
97
|
+
a.add_parser("uninstall")
|
|
98
|
+
a.add_parser("log").add_argument("-f", "--follow", action="store_true")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
102
|
+
if args.daemon_cmd == "install":
|
|
103
|
+
return install(args.dry_run)
|
|
104
|
+
if args.daemon_cmd == "uninstall":
|
|
105
|
+
return uninstall()
|
|
106
|
+
if args.daemon_cmd == "log":
|
|
107
|
+
return log(args.follow)
|
|
108
|
+
return status()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""`handler image …` — the images agents run in: the toolbox (the base every agent gets), one per
|
|
2
|
+
harness and per addon on top of it, and the network filter. Built locally, through handlerd (D.4,
|
|
3
|
+
G.4), like the web UI.
|
|
4
|
+
|
|
5
|
+
handler image list every image: built or not, the harness's version, when built
|
|
6
|
+
handler image rebuild NAME rebuild a harness image from the files on disk (the
|
|
7
|
+
harness's latest release), check it, then switch agents to
|
|
8
|
+
it; the log streams to the terminal
|
|
9
|
+
handler image rebuild addon:NAME same for an addon's image (also its first build)
|
|
10
|
+
handler image rebuild toolbox same for the toolbox (system updates), then every harness and
|
|
11
|
+
addon on top of it: all switch, or none
|
|
12
|
+
handler image rebuild network-filter
|
|
13
|
+
the network filter: what checks where agents connect and adds
|
|
14
|
+
your secrets to their requests (handlerd builds it when it is
|
|
15
|
+
missing)
|
|
16
|
+
|
|
17
|
+
Ctrl-C during a rebuild cancels it: nothing switches. Running sessions keep the image they started
|
|
18
|
+
on.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .client import Daemon
|
|
27
|
+
from .output import table
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_parser(sub: Any) -> None:
|
|
31
|
+
image = sub.add_parser("image", help="images", description=__doc__,
|
|
32
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
33
|
+
i = image.add_subparsers(dest="image_cmd", required=True)
|
|
34
|
+
i.add_parser("list")
|
|
35
|
+
i.add_parser("rebuild").add_argument(
|
|
36
|
+
"target", help="a harness name, 'toolbox', 'network-filter', or 'addon:NAME'"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
41
|
+
d = Daemon()
|
|
42
|
+
if args.image_cmd == "list":
|
|
43
|
+
print_images(d)
|
|
44
|
+
return 0
|
|
45
|
+
return rebuild(d, args.target)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_images(d: Daemon) -> None:
|
|
49
|
+
rows = [["NAME", "IMAGE", "VERSION", "BUILT"]]
|
|
50
|
+
|
|
51
|
+
def row(name: str, info: dict[str, Any], version: str = "-") -> None:
|
|
52
|
+
built = (info.get("builtAt") or "-")[:16].replace("T", " ")
|
|
53
|
+
rows.append([name, info["image"], version if info["imagePresent"] else "not built", built])
|
|
54
|
+
|
|
55
|
+
row("toolbox", d.call("GET", "/v1/toolbox"))
|
|
56
|
+
for h in d.call("GET", "/v1/harnesses"):
|
|
57
|
+
row(h["name"], h, h.get("version", "-"))
|
|
58
|
+
for a in d.call("GET", "/v1/addons"):
|
|
59
|
+
if a.get("image"):
|
|
60
|
+
row(f"addon:{a['name']}", a, a.get("version", "-"))
|
|
61
|
+
row("network-filter", d.call("GET", "/v1/network-filter"))
|
|
62
|
+
table(rows)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def path(name: str) -> str:
|
|
66
|
+
"""API path of a harness or `addon:NAME`."""
|
|
67
|
+
if name.startswith("addon:"):
|
|
68
|
+
return f"addons/{name.removeprefix('addon:')}"
|
|
69
|
+
return f"harnesses/{name}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def rebuild(d: Daemon, target: str) -> int:
|
|
73
|
+
own = target in ("toolbox", "network-filter")
|
|
74
|
+
return watch(d, d.call("POST", f"/v1/{target if own else path(target)}/rebuild"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def watch(d: Daemon, build: Any) -> int:
|
|
78
|
+
"""Stream a started build's log to its end (Ctrl-C cancels it); 0 when it succeeded."""
|
|
79
|
+
try:
|
|
80
|
+
build = follow(d, build["id"])
|
|
81
|
+
except KeyboardInterrupt:
|
|
82
|
+
print("\ncancelling…", file=sys.stderr)
|
|
83
|
+
build = d.call("POST", f"/v1/builds/{build['id']}/cancel")
|
|
84
|
+
for name, version in build.get("versions", {}).items():
|
|
85
|
+
print(f"{name}: {version}")
|
|
86
|
+
error = f" ({build['error']})" if build.get("error") else ""
|
|
87
|
+
print(f"build {build['id']}: {build['state']}{error}")
|
|
88
|
+
return 0 if build["state"] == "succeeded" else 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def follow(d: Daemon, build_id: str) -> Any:
|
|
92
|
+
"""Print the log as it grows; return the build once it has ended."""
|
|
93
|
+
offset = 0
|
|
94
|
+
while True:
|
|
95
|
+
chunk = d.call("GET", f"/v1/builds/{build_id}/log?offset={offset}")
|
|
96
|
+
sys.stdout.write(chunk["text"])
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
offset = chunk["nextOffset"]
|
|
99
|
+
if chunk["complete"]:
|
|
100
|
+
return d.call("GET", f"/v1/builds/{build_id}")
|
|
101
|
+
if not chunk["text"]:
|
|
102
|
+
time.sleep(1)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""`handler inbox` — what handler told you: your apps' notifications, your automations' results and
|
|
2
|
+
failures, newest first (the web UI's Inbox)."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .automation_commands import when_at
|
|
8
|
+
from .client import Daemon
|
|
9
|
+
from .output import one_line, table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(sub: Any) -> None:
|
|
13
|
+
sub.add_parser("inbox", help="what handler told you", description=__doc__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dispatch(_: argparse.Namespace) -> int:
|
|
17
|
+
messages = Daemon().call("GET", "/v1/inbox")
|
|
18
|
+
if not messages:
|
|
19
|
+
print("nothing yet")
|
|
20
|
+
return 0
|
|
21
|
+
rows = [["AT", "FROM", "STATUS", "TITLE", "SUMMARY"]]
|
|
22
|
+
for m in messages:
|
|
23
|
+
source = f"{m['source']} {m['name']}"
|
|
24
|
+
unread = "" if m.get("read") else "• "
|
|
25
|
+
rows.append([
|
|
26
|
+
unread + when_at(m["at"]),
|
|
27
|
+
source,
|
|
28
|
+
m.get("status") or "-",
|
|
29
|
+
one_line(m["title"], 40),
|
|
30
|
+
one_line(m.get("summary") or "", 70),
|
|
31
|
+
]) # fmt: skip
|
|
32
|
+
table(rows)
|
|
33
|
+
return 0
|
handler_cli/output.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Plain-text output shared by the commands."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def one_line(text: str, width: int) -> str:
|
|
5
|
+
flat = " ".join(text.split())
|
|
6
|
+
return flat if len(flat) <= width else flat[: width - 1] + "…"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def table(rows: list[list[str]]) -> None:
|
|
10
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
|
|
11
|
+
for r in rows:
|
|
12
|
+
print(" ".join(cell.ljust(w) for cell, w in zip(r, widths, strict=True)).rstrip())
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`handler project …` — projects: a folder and its agents (contract D.8), created and edited in
|
|
2
|
+
the web UI. In a project's folder, `handler run`, `terminal` and `acp` use its agents.
|
|
3
|
+
|
|
4
|
+
handler project list projects, most recently used first: context, agents, folder
|
|
5
|
+
handler project show NAME its agents (`*`: the default one), what each runs on and its
|
|
6
|
+
settings
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .agent_commands import describe_agent, runs
|
|
13
|
+
from .client import ClientError, Daemon
|
|
14
|
+
from .output import table
|
|
15
|
+
from .session_commands import agent_label
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_parser(sub: Any) -> None:
|
|
19
|
+
project = sub.add_parser("project", help="projects", description=__doc__,
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
21
|
+
p = project.add_subparsers(dest="project_cmd", required=True)
|
|
22
|
+
p.add_parser("list")
|
|
23
|
+
p.add_parser("show").add_argument("name")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
27
|
+
d = Daemon()
|
|
28
|
+
projects = d.call("GET", "/v1/projects")
|
|
29
|
+
if args.project_cmd == "list":
|
|
30
|
+
rows = [["PROJECT", "CONTEXT", "AGENTS", "FOLDER"]]
|
|
31
|
+
for p in projects:
|
|
32
|
+
agents = ", ".join(agent_label(a) for a in p["agents"])
|
|
33
|
+
rows.append([p["name"], p.get("context") or "-", agents, p["folder"]])
|
|
34
|
+
table(rows) if len(rows) > 1 else print("no project: create one in the web UI (handler ui)")
|
|
35
|
+
return 0
|
|
36
|
+
wanted = args.name.lower()
|
|
37
|
+
found = next((p for p in projects if wanted in (p["name"].lower(), p["id"])), None)
|
|
38
|
+
if found is None:
|
|
39
|
+
names = ", ".join(p["name"] for p in projects) or "none"
|
|
40
|
+
raise ClientError(f"no project '{args.name}': {names}")
|
|
41
|
+
show(found)
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def show(p: dict[str, Any]) -> None:
|
|
46
|
+
context = f" · {p['context']}" if p.get("context") else ""
|
|
47
|
+
print(f"{p['name']}{context} · {p['folder']}")
|
|
48
|
+
if p.get("description"):
|
|
49
|
+
print(f" {p['description']}")
|
|
50
|
+
rows = [["AGENT", "ID", "RUNS", "SETTINGS", "LIVE"]]
|
|
51
|
+
for a in p["agents"]:
|
|
52
|
+
default = " *" if a["id"] == p.get("defaultAgent") else ""
|
|
53
|
+
rows.append(
|
|
54
|
+
[
|
|
55
|
+
f"{agent_label(a)}{default}",
|
|
56
|
+
a["id"],
|
|
57
|
+
runs(a),
|
|
58
|
+
describe_agent(a, runs_too=False),
|
|
59
|
+
str(len(a["connections"])),
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
table(rows)
|
|
63
|
+
print("* the project's default agent")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""`handler provider …` — providers (contract D.12): where model calls go when no subscription
|
|
2
|
+
does, an API and its key (Anthropic, OpenAI, Mistral, OpenRouter) or a model server on this
|
|
3
|
+
computer (llama.cpp, Ollama). A provider starts from a template; its key goes to handler's secret
|
|
4
|
+
store, never shown again.
|
|
5
|
+
|
|
6
|
+
handler provider list the providers: endpoints, models, key, contexts, agents
|
|
7
|
+
handler provider templates what a provider can start from
|
|
8
|
+
handler provider add NAME --from TEMPLATE [--key] [--endpoint PROTOCOL=URL …] [--model ID …]
|
|
9
|
+
[--context CONTEXT …] [--description TEXT]
|
|
10
|
+
a provider from a template. --key: ask for its key (hidden,
|
|
11
|
+
or read from stdin). --endpoint: another URL for a protocol
|
|
12
|
+
(e.g. a server on another port). --model: the models it offers,
|
|
13
|
+
instead of the template's. --context: the project contexts it
|
|
14
|
+
is made for (Work, Personal…, or any; none: it runs nowhere a
|
|
15
|
+
project has a context)
|
|
16
|
+
handler provider rm NAME it and its key (agents using it cannot start until given
|
|
17
|
+
another)
|
|
18
|
+
|
|
19
|
+
Models' details and per-harness files are edited in the web UI (Settings → Model access) or in
|
|
20
|
+
~/.config/handler-ai/providers/NAME.toml.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .account_commands import ask_token
|
|
27
|
+
from .client import ClientError, Daemon
|
|
28
|
+
from .output import table
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_parser(sub: Any) -> None:
|
|
32
|
+
provider = sub.add_parser("provider", help="providers", description=__doc__,
|
|
33
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
34
|
+
p = provider.add_subparsers(dest="provider_cmd", required=True)
|
|
35
|
+
p.add_parser("list")
|
|
36
|
+
p.add_parser("templates")
|
|
37
|
+
add = p.add_parser("add")
|
|
38
|
+
add.add_argument("name")
|
|
39
|
+
add.add_argument("--from", dest="template", required=True, metavar="TEMPLATE")
|
|
40
|
+
add.add_argument("--key", action="store_true", help="ask for its key")
|
|
41
|
+
add.add_argument("--endpoint", action="append", metavar="PROTOCOL=URL", default=[])
|
|
42
|
+
add.add_argument("--model", action="append", metavar="ID", default=[])
|
|
43
|
+
add.add_argument("--context", action="append", metavar="CONTEXT", default=[])
|
|
44
|
+
add.add_argument("--description")
|
|
45
|
+
p.add_parser("rm").add_argument("name")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
49
|
+
d = Daemon()
|
|
50
|
+
if args.provider_cmd == "list":
|
|
51
|
+
rows = [["NAME", "STATUS", "ENDPOINTS", "MODELS", "KEY", "CONTEXTS", "AGENTS"]]
|
|
52
|
+
for p in d.call("GET", "/v1/providers"):
|
|
53
|
+
status = p["status"] if p["status"] == "ready" else "; ".join(
|
|
54
|
+
p.get("problems") or [p.get("error") or p["status"]]) # fmt: skip
|
|
55
|
+
rows.append(
|
|
56
|
+
[
|
|
57
|
+
p["name"],
|
|
58
|
+
status,
|
|
59
|
+
", ".join(p.get("endpoints", {})),
|
|
60
|
+
", ".join(m["id"] for m in p.get("models", [])),
|
|
61
|
+
p.get("key") or "-",
|
|
62
|
+
", ".join(p.get("contexts", [])) or "-",
|
|
63
|
+
", ".join(p.get("agents", [])) or "-",
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
table(rows) if len(rows) > 1 else print("no provider: handler provider templates")
|
|
67
|
+
elif args.provider_cmd == "templates":
|
|
68
|
+
rows = [["TEMPLATE", "KEY", "ENDPOINTS", "DESCRIPTION"]]
|
|
69
|
+
for t in d.call("GET", "/v1/provider-templates"):
|
|
70
|
+
needs = "needed" if t.get("needsKey") else "-"
|
|
71
|
+
endpoints = ", ".join(f"{k} {v}" for k, v in t["endpoints"].items())
|
|
72
|
+
rows.append([t["name"], needs, endpoints, t.get("description") or ""])
|
|
73
|
+
table(rows)
|
|
74
|
+
elif args.provider_cmd == "add":
|
|
75
|
+
made = d.call("POST", "/v1/providers", request(d, args))
|
|
76
|
+
print(f"{made['name']}: created ({made['status']})")
|
|
77
|
+
else:
|
|
78
|
+
d.call("DELETE", f"/v1/providers/{args.name}")
|
|
79
|
+
print(f"{args.name}: removed")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def request(d: Daemon, args: argparse.Namespace) -> dict[str, Any]:
|
|
84
|
+
"""`POST /v1/providers`'s body: the template, changed by the flags typed (as the web UI's form
|
|
85
|
+
starting from it)."""
|
|
86
|
+
templates = d.call("GET", "/v1/provider-templates")
|
|
87
|
+
template = next((t for t in templates if t["name"] == args.template), None)
|
|
88
|
+
if template is None:
|
|
89
|
+
names = ", ".join(t["name"] for t in templates)
|
|
90
|
+
raise ClientError(f"no template '{args.template}': {names}")
|
|
91
|
+
endpoints = dict(template["endpoints"])
|
|
92
|
+
for given in args.endpoint:
|
|
93
|
+
protocol, _, url = given.partition("=")
|
|
94
|
+
if not url:
|
|
95
|
+
raise ClientError(f"--endpoint {given}: PROTOCOL=URL, e.g. openai-chat=http://…")
|
|
96
|
+
endpoints[protocol] = url
|
|
97
|
+
models = template["models"]
|
|
98
|
+
if args.model:
|
|
99
|
+
known = {m["id"]: m for m in models}
|
|
100
|
+
models = [known.get(model, {"id": model}) for model in args.model]
|
|
101
|
+
body: dict[str, Any] = {
|
|
102
|
+
"name": args.name,
|
|
103
|
+
"description": args.description or template.get("description"),
|
|
104
|
+
"endpoints": endpoints,
|
|
105
|
+
"models": models,
|
|
106
|
+
"contexts": args.context,
|
|
107
|
+
"files": template.get("files"),
|
|
108
|
+
}
|
|
109
|
+
if args.key:
|
|
110
|
+
body["key"] = ask_token(args.name, "key")
|
|
111
|
+
elif template.get("needsKey"):
|
|
112
|
+
raise ClientError(f"{args.template} needs a key: add --key")
|
|
113
|
+
return body
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`handler secret …` — handler's secret store (contract D.14). A value is named by a path after
|
|
2
|
+
what owns it (`providers/openrouter/apiKey`) and referenced from an account, a provider or a binding
|
|
3
|
+
with `{ secret = "<path>" }`. Values are written, never read back. An app's secrets are set with
|
|
4
|
+
`handler app secret`.
|
|
5
|
+
|
|
6
|
+
handler secret list the secrets handler uses (in its store or kept elsewhere):
|
|
7
|
+
where each comes from, what it belongs to, and whether agents
|
|
8
|
+
can read it (else handler adds it to their requests to its
|
|
9
|
+
hosts)
|
|
10
|
+
handler secret set PATH [--description TEXT]
|
|
11
|
+
set a value (asked for, or read from stdin)
|
|
12
|
+
handler secret rm PATH forget PATH and everything under it (refused while referenced)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import getpass
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .client import Daemon
|
|
21
|
+
from .output import table
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def add_parser(sub: Any) -> None:
|
|
25
|
+
secret = sub.add_parser("secret", help="the secret store", description=__doc__,
|
|
26
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
27
|
+
s = secret.add_subparsers(dest="secret_cmd", required=True)
|
|
28
|
+
s.add_parser("list")
|
|
29
|
+
put = s.add_parser("set")
|
|
30
|
+
put.add_argument("path")
|
|
31
|
+
put.add_argument("--description")
|
|
32
|
+
s.add_parser("rm").add_argument("path")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def dispatch(args: argparse.Namespace) -> int:
|
|
36
|
+
d = Daemon()
|
|
37
|
+
if args.secret_cmd == "list":
|
|
38
|
+
rows = [["PATH", "FROM", "USED BY", "AGENTS", "UPDATED"]]
|
|
39
|
+
for s in d.call("GET", "/v1/secrets"):
|
|
40
|
+
source = s["source"] if s["hasValue"] else f"{s['source']} (not set)"
|
|
41
|
+
used = ", ".join(s["usedBy"]) or "nothing"
|
|
42
|
+
rows.append([s["path"], source, used, _given(s), (s.get("updatedAt") or "")[:16]])
|
|
43
|
+
table(rows) if len(rows) > 1 else print("no secret")
|
|
44
|
+
return 0
|
|
45
|
+
if args.secret_cmd == "set":
|
|
46
|
+
prompt = f"{args.path}: "
|
|
47
|
+
value = getpass.getpass(prompt) if sys.stdin.isatty() else sys.stdin.readline()
|
|
48
|
+
body = {"value": value.strip(), "description": args.description}
|
|
49
|
+
s = d.call("PUT", f"/v1/secrets/{args.path}", body)
|
|
50
|
+
print(f"{s['path']}: set (used by {', '.join(s['usedBy']) or 'nothing yet'})")
|
|
51
|
+
return 0
|
|
52
|
+
d.call("DELETE", f"/v1/secrets/{args.path}")
|
|
53
|
+
print(f"{args.path}: removed")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _given(s: dict[str, Any]) -> str:
|
|
58
|
+
"""What agents get (E3): a stand-in handler swaps for the value on its way to its hosts, or the
|
|
59
|
+
value itself."""
|
|
60
|
+
if s.get("visible"):
|
|
61
|
+
return f"can read it: {s['visible']}"
|
|
62
|
+
if s["hosts"]:
|
|
63
|
+
return "never see it, handler adds it for " + ", ".join(s["hosts"])
|
|
64
|
+
return "-"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""`handler attach --stdio` and `handler acp AGENT`: a daemon-held session (F) as a plain stdio ACP
|
|
2
|
+
agent, for editors (Zed) and terminal ACP clients.
|
|
3
|
+
|
|
4
|
+
Such a client starts a conversation with `session/new`; a session already has one. So the bridge
|
|
5
|
+
answers `session/new` with the session's own: it sends `session/load` instead (the daemon replays
|
|
6
|
+
what was said) and adds the `sessionId` to the answer. A `session/load` of another id is pointed at
|
|
7
|
+
the session's. The daemon's `_handler/*` notifications (state for its own clients) are not passed
|
|
8
|
+
on. Everything else goes through unchanged, and the policy (C.4) applies in the daemon as usual.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from websockets.exceptions import ConnectionClosed
|
|
17
|
+
from websockets.sync.client import ClientConnection, connect
|
|
18
|
+
from websockets.typing import Subprotocol
|
|
19
|
+
|
|
20
|
+
from handler_contracts import ACP_SUBPROTOCOL, BEARER_SUBPROTOCOL_PREFIX, AcpCloseCode
|
|
21
|
+
|
|
22
|
+
from .client import Daemon
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SessionBridge:
|
|
26
|
+
def __init__(self, daemon: Daemon, session_id: str) -> None:
|
|
27
|
+
self.daemon = daemon
|
|
28
|
+
self.session_id = session_id
|
|
29
|
+
self.new_ids: set[Any] = set() # the client's session/new requests, answered by a load
|
|
30
|
+
self.lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def acp_session(self) -> dict[str, Any]:
|
|
33
|
+
return self.daemon.call("GET", f"/v1/sessions/{self.session_id}")
|
|
34
|
+
|
|
35
|
+
def to_daemon(self, msg: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
method = msg.get("method")
|
|
37
|
+
if method in ("session/new", "session/load") and "id" in msg:
|
|
38
|
+
info = self.acp_session() # asked after initialize: the agent runs, its id is known
|
|
39
|
+
params = {"sessionId": info["acpSessionId"], "cwd": info["scope"], "mcpServers": []}
|
|
40
|
+
if method == "session/new":
|
|
41
|
+
with self.lock:
|
|
42
|
+
self.new_ids.add(msg["id"])
|
|
43
|
+
return msg | {"method": "session/load", "params": params}
|
|
44
|
+
return msg
|
|
45
|
+
|
|
46
|
+
def to_client(self, msg: dict[str, Any]) -> dict[str, Any] | None:
|
|
47
|
+
method = msg.get("method")
|
|
48
|
+
if isinstance(method, str) and method.startswith("_handler/"):
|
|
49
|
+
return None
|
|
50
|
+
with self.lock:
|
|
51
|
+
answered_new = "method" not in msg and msg.get("id") in self.new_ids
|
|
52
|
+
self.new_ids.discard(msg.get("id"))
|
|
53
|
+
if answered_new and isinstance(msg.get("result"), dict):
|
|
54
|
+
acp_id = self.acp_session()["acpSessionId"]
|
|
55
|
+
return msg | {"result": msg["result"] | {"sessionId": acp_id}}
|
|
56
|
+
return msg
|
|
57
|
+
|
|
58
|
+
def stdin_to_ws(self, ws: ClientConnection) -> None:
|
|
59
|
+
try:
|
|
60
|
+
for line in sys.stdin.buffer:
|
|
61
|
+
if not line.strip():
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
msg = json.loads(line)
|
|
65
|
+
except ValueError:
|
|
66
|
+
continue
|
|
67
|
+
if isinstance(msg, dict):
|
|
68
|
+
ws.send(json.dumps(self.to_daemon(msg)))
|
|
69
|
+
except ConnectionClosed:
|
|
70
|
+
return
|
|
71
|
+
ws.close() # the client closed our stdin: detach (the agent keeps running)
|
|
72
|
+
|
|
73
|
+
def run(self, acp_url: str) -> int:
|
|
74
|
+
token = self.daemon.token
|
|
75
|
+
protocols = [Subprotocol(ACP_SUBPROTOCOL), Subprotocol(BEARER_SUBPROTOCOL_PREFIX + token)]
|
|
76
|
+
with connect(acp_url, subprotocols=protocols, max_size=None) as ws:
|
|
77
|
+
threading.Thread(target=self.stdin_to_ws, args=(ws,), daemon=True).start()
|
|
78
|
+
try:
|
|
79
|
+
for message in ws:
|
|
80
|
+
for line in str(message).splitlines():
|
|
81
|
+
if line.strip() and (out := self.to_client(json.loads(line))) is not None:
|
|
82
|
+
sys.stdout.write(json.dumps(out) + "\n")
|
|
83
|
+
sys.stdout.flush()
|
|
84
|
+
except ConnectionClosed:
|
|
85
|
+
pass
|
|
86
|
+
if ws.close_code in (None, AcpCloseCode.NORMAL):
|
|
87
|
+
return 0
|
|
88
|
+
print(f"handler: session closed ({ws.close_code}) {ws.close_reason}", file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_session_bridge(daemon: Daemon, session: dict[str, Any]) -> int:
|
|
93
|
+
return SessionBridge(daemon, session["id"]).run(session["acpUrl"])
|