hrns 0.0.1__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.
- hrns/__init__.py +13 -0
- hrns/client/__init__.py +120 -0
- hrns/client/__main__.py +10 -0
- hrns/client/_version.py +5 -0
- hrns/client/attachments.py +167 -0
- hrns/client/bridge.py +250 -0
- hrns/client/bus.py +58 -0
- hrns/client/catalog.py +275 -0
- hrns/client/clean.py +174 -0
- hrns/client/cli.py +947 -0
- hrns/client/client.py +432 -0
- hrns/client/connection.py +197 -0
- hrns/client/constants.py +47 -0
- hrns/client/errors.py +98 -0
- hrns/client/events.py +185 -0
- hrns/client/extension.py +299 -0
- hrns/client/extensions_api.py +242 -0
- hrns/client/headless.py +328 -0
- hrns/client/interactions.py +374 -0
- hrns/client/launch.py +159 -0
- hrns/client/local.py +187 -0
- hrns/client/payloads.py +129 -0
- hrns/client/profiles.py +301 -0
- hrns/client/renderers.py +100 -0
- hrns/client/session.py +377 -0
- hrns/client/transport.py +523 -0
- hrns/client/tui/__init__.py +1 -0
- hrns/client/tui/app.py +860 -0
- hrns/client/tui/approval.py +543 -0
- hrns/client/tui/blocks.py +305 -0
- hrns/client/tui/chat/__init__.py +1 -0
- hrns/client/tui/chat/actions.py +285 -0
- hrns/client/tui/chat/capabilities.py +470 -0
- hrns/client/tui/chat/chrome.py +157 -0
- hrns/client/tui/chat/commands.py +253 -0
- hrns/client/tui/chat/connection.py +93 -0
- hrns/client/tui/chat/model.py +464 -0
- hrns/client/tui/chat/prompts.py +645 -0
- hrns/client/tui/chat/screen.py +887 -0
- hrns/client/tui/chat/session.py +261 -0
- hrns/client/tui/choice_keys.py +55 -0
- hrns/client/tui/commands.py +96 -0
- hrns/client/tui/completion.py +97 -0
- hrns/client/tui/composer.py +247 -0
- hrns/client/tui/connection_screen.py +650 -0
- hrns/client/tui/dialogs.py +1002 -0
- hrns/client/tui/extensions.py +138 -0
- hrns/client/tui/formatting.py +77 -0
- hrns/client/tui/local.py +332 -0
- hrns/client/tui/messages.py +58 -0
- hrns/client/tui/panels/__init__.py +5 -0
- hrns/client/tui/panels/activity.py +57 -0
- hrns/client/tui/panels/capability.py +373 -0
- hrns/client/tui/panels/mcp.py +219 -0
- hrns/client/tui/panels/session.py +243 -0
- hrns/client/tui/panels/side.py +85 -0
- hrns/client/tui/panels/todo.py +62 -0
- hrns/client/tui/screen_base.py +23 -0
- hrns/client/tui/state.py +774 -0
- hrns/client/tui/status.py +277 -0
- hrns/client/tui/tool_views.py +404 -0
- hrns/client/tui/transcript.py +818 -0
- hrns/client/turn.py +169 -0
- hrns/client/updates.py +190 -0
- hrns/client/url_policy.py +57 -0
- hrns/config/__init__.py +30 -0
- hrns/config/mcp.py +288 -0
- hrns/config/settings.py +173 -0
- hrns/protocol/__init__.py +105 -0
- hrns/protocol/errors.py +11 -0
- hrns/protocol/extensions.py +161 -0
- hrns/protocol/models.py +166 -0
- hrns/protocol/py.typed +0 -0
- hrns/protocol/transport.py +145 -0
- hrns/py.typed +0 -0
- hrns/runtime/__init__.py +154 -0
- hrns/runtime/_model_usage.py +69 -0
- hrns/runtime/_unavailable_tools.py +103 -0
- hrns/runtime/_version.py +5 -0
- hrns/runtime/_warnings.py +9 -0
- hrns/runtime/acp.py +174 -0
- hrns/runtime/acp_store.py +398 -0
- hrns/runtime/ask_user.py +94 -0
- hrns/runtime/capabilities/__init__.py +34 -0
- hrns/runtime/capabilities/ask_user/__init__.py +60 -0
- hrns/runtime/capabilities/ask_user/_capability.py +36 -0
- hrns/runtime/capabilities/ask_user/config.py +22 -0
- hrns/runtime/capabilities/coder/__init__.py +60 -0
- hrns/runtime/capabilities/coder/_capability.py +91 -0
- hrns/runtime/capabilities/coder/_changes.py +208 -0
- hrns/runtime/capabilities/coder/_ids.py +19 -0
- hrns/runtime/capabilities/coder/_instructions.py +32 -0
- hrns/runtime/capabilities/coder/_needs.py +11 -0
- hrns/runtime/capabilities/coder/_presentation.py +52 -0
- hrns/runtime/capabilities/coder/_search.py +434 -0
- hrns/runtime/capabilities/coder/_tools.py +361 -0
- hrns/runtime/capabilities/coder/_workspace.py +107 -0
- hrns/runtime/capabilities/coder/config.py +41 -0
- hrns/runtime/capabilities/mcp/__init__.py +64 -0
- hrns/runtime/capabilities/mcp/_capability.py +121 -0
- hrns/runtime/capabilities/mcp/_status.py +92 -0
- hrns/runtime/capabilities/mcp/config.py +11 -0
- hrns/runtime/capabilities/skills/__init__.py +76 -0
- hrns/runtime/capabilities/skills/_capability.py +85 -0
- hrns/runtime/capabilities/skills/_catalog.py +144 -0
- hrns/runtime/capabilities/skills/_invocation.py +73 -0
- hrns/runtime/capabilities/skills/_needs.py +7 -0
- hrns/runtime/capabilities/skills/config.py +57 -0
- hrns/runtime/capabilities/todo/__init__.py +58 -0
- hrns/runtime/capabilities/todo/_capability.py +57 -0
- hrns/runtime/capabilities/todo/config.py +23 -0
- hrns/runtime/cli.py +406 -0
- hrns/runtime/command_service.py +209 -0
- hrns/runtime/commands.py +170 -0
- hrns/runtime/composition.py +526 -0
- hrns/runtime/configuration_service.py +164 -0
- hrns/runtime/definitions.py +671 -0
- hrns/runtime/deps.py +89 -0
- hrns/runtime/extensions.py +234 -0
- hrns/runtime/host.py +485 -0
- hrns/runtime/host_support.py +250 -0
- hrns/runtime/local.py +366 -0
- hrns/runtime/policy/__init__.py +23 -0
- hrns/runtime/policy/binding.py +156 -0
- hrns/runtime/policy/catalog.py +439 -0
- hrns/runtime/policy/validation.py +468 -0
- hrns/runtime/postgres.py +354 -0
- hrns/runtime/presentation.py +256 -0
- hrns/runtime/registry.py +631 -0
- hrns/runtime/resources.py +488 -0
- hrns/runtime/session_service.py +446 -0
- hrns/runtime/steering.py +59 -0
- hrns/runtime/store.py +355 -0
- hrns/runtime/todo.py +160 -0
- hrns/server/__init__.py +18 -0
- hrns/server/_version.py +5 -0
- hrns/server/api.py +66 -0
- hrns/server/app.py +213 -0
- hrns/server/auth.py +64 -0
- hrns/server/catalog.py +162 -0
- hrns/server/errors.py +123 -0
- hrns/server/models.py +67 -0
- hrns/server/proxy.py +157 -0
- hrns-0.0.1.dist-info/METADATA +262 -0
- hrns-0.0.1.dist-info/RECORD +148 -0
- hrns-0.0.1.dist-info/WHEEL +4 -0
- hrns-0.0.1.dist-info/entry_points.txt +4 -0
- hrns-0.0.1.dist-info/licenses/LICENSE +21 -0
hrns/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""hrns: programmable Pydantic AI agents with an ACP terminal and hosting.
|
|
2
|
+
|
|
3
|
+
The distribution exposes independent subpackages:
|
|
4
|
+
|
|
5
|
+
- ``hrns.protocol``: versioned contracts and transport framing.
|
|
6
|
+
- ``hrns.config``: shared local settings.
|
|
7
|
+
- ``hrns.runtime``: native agent definitions, sessions, and ACP hosting.
|
|
8
|
+
- ``hrns.client``: Textual terminal, headless runner, and stdio bridge.
|
|
9
|
+
- ``hrns.server``: authenticated deployment catalog and worker hosting.
|
|
10
|
+
|
|
11
|
+
Importing ``hrns`` does not import any subpackage, so the client and server
|
|
12
|
+
keep their independent dependency boundaries.
|
|
13
|
+
"""
|
hrns/client/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The standalone hrns client: ACP terminal, headless runner, bridge.
|
|
2
|
+
|
|
3
|
+
This module does not import ``hrns.runtime``. It speaks ACP v1
|
|
4
|
+
with third-party agents over stdio and with project hosts over the versioned
|
|
5
|
+
WebSocket transport. The UI is Textual; the network layer is ``acp``,
|
|
6
|
+
``websockets`` and ``httpx``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .catalog import AgentDeployment, AgentPage, CatalogClient
|
|
11
|
+
from .client import HrnsAcpClient
|
|
12
|
+
from .errors import (
|
|
13
|
+
CatalogError,
|
|
14
|
+
ConnectionLost,
|
|
15
|
+
ExtensionUnavailable,
|
|
16
|
+
HrnsClientError,
|
|
17
|
+
InteractionRejected,
|
|
18
|
+
InteractionRequired,
|
|
19
|
+
ProtocolError,
|
|
20
|
+
SecretMissing,
|
|
21
|
+
TransportError,
|
|
22
|
+
)
|
|
23
|
+
from .events import (
|
|
24
|
+
AgentTextChunk,
|
|
25
|
+
CapabilitiesListed,
|
|
26
|
+
CommandsUpdated,
|
|
27
|
+
ConfigOptionsUpdated,
|
|
28
|
+
ConfigurationChanged,
|
|
29
|
+
ConnectionEvent,
|
|
30
|
+
ModesUpdated,
|
|
31
|
+
PlanChanged,
|
|
32
|
+
PluginListed,
|
|
33
|
+
SessionsListed,
|
|
34
|
+
ToolEvent,
|
|
35
|
+
TurnCompleted,
|
|
36
|
+
TurnStarted,
|
|
37
|
+
UsageUpdated,
|
|
38
|
+
UserMessageReplayed,
|
|
39
|
+
)
|
|
40
|
+
from .interactions import (
|
|
41
|
+
PERMISSION_MODES,
|
|
42
|
+
READ_ONLY_KINDS,
|
|
43
|
+
AskUserAnswer,
|
|
44
|
+
AskUserOption,
|
|
45
|
+
AskUserQuestion,
|
|
46
|
+
AskUserRequest,
|
|
47
|
+
AskUserResponse,
|
|
48
|
+
ElicitationAnswer,
|
|
49
|
+
ElicitationRequest,
|
|
50
|
+
HeadlessInteractions,
|
|
51
|
+
InteractionPolicy,
|
|
52
|
+
PermissionDecision,
|
|
53
|
+
PermissionRequest,
|
|
54
|
+
RequireInteractions,
|
|
55
|
+
ask_user_payload,
|
|
56
|
+
auto_permission,
|
|
57
|
+
parse_ask_user_request,
|
|
58
|
+
)
|
|
59
|
+
from .profiles import Profile, Profiles, default_profiles_path, secret_value
|
|
60
|
+
from .renderers import RendererRegistry, readable_text
|
|
61
|
+
from .session import SessionProxy
|
|
62
|
+
from .transport import StdioAgentTransport, WebSocketAgentTransport
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"__version__",
|
|
66
|
+
"AgentDeployment",
|
|
67
|
+
"AgentPage",
|
|
68
|
+
"AgentTextChunk",
|
|
69
|
+
"AskUserAnswer",
|
|
70
|
+
"AskUserOption",
|
|
71
|
+
"AskUserQuestion",
|
|
72
|
+
"AskUserRequest",
|
|
73
|
+
"AskUserResponse",
|
|
74
|
+
"CapabilitiesListed",
|
|
75
|
+
"CatalogClient",
|
|
76
|
+
"CatalogError",
|
|
77
|
+
"CommandsUpdated",
|
|
78
|
+
"ConfigOptionsUpdated",
|
|
79
|
+
"ConfigurationChanged",
|
|
80
|
+
"ConnectionEvent",
|
|
81
|
+
"ConnectionLost",
|
|
82
|
+
"ElicitationAnswer",
|
|
83
|
+
"ElicitationRequest",
|
|
84
|
+
"ExtensionUnavailable",
|
|
85
|
+
"HrnsAcpClient",
|
|
86
|
+
"HrnsClientError",
|
|
87
|
+
"HeadlessInteractions",
|
|
88
|
+
"InteractionPolicy",
|
|
89
|
+
"InteractionRejected",
|
|
90
|
+
"InteractionRequired",
|
|
91
|
+
"ModesUpdated",
|
|
92
|
+
"PERMISSION_MODES",
|
|
93
|
+
"PermissionDecision",
|
|
94
|
+
"PermissionRequest",
|
|
95
|
+
"PlanChanged",
|
|
96
|
+
"PluginListed",
|
|
97
|
+
"Profile",
|
|
98
|
+
"Profiles",
|
|
99
|
+
"ProtocolError",
|
|
100
|
+
"READ_ONLY_KINDS",
|
|
101
|
+
"RendererRegistry",
|
|
102
|
+
"RequireInteractions",
|
|
103
|
+
"SecretMissing",
|
|
104
|
+
"SessionProxy",
|
|
105
|
+
"SessionsListed",
|
|
106
|
+
"StdioAgentTransport",
|
|
107
|
+
"ToolEvent",
|
|
108
|
+
"TransportError",
|
|
109
|
+
"TurnCompleted",
|
|
110
|
+
"TurnStarted",
|
|
111
|
+
"UsageUpdated",
|
|
112
|
+
"UserMessageReplayed",
|
|
113
|
+
"ask_user_payload",
|
|
114
|
+
"auto_permission",
|
|
115
|
+
"parse_ask_user_request",
|
|
116
|
+
"WebSocketAgentTransport",
|
|
117
|
+
"default_profiles_path",
|
|
118
|
+
"readable_text",
|
|
119
|
+
"secret_value",
|
|
120
|
+
]
|
hrns/client/__main__.py
ADDED
hrns/client/_version.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Explicit, bounded file attachments for ACP prompts.
|
|
2
|
+
|
|
3
|
+
Attachments are content, not filesystem grants. The caller chooses each path; the
|
|
4
|
+
receiving agent never gains access to the client's working directory from an attachment,
|
|
5
|
+
and a hosted agent receives bytes rather than a local path it cannot read.
|
|
6
|
+
|
|
7
|
+
Text resources are embedded as UTF-8 text; everything else is embedded as base64 bytes.
|
|
8
|
+
Only regular files are accepted. The aggregate is bounded by the ACP message limit, with
|
|
9
|
+
headroom reserved for the rest of the JSON-RPC envelope.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import mimetypes
|
|
16
|
+
import stat
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Sequence
|
|
19
|
+
|
|
20
|
+
from acp import schema
|
|
21
|
+
|
|
22
|
+
from .errors import HrnsClientError
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"MAX_ATTACHMENT_BYTES",
|
|
26
|
+
"attachment_summary",
|
|
27
|
+
"build_prompt_blocks",
|
|
28
|
+
"validate_attachment",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
#: Per-file limit; the client's prompt-size check still applies to the aggregate.
|
|
32
|
+
MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
|
33
|
+
_TEXTUAL_SUFFIXES = {
|
|
34
|
+
".json",
|
|
35
|
+
".jsonl",
|
|
36
|
+
".yaml",
|
|
37
|
+
".yml",
|
|
38
|
+
".toml",
|
|
39
|
+
".md",
|
|
40
|
+
".txt",
|
|
41
|
+
".csv",
|
|
42
|
+
".tsv",
|
|
43
|
+
".py",
|
|
44
|
+
".ts",
|
|
45
|
+
".tsx",
|
|
46
|
+
".js",
|
|
47
|
+
".jsx",
|
|
48
|
+
".css",
|
|
49
|
+
".html",
|
|
50
|
+
".xml",
|
|
51
|
+
".sql",
|
|
52
|
+
".sh",
|
|
53
|
+
".rs",
|
|
54
|
+
".go",
|
|
55
|
+
".java",
|
|
56
|
+
".kt",
|
|
57
|
+
".c",
|
|
58
|
+
".h",
|
|
59
|
+
".cpp",
|
|
60
|
+
".hpp",
|
|
61
|
+
".rb",
|
|
62
|
+
".php",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _media_type(path: Path) -> str:
|
|
67
|
+
guessed, _ = mimetypes.guess_type(path.name)
|
|
68
|
+
if guessed:
|
|
69
|
+
return guessed
|
|
70
|
+
if path.suffix.lower() in _TEXTUAL_SUFFIXES:
|
|
71
|
+
return "text/plain"
|
|
72
|
+
return "application/octet-stream"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_text(media_type: str, path: Path) -> bool:
|
|
76
|
+
return (
|
|
77
|
+
media_type.startswith("text/")
|
|
78
|
+
or media_type in {"application/json", "application/xml", "application/yaml"}
|
|
79
|
+
or path.suffix.lower() in _TEXTUAL_SUFFIXES
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_attachment(raw: str | Path) -> Path:
|
|
84
|
+
"""Resolve and validate one explicit attachment without reading its content."""
|
|
85
|
+
path = Path(raw).expanduser()
|
|
86
|
+
if not path.is_absolute():
|
|
87
|
+
path = path.resolve()
|
|
88
|
+
try:
|
|
89
|
+
info = path.stat()
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
raise HrnsClientError(f"Cannot attach {path}: {exc}") from exc
|
|
92
|
+
if not stat.S_ISREG(info.st_mode):
|
|
93
|
+
raise HrnsClientError(f"Cannot attach {path}: only regular files are supported")
|
|
94
|
+
if info.st_size > MAX_ATTACHMENT_BYTES:
|
|
95
|
+
raise HrnsClientError(
|
|
96
|
+
f"Cannot attach {path}: {info.st_size:,} bytes exceeds the "
|
|
97
|
+
f"{MAX_ATTACHMENT_BYTES:,}-byte per-file limit"
|
|
98
|
+
)
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_regular(path: Path) -> bytes:
|
|
103
|
+
validate_attachment(path)
|
|
104
|
+
try:
|
|
105
|
+
return path.read_bytes()
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise HrnsClientError(f"Cannot read {path}: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_prompt_blocks(prompt: str, attachments: Sequence[str] = ()) -> list[Any]:
|
|
111
|
+
"""Build ACP prompt blocks: the explicit user text followed by embedded resources."""
|
|
112
|
+
if not prompt.strip():
|
|
113
|
+
raise HrnsClientError("A prompt needs non-empty text; attachments do not replace it.")
|
|
114
|
+
blocks: list[Any] = [schema.TextContentBlock(type="text", text=prompt)]
|
|
115
|
+
total = len(prompt.encode("utf-8"))
|
|
116
|
+
for raw in attachments:
|
|
117
|
+
path = Path(raw).expanduser()
|
|
118
|
+
if not path.is_absolute():
|
|
119
|
+
path = path.resolve()
|
|
120
|
+
content = _read_regular(path)
|
|
121
|
+
total += len(content)
|
|
122
|
+
# Base64 expands by 4/3; leave room for JSON-RPC fields and other blocks.
|
|
123
|
+
if total * 4 // 3 > 12 * 1024 * 1024:
|
|
124
|
+
raise HrnsClientError(
|
|
125
|
+
"Attachments are too large for one ACP prompt (16 MiB frame limit); "
|
|
126
|
+
"attach fewer or smaller files."
|
|
127
|
+
)
|
|
128
|
+
media_type = _media_type(path)
|
|
129
|
+
uri = path.as_uri()
|
|
130
|
+
if _is_text(media_type, path):
|
|
131
|
+
try:
|
|
132
|
+
text = content.decode("utf-8")
|
|
133
|
+
except UnicodeDecodeError:
|
|
134
|
+
text = None
|
|
135
|
+
if text is not None:
|
|
136
|
+
resource: Any = schema.TextResourceContents(
|
|
137
|
+
uri=uri, text=text, mime_type=media_type
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
resource = schema.BlobResourceContents(
|
|
141
|
+
uri=uri,
|
|
142
|
+
blob=base64.b64encode(content).decode("ascii"),
|
|
143
|
+
mime_type="application/octet-stream",
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
resource = schema.BlobResourceContents(
|
|
147
|
+
uri=uri,
|
|
148
|
+
blob=base64.b64encode(content).decode("ascii"),
|
|
149
|
+
mime_type=media_type,
|
|
150
|
+
)
|
|
151
|
+
blocks.append(schema.EmbeddedResourceContentBlock(type="resource", resource=resource))
|
|
152
|
+
return blocks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def attachment_summary(attachments: Sequence[str]) -> list[dict[str, Any]]:
|
|
156
|
+
"""Non-secret display metadata for the UI and JSON output."""
|
|
157
|
+
result: list[dict[str, Any]] = []
|
|
158
|
+
for raw in attachments:
|
|
159
|
+
path = Path(raw).expanduser().resolve()
|
|
160
|
+
try:
|
|
161
|
+
size = path.stat().st_size
|
|
162
|
+
except OSError:
|
|
163
|
+
size = None
|
|
164
|
+
result.append(
|
|
165
|
+
{"name": path.name, "path": str(path), "size": size, "mimeType": _media_type(path)}
|
|
166
|
+
)
|
|
167
|
+
return result
|
hrns/client/bridge.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Stdio ACP bridge to a hosted agent (``hrns bridge``).
|
|
2
|
+
|
|
3
|
+
The bridge is a framing adapter: newline-delimited ACP JSON-RPC on stdin and
|
|
4
|
+
stdout, one complete JSON object per WebSocket text message on the remote
|
|
5
|
+
side. Any stdio-only ACP client can launch it.
|
|
6
|
+
|
|
7
|
+
With ``--remote-cwd``, the local client's ``cwd`` on ``session/new``,
|
|
8
|
+
``session/load`` and ``session/resume`` is *replaced* with the remote
|
|
9
|
+
directory. Nothing else is rewritten; paths returned by the agent remain
|
|
10
|
+
clearly remote.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextlib
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from websockets.asyncio.client import ClientConnection
|
|
23
|
+
from websockets.asyncio.client import connect as ws_connect
|
|
24
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus, WebSocketException
|
|
25
|
+
|
|
26
|
+
from hrns.protocol import HrnsError, decode_frame, encode_frame
|
|
27
|
+
|
|
28
|
+
from .constants import (
|
|
29
|
+
MAX_MESSAGE_BYTES,
|
|
30
|
+
WS_PING_INTERVAL,
|
|
31
|
+
WS_PING_TIMEOUT,
|
|
32
|
+
WS_SUBPROTOCOL,
|
|
33
|
+
WS_WRITE_LIMIT,
|
|
34
|
+
)
|
|
35
|
+
from .errors import HrnsClientError, ProtocolError
|
|
36
|
+
from .profiles import secret_value
|
|
37
|
+
from .url_policy import require_secure_ws_url
|
|
38
|
+
|
|
39
|
+
__all__ = ["BridgeSession", "rewrite_session_cwd", "run_bridge", "run_bridge_pump"]
|
|
40
|
+
|
|
41
|
+
SESSION_METHODS = frozenset({"session/new", "session/load", "session/resume"})
|
|
42
|
+
|
|
43
|
+
EXIT_BRIDGE_OK = 0
|
|
44
|
+
EXIT_BRIDGE_FAILURE = 1
|
|
45
|
+
EXIT_BRIDGE_PROTOCOL = 4
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class BridgeSession:
|
|
50
|
+
"""Everything needed to reach the hosted agent."""
|
|
51
|
+
|
|
52
|
+
url: str # ws(s) URL of /v1/agents/{id}/acp
|
|
53
|
+
token_ref: str = ""
|
|
54
|
+
token: str | None = None
|
|
55
|
+
remote_cwd: str | None = None
|
|
56
|
+
agent_id: str = ""
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
require_secure_ws_url(self.url)
|
|
60
|
+
|
|
61
|
+
def headers(self) -> dict[str, str]:
|
|
62
|
+
headers: dict[str, str] = {}
|
|
63
|
+
token = (
|
|
64
|
+
self.token
|
|
65
|
+
if self.token is not None
|
|
66
|
+
else (secret_value(self.token_ref) if self.token_ref else None)
|
|
67
|
+
)
|
|
68
|
+
if token:
|
|
69
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
70
|
+
return headers
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def rewrite_session_cwd(message: dict[str, Any], remote_cwd: str) -> bool:
|
|
74
|
+
"""Replace ``params.cwd`` on ACP session setup/load/resume. Returns True if rewritten."""
|
|
75
|
+
if message.get("method") not in SESSION_METHODS:
|
|
76
|
+
return False
|
|
77
|
+
params = message.get("params")
|
|
78
|
+
if not isinstance(params, dict):
|
|
79
|
+
return False
|
|
80
|
+
params["cwd"] = remote_cwd
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _default_ws_connect(session: BridgeSession) -> ClientConnection:
|
|
85
|
+
return await ws_connect(
|
|
86
|
+
session.url,
|
|
87
|
+
subprotocols=[WS_SUBPROTOCOL],
|
|
88
|
+
max_size=MAX_MESSAGE_BYTES,
|
|
89
|
+
ping_interval=WS_PING_INTERVAL,
|
|
90
|
+
ping_timeout=WS_PING_TIMEOUT,
|
|
91
|
+
write_limit=WS_WRITE_LIMIT,
|
|
92
|
+
additional_headers=session.headers(),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def run_bridge_pump(
|
|
97
|
+
read_line: Callable[[], Awaitable[bytes]],
|
|
98
|
+
write_local: Callable[[bytes], Awaitable[None]],
|
|
99
|
+
connect_remote: Callable[[], Awaitable[ClientConnection]],
|
|
100
|
+
*,
|
|
101
|
+
remote_cwd: str | None = None,
|
|
102
|
+
stderr: Callable[[str], None] | None = None,
|
|
103
|
+
) -> int:
|
|
104
|
+
"""Run both directions until stdin closes or the remote side goes away."""
|
|
105
|
+
report = stderr or (lambda line: print(f"bridge: {line}", file=sys.stderr, flush=True))
|
|
106
|
+
try:
|
|
107
|
+
ws = await connect_remote()
|
|
108
|
+
except InvalidStatus as exc:
|
|
109
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
110
|
+
report(f"the server rejected the connection{f' (HTTP {status})' if status else ''}")
|
|
111
|
+
return EXIT_BRIDGE_FAILURE
|
|
112
|
+
except (OSError, WebSocketException) as exc:
|
|
113
|
+
report(f"cannot connect to the remote agent: {exc}")
|
|
114
|
+
return EXIT_BRIDGE_FAILURE
|
|
115
|
+
selected = getattr(ws, "subprotocol", None)
|
|
116
|
+
if selected != WS_SUBPROTOCOL:
|
|
117
|
+
report(
|
|
118
|
+
f"the server did not accept the '{WS_SUBPROTOCOL}' subprotocol"
|
|
119
|
+
+ (f" (selected {selected!r})" if selected else "")
|
|
120
|
+
)
|
|
121
|
+
await ws.close()
|
|
122
|
+
return EXIT_BRIDGE_PROTOCOL
|
|
123
|
+
|
|
124
|
+
rewritten = False
|
|
125
|
+
remote_error: str = ""
|
|
126
|
+
|
|
127
|
+
async def local_to_remote() -> None:
|
|
128
|
+
nonlocal rewritten
|
|
129
|
+
while True:
|
|
130
|
+
line = await read_line()
|
|
131
|
+
if not line:
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
message = decode_frame(line)
|
|
135
|
+
except (HrnsClientError, HrnsError) as exc:
|
|
136
|
+
report(f"invalid local ACP frame: {exc}")
|
|
137
|
+
raise ProtocolError(str(exc)) from exc
|
|
138
|
+
if remote_cwd and rewrite_session_cwd(message, remote_cwd):
|
|
139
|
+
rewritten = True
|
|
140
|
+
wire = encode_frame(message)
|
|
141
|
+
if len(wire.encode("utf-8")) > MAX_MESSAGE_BYTES:
|
|
142
|
+
report("local message exceeds the 16 MiB transport limit")
|
|
143
|
+
raise ProtocolError("message too large")
|
|
144
|
+
await ws.send(wire)
|
|
145
|
+
|
|
146
|
+
async def remote_to_local() -> None:
|
|
147
|
+
nonlocal remote_error
|
|
148
|
+
try:
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
message = await ws.recv()
|
|
152
|
+
except ConnectionClosed as exc:
|
|
153
|
+
received = getattr(exc, "rcvd", None)
|
|
154
|
+
code = getattr(received, "code", None)
|
|
155
|
+
remote_error = f"remote connection closed{f' (code {code})' if code else ''}"
|
|
156
|
+
if code == 1009:
|
|
157
|
+
remote_error = (
|
|
158
|
+
"the server closed with status 1009 after an oversized message"
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
if isinstance(message, bytes):
|
|
162
|
+
report("remote sent a binary WebSocket frame; ACP frames must be text")
|
|
163
|
+
raise ProtocolError("binary frame")
|
|
164
|
+
try:
|
|
165
|
+
decode_frame(message)
|
|
166
|
+
except (HrnsClientError, HrnsError) as exc:
|
|
167
|
+
report(f"invalid remote ACP frame: {exc}")
|
|
168
|
+
raise ProtocolError(str(exc)) from exc
|
|
169
|
+
await write_local(message.rstrip().encode("utf-8") + b"\n")
|
|
170
|
+
except WebSocketException as exc:
|
|
171
|
+
remote_error = f"remote transport failed: {exc}"
|
|
172
|
+
|
|
173
|
+
local_task = asyncio.create_task(local_to_remote(), name="hrns-bridge-local")
|
|
174
|
+
remote_task = asyncio.create_task(remote_to_local(), name="hrns-bridge-remote")
|
|
175
|
+
errors: list[BaseException] = []
|
|
176
|
+
try:
|
|
177
|
+
done, _pending = await asyncio.wait(
|
|
178
|
+
{local_task, remote_task}, return_when=asyncio.FIRST_COMPLETED
|
|
179
|
+
)
|
|
180
|
+
if remote_task in done:
|
|
181
|
+
# Give a final in-flight local write a moment, then stop.
|
|
182
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
183
|
+
await asyncio.wait_for(local_task, timeout=0.2)
|
|
184
|
+
else:
|
|
185
|
+
# Local EOF: allow trailing responses already in flight, then close.
|
|
186
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
187
|
+
await asyncio.wait_for(remote_task, timeout=0.2)
|
|
188
|
+
for task in done:
|
|
189
|
+
if task.cancelled():
|
|
190
|
+
continue
|
|
191
|
+
exc = task.exception()
|
|
192
|
+
if exc is not None:
|
|
193
|
+
errors.append(exc)
|
|
194
|
+
finally:
|
|
195
|
+
for task in (local_task, remote_task):
|
|
196
|
+
task.cancel()
|
|
197
|
+
for task in (local_task, remote_task):
|
|
198
|
+
with contextlib.suppress(BaseException):
|
|
199
|
+
await task
|
|
200
|
+
with contextlib.suppress(Exception):
|
|
201
|
+
await ws.close(code=1000)
|
|
202
|
+
if rewritten:
|
|
203
|
+
report(f"session cwd rewritten to remote path {remote_cwd}")
|
|
204
|
+
if errors:
|
|
205
|
+
report(str(errors[0]))
|
|
206
|
+
return EXIT_BRIDGE_PROTOCOL
|
|
207
|
+
if remote_error:
|
|
208
|
+
report(remote_error)
|
|
209
|
+
return EXIT_BRIDGE_FAILURE
|
|
210
|
+
return EXIT_BRIDGE_OK
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def run_bridge(
|
|
214
|
+
session: BridgeSession,
|
|
215
|
+
*,
|
|
216
|
+
reader: asyncio.StreamReader | None = None,
|
|
217
|
+
writer: asyncio.StreamWriter | None = None,
|
|
218
|
+
stderr: Callable[[str], None] | None = None,
|
|
219
|
+
) -> int:
|
|
220
|
+
"""Run the bridge over real (or injected) stdio streams."""
|
|
221
|
+
own_streams = reader is None and writer is None
|
|
222
|
+
if own_streams:
|
|
223
|
+
from acp.stdio import stdio_streams
|
|
224
|
+
|
|
225
|
+
reader, writer = await stdio_streams(limit=MAX_MESSAGE_BYTES)
|
|
226
|
+
assert reader is not None and writer is not None
|
|
227
|
+
|
|
228
|
+
async def read_line() -> bytes:
|
|
229
|
+
return await reader.readline()
|
|
230
|
+
|
|
231
|
+
async def write_local(data: bytes) -> None:
|
|
232
|
+
writer.write(data)
|
|
233
|
+
await writer.drain()
|
|
234
|
+
|
|
235
|
+
async def connect_remote() -> ClientConnection:
|
|
236
|
+
return await _default_ws_connect(session)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
return await run_bridge_pump(
|
|
240
|
+
read_line,
|
|
241
|
+
write_local,
|
|
242
|
+
connect_remote,
|
|
243
|
+
remote_cwd=session.remote_cwd,
|
|
244
|
+
stderr=stderr,
|
|
245
|
+
)
|
|
246
|
+
finally:
|
|
247
|
+
if own_streams:
|
|
248
|
+
with contextlib.suppress(Exception):
|
|
249
|
+
writer.close()
|
|
250
|
+
await writer.wait_closed()
|
hrns/client/bus.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""A tiny synchronous event bus for typed client events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import weakref
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
Subscriber = Callable[[Any], Awaitable[None] | None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventBus:
|
|
14
|
+
"""Publishes client events to in-process subscribers.
|
|
15
|
+
|
|
16
|
+
Handlers are called synchronously on the loop that publishes. Coroutine
|
|
17
|
+
handlers are scheduled as tasks so a slow consumer never blocks protocol
|
|
18
|
+
dispatch. Disabled handlers are pruned.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._subscribers: list[Subscriber] = []
|
|
23
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
24
|
+
|
|
25
|
+
def subscribe(self, handler: Subscriber) -> Callable[[], None]:
|
|
26
|
+
"""Register a handler and return a function that removes it."""
|
|
27
|
+
if not callable(handler):
|
|
28
|
+
raise TypeError("Event handlers must be callable")
|
|
29
|
+
self._subscribers.append(handler)
|
|
30
|
+
if isinstance(handler, weakref.ref): # pragma: no cover - defensive
|
|
31
|
+
raise TypeError("Pass a bound method or closure, not a weakref")
|
|
32
|
+
|
|
33
|
+
def unsubscribe() -> None:
|
|
34
|
+
try:
|
|
35
|
+
self._subscribers.remove(handler)
|
|
36
|
+
except ValueError: # pragma: no cover - already removed
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
return unsubscribe
|
|
40
|
+
|
|
41
|
+
def publish(self, event: Any) -> None:
|
|
42
|
+
for handler in list(self._subscribers):
|
|
43
|
+
try:
|
|
44
|
+
result = handler(event)
|
|
45
|
+
except Exception:
|
|
46
|
+
import logging
|
|
47
|
+
|
|
48
|
+
logging.getLogger(__name__).exception("Event handler failed")
|
|
49
|
+
continue
|
|
50
|
+
if asyncio.iscoroutine(result):
|
|
51
|
+
task = asyncio.create_task(result)
|
|
52
|
+
self._tasks.add(task)
|
|
53
|
+
task.add_done_callback(self._tasks.discard)
|
|
54
|
+
|
|
55
|
+
async def drain(self) -> None:
|
|
56
|
+
"""Wait for handler tasks published so far to finish (tests only)."""
|
|
57
|
+
if self._tasks:
|
|
58
|
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|