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/terminal.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The terminal client of `handler run` and `handler attach`: a daemon-held session
|
|
2
|
+
(F) in the terminal, as one more attachment (F.7) — the web UI and a paired phone can show the same
|
|
3
|
+
session at the same time.
|
|
4
|
+
|
|
5
|
+
Plain ACP over the session's WebSocket: `initialize`, `session/load` (the daemon replays the
|
|
6
|
+
conversation), then prompts typed on stdin. The agent's text streams to stdout; tool calls are one
|
|
7
|
+
line each; a permission request lists its options, the next line answers it (the first answer, from
|
|
8
|
+
any client, wins). Ctrl-C cancels the turn in progress, or detaches when there is none; Ctrl-D and
|
|
9
|
+
`/detach` detach: the agent keeps running (`handler attach` comes back to it). `/stop` stops it.
|
|
10
|
+
|
|
11
|
+
stdin is read by a daemon thread (a blocked read must not keep the process alive after detaching).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import itertools
|
|
17
|
+
import json
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Any, TextIO
|
|
22
|
+
|
|
23
|
+
with contextlib.suppress(ImportError): # not on every platform
|
|
24
|
+
import readline # noqa: F401 — line editing and history for input()
|
|
25
|
+
|
|
26
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
27
|
+
from websockets.exceptions import ConnectionClosed
|
|
28
|
+
from websockets.typing import Subprotocol
|
|
29
|
+
|
|
30
|
+
from handler_contracts import ACP_SUBPROTOCOL, BEARER_SUBPROTOCOL_PREFIX
|
|
31
|
+
|
|
32
|
+
from .client import Daemon
|
|
33
|
+
|
|
34
|
+
Message = dict[str, Any]
|
|
35
|
+
HELP = "Enter: send · Ctrl-C: cancel the turn (or detach) · Ctrl-D, /detach: detach · /stop: stop"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Screen:
|
|
39
|
+
"""What is printed: streamed text, one line per event, colors when stdout is a terminal."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, out: TextIO) -> None:
|
|
42
|
+
self.out = out
|
|
43
|
+
self.color = out.isatty()
|
|
44
|
+
self.midline = False # the last write did not end a line
|
|
45
|
+
self.stream: str | None = None # the kind of chunk being streamed
|
|
46
|
+
|
|
47
|
+
def paint(self, text: str, code: str) -> str:
|
|
48
|
+
return f"\033[{code}m{text}\033[0m" if self.color else text
|
|
49
|
+
|
|
50
|
+
def line(self, text: str) -> None:
|
|
51
|
+
if self.midline:
|
|
52
|
+
self.out.write("\n")
|
|
53
|
+
self.out.write(text + "\n")
|
|
54
|
+
self.out.flush()
|
|
55
|
+
self.midline, self.stream = False, None
|
|
56
|
+
|
|
57
|
+
def chunk(self, kind: str, text: str) -> None:
|
|
58
|
+
if self.stream != kind and self.midline:
|
|
59
|
+
self.out.write("\n")
|
|
60
|
+
if kind == "thought":
|
|
61
|
+
text = self.paint(text, "2")
|
|
62
|
+
self.out.write(text)
|
|
63
|
+
self.out.flush()
|
|
64
|
+
self.midline, self.stream = not text.endswith("\n"), kind
|
|
65
|
+
|
|
66
|
+
def update(self, u: Message) -> None:
|
|
67
|
+
kind = u.get("sessionUpdate")
|
|
68
|
+
content = u.get("content") or {}
|
|
69
|
+
text = content.get("text", "") if isinstance(content, dict) else ""
|
|
70
|
+
if kind == "agent_message_chunk":
|
|
71
|
+
self.chunk("agent", text)
|
|
72
|
+
elif kind == "agent_thought_chunk":
|
|
73
|
+
self.chunk("thought", text)
|
|
74
|
+
elif kind == "user_message_chunk":
|
|
75
|
+
self.line(self.paint("> ", "1;36") + text)
|
|
76
|
+
elif kind == "tool_call":
|
|
77
|
+
self.line(self.paint(f"• {u.get('title') or u.get('kind') or 'tool'}", "33"))
|
|
78
|
+
elif kind == "tool_call_update" and u.get("status") == "failed":
|
|
79
|
+
self.line(self.paint(f" ✗ {u.get('title') or 'failed'}", "31"))
|
|
80
|
+
elif kind == "plan":
|
|
81
|
+
for entry in u.get("entries") or []:
|
|
82
|
+
mark = {"completed": "✓", "in_progress": "…"}.get(entry.get("status"), "·")
|
|
83
|
+
self.line(self.paint(f" {mark} {entry.get('content', '')}", "2"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Terminal:
|
|
87
|
+
def __init__(self, daemon: Daemon, session: Message, out: TextIO = sys.stdout) -> None:
|
|
88
|
+
self.daemon = daemon
|
|
89
|
+
self.session = session
|
|
90
|
+
self.screen = Screen(out)
|
|
91
|
+
self.ids = itertools.count(1)
|
|
92
|
+
self.waiting: dict[int, asyncio.Future[Message]] = {}
|
|
93
|
+
self.permission: Message | None = None # the request the next line answers
|
|
94
|
+
self.busy = False
|
|
95
|
+
self.prompt_id: int | None = None
|
|
96
|
+
self.lines: asyncio.Queue[str | None] = asyncio.Queue()
|
|
97
|
+
self.ws: ClientConnection | None = None
|
|
98
|
+
self.done = asyncio.Event()
|
|
99
|
+
self.result = 0
|
|
100
|
+
self.background: set[asyncio.Task[None]] = set()
|
|
101
|
+
|
|
102
|
+
# ── wire ────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
async def send(self, msg: Message) -> None:
|
|
105
|
+
assert self.ws is not None
|
|
106
|
+
await self.ws.send(json.dumps({"jsonrpc": "2.0"} | msg))
|
|
107
|
+
|
|
108
|
+
async def request(self, method: str, params: Message) -> Message:
|
|
109
|
+
rid = next(self.ids)
|
|
110
|
+
future: asyncio.Future[Message] = asyncio.get_running_loop().create_future()
|
|
111
|
+
self.waiting[rid] = future
|
|
112
|
+
await self.send({"id": rid, "method": method, "params": params})
|
|
113
|
+
response = await future
|
|
114
|
+
if "error" in response:
|
|
115
|
+
raise RuntimeError(f"{method}: {response['error'].get('message')}")
|
|
116
|
+
return response.get("result") or {}
|
|
117
|
+
|
|
118
|
+
async def receive(self) -> None:
|
|
119
|
+
assert self.ws is not None
|
|
120
|
+
try:
|
|
121
|
+
async for raw in self.ws:
|
|
122
|
+
for line in str(raw).splitlines():
|
|
123
|
+
if line.strip():
|
|
124
|
+
self.handle(json.loads(line))
|
|
125
|
+
except ConnectionClosed:
|
|
126
|
+
pass
|
|
127
|
+
reason = self.ws.close_reason or "closed"
|
|
128
|
+
if not self.done.is_set():
|
|
129
|
+
self.screen.line(self.screen.paint(f"[session closed: {reason}]", "31"))
|
|
130
|
+
self.result = 1
|
|
131
|
+
self.done.set()
|
|
132
|
+
|
|
133
|
+
def handle(self, m: Message) -> None:
|
|
134
|
+
method, mid = m.get("method"), m.get("id")
|
|
135
|
+
if method is None and mid in self.waiting:
|
|
136
|
+
self.waiting.pop(mid).set_result(m)
|
|
137
|
+
return
|
|
138
|
+
if method is None and mid == self.prompt_id:
|
|
139
|
+
self.end_turn((m.get("result") or {}).get("stopReason", "error"))
|
|
140
|
+
return
|
|
141
|
+
params = m.get("params") or {}
|
|
142
|
+
if method == "session/update":
|
|
143
|
+
self.screen.update(params.get("update") or {})
|
|
144
|
+
elif method == "session/request_permission" and mid is not None:
|
|
145
|
+
self.ask(m)
|
|
146
|
+
elif method == "_handler/permission_resolved":
|
|
147
|
+
if self.permission and self.permission.get("id") == params.get("requestId"):
|
|
148
|
+
self.permission = None
|
|
149
|
+
self.screen.line(self.screen.paint(" (answered elsewhere)", "2"))
|
|
150
|
+
elif method == "_handler/turn_ended":
|
|
151
|
+
self.end_turn(params.get("stopReason", "end_turn"))
|
|
152
|
+
elif method == "_handler/session_state":
|
|
153
|
+
self.busy = bool(params.get("busy"))
|
|
154
|
+
|
|
155
|
+
def ask(self, request: Message) -> None:
|
|
156
|
+
self.permission = request
|
|
157
|
+
params = request.get("params") or {}
|
|
158
|
+
title = (params.get("toolCall") or {}).get("title") or "the agent asks"
|
|
159
|
+
options = params.get("options") or []
|
|
160
|
+
choices = " ".join(f"{i}) {o.get('name')}" for i, o in enumerate(options, 1))
|
|
161
|
+
self.screen.line(self.screen.paint(f"? {title}", "1;33"))
|
|
162
|
+
self.screen.line(f" {choices} (type a number)")
|
|
163
|
+
|
|
164
|
+
def end_turn(self, stop: str) -> None:
|
|
165
|
+
self.busy, self.prompt_id = False, None
|
|
166
|
+
if stop != "end_turn":
|
|
167
|
+
self.screen.line(self.screen.paint(f"[{stop}]", "2"))
|
|
168
|
+
elif self.screen.midline:
|
|
169
|
+
self.screen.line("")
|
|
170
|
+
|
|
171
|
+
# ── you ─────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
async def typed(self, line: str) -> None:
|
|
174
|
+
text = line.strip()
|
|
175
|
+
if self.permission is not None:
|
|
176
|
+
options = (self.permission.get("params") or {}).get("options") or []
|
|
177
|
+
if text.isdigit() and 1 <= int(text) <= len(options):
|
|
178
|
+
answer = {
|
|
179
|
+
"outcome": {
|
|
180
|
+
"outcome": "selected",
|
|
181
|
+
"optionId": options[int(text) - 1]["optionId"],
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
request, self.permission = self.permission, None
|
|
185
|
+
await self.send({"id": request["id"], "result": answer})
|
|
186
|
+
else:
|
|
187
|
+
self.screen.line(f" type a number from 1 to {len(options)}")
|
|
188
|
+
return
|
|
189
|
+
if not text:
|
|
190
|
+
return
|
|
191
|
+
if text in ("/detach", "/quit", "/exit"):
|
|
192
|
+
self.detach()
|
|
193
|
+
elif text == "/stop":
|
|
194
|
+
self.daemon.call("POST", f"/v1/sessions/{self.session['id']}/stop")
|
|
195
|
+
self.done.set()
|
|
196
|
+
elif text == "/help":
|
|
197
|
+
self.screen.line(HELP)
|
|
198
|
+
elif self.busy:
|
|
199
|
+
self.screen.line(" a turn is in progress (Ctrl-C cancels it)")
|
|
200
|
+
else:
|
|
201
|
+
self.busy = True
|
|
202
|
+
self.prompt_id = rid = next(self.ids)
|
|
203
|
+
params = {
|
|
204
|
+
"sessionId": self.session["acpSessionId"],
|
|
205
|
+
"prompt": [{"type": "text", "text": line}],
|
|
206
|
+
}
|
|
207
|
+
await self.send({"id": rid, "method": "session/prompt", "params": params})
|
|
208
|
+
|
|
209
|
+
def interrupt(self) -> None:
|
|
210
|
+
if self.busy:
|
|
211
|
+
cancel = {
|
|
212
|
+
"method": "session/cancel",
|
|
213
|
+
"params": {"sessionId": self.session["acpSessionId"]},
|
|
214
|
+
}
|
|
215
|
+
task = asyncio.ensure_future(self.send(cancel))
|
|
216
|
+
self.background.add(task)
|
|
217
|
+
task.add_done_callback(self.background.discard)
|
|
218
|
+
self.screen.line(self.screen.paint("[cancelling…]", "2"))
|
|
219
|
+
else:
|
|
220
|
+
self.detach()
|
|
221
|
+
|
|
222
|
+
def detach(self) -> None:
|
|
223
|
+
self.screen.line(
|
|
224
|
+
self.screen.paint(
|
|
225
|
+
f"[detached: the agent keeps running — handler attach {self.session['id']}]", "2"
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
self.done.set()
|
|
229
|
+
|
|
230
|
+
def read_stdin(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
231
|
+
while True:
|
|
232
|
+
try:
|
|
233
|
+
line = input()
|
|
234
|
+
except (EOFError, OSError):
|
|
235
|
+
loop.call_soon_threadsafe(self.lines.put_nowait, None)
|
|
236
|
+
return
|
|
237
|
+
except KeyboardInterrupt:
|
|
238
|
+
continue
|
|
239
|
+
loop.call_soon_threadsafe(self.lines.put_nowait, line)
|
|
240
|
+
|
|
241
|
+
async def keyboard(self) -> None:
|
|
242
|
+
while not self.done.is_set():
|
|
243
|
+
line = await self.lines.get()
|
|
244
|
+
if line is None:
|
|
245
|
+
self.detach()
|
|
246
|
+
return
|
|
247
|
+
await self.typed(line)
|
|
248
|
+
|
|
249
|
+
# ── run ─────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
async def run(self, url: str, token: str) -> int:
|
|
252
|
+
protocols = [Subprotocol(ACP_SUBPROTOCOL), Subprotocol(BEARER_SUBPROTOCOL_PREFIX + token)]
|
|
253
|
+
async with connect(url, subprotocols=protocols, max_size=None) as ws:
|
|
254
|
+
self.ws = ws
|
|
255
|
+
receiver = asyncio.create_task(self.receive())
|
|
256
|
+
await self.request(
|
|
257
|
+
"initialize", {"protocolVersion": 1, "clientCapabilities": {}}
|
|
258
|
+
) # once it runs
|
|
259
|
+
self.session = self.daemon.call("GET", f"/v1/sessions/{self.session['id']}")
|
|
260
|
+
params = {
|
|
261
|
+
"sessionId": self.session["acpSessionId"],
|
|
262
|
+
"cwd": self.session["scope"],
|
|
263
|
+
"mcpServers": [],
|
|
264
|
+
}
|
|
265
|
+
await self.request("session/load", params)
|
|
266
|
+
self.screen.line(self.screen.paint(HELP, "2"))
|
|
267
|
+
loop = asyncio.get_running_loop()
|
|
268
|
+
with contextlib.suppress(NotImplementedError):
|
|
269
|
+
loop.add_signal_handler(signal.SIGINT, self.interrupt)
|
|
270
|
+
threading.Thread(target=self.read_stdin, args=(loop,), daemon=True).start()
|
|
271
|
+
keyboard = asyncio.create_task(self.keyboard())
|
|
272
|
+
await self.done.wait()
|
|
273
|
+
keyboard.cancel()
|
|
274
|
+
receiver.cancel()
|
|
275
|
+
return self.result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def attach(daemon: Daemon, session: Message) -> int:
|
|
279
|
+
"""Attach the terminal to a session until you detach or it ends."""
|
|
280
|
+
return asyncio.run(Terminal(daemon, session).run(session["acpUrl"], daemon.token))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""`handler terminal`: this terminal attached to a harness's own terminal program in its sandbox
|
|
2
|
+
(D.16). handlerd starts the sandbox, then this process becomes the sandbox's `attach` command: raw
|
|
3
|
+
while it runs (every key goes to the program, Ctrl-C included), resized with this terminal, its
|
|
4
|
+
exit code the program's; closing the terminal stops the sandbox. handlerd is not in between, so it
|
|
5
|
+
can restart meanwhile.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, NoReturn
|
|
11
|
+
|
|
12
|
+
from handler_contracts import TerminalStart, TerminalStarted, terminal_path
|
|
13
|
+
|
|
14
|
+
from .client import Daemon
|
|
15
|
+
|
|
16
|
+
_TERM = re.compile(r"^[A-Za-z0-9._+-]{1,64}$")
|
|
17
|
+
START_TIMEOUT = 600.0 # seconds: handlerd's own limit (a first start may build the proxy's image)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _start(args: list[str], model: dict[str, Any] | None) -> TerminalStart:
|
|
21
|
+
term = os.environ.get("TERM", "")
|
|
22
|
+
colorterm = os.environ.get("COLORTERM", "")
|
|
23
|
+
extra: dict[str, Any] = {"term": term} if _TERM.match(term) else {}
|
|
24
|
+
if _TERM.match(colorterm):
|
|
25
|
+
extra["colorterm"] = colorterm
|
|
26
|
+
if model is not None:
|
|
27
|
+
extra["model"] = model
|
|
28
|
+
return TerminalStart(args=args, **extra)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(d: Daemon, agent_id: str, args: list[str], model: dict[str, Any] | None = None) -> NoReturn:
|
|
32
|
+
"""The agent's terminal, with `model` (one of the agent's; default: its first); ClientError
|
|
33
|
+
when it cannot start."""
|
|
34
|
+
body = _start(args, model).model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
35
|
+
raw = d.call("POST", terminal_path(agent_id), body, timeout=START_TIMEOUT)
|
|
36
|
+
started = TerminalStarted.model_validate(raw)
|
|
37
|
+
os.execvp(started.attach[0], started.attach)
|