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
|
@@ -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
|