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.
Files changed (148) hide show
  1. hrns/__init__.py +13 -0
  2. hrns/client/__init__.py +120 -0
  3. hrns/client/__main__.py +10 -0
  4. hrns/client/_version.py +5 -0
  5. hrns/client/attachments.py +167 -0
  6. hrns/client/bridge.py +250 -0
  7. hrns/client/bus.py +58 -0
  8. hrns/client/catalog.py +275 -0
  9. hrns/client/clean.py +174 -0
  10. hrns/client/cli.py +947 -0
  11. hrns/client/client.py +432 -0
  12. hrns/client/connection.py +197 -0
  13. hrns/client/constants.py +47 -0
  14. hrns/client/errors.py +98 -0
  15. hrns/client/events.py +185 -0
  16. hrns/client/extension.py +299 -0
  17. hrns/client/extensions_api.py +242 -0
  18. hrns/client/headless.py +328 -0
  19. hrns/client/interactions.py +374 -0
  20. hrns/client/launch.py +159 -0
  21. hrns/client/local.py +187 -0
  22. hrns/client/payloads.py +129 -0
  23. hrns/client/profiles.py +301 -0
  24. hrns/client/renderers.py +100 -0
  25. hrns/client/session.py +377 -0
  26. hrns/client/transport.py +523 -0
  27. hrns/client/tui/__init__.py +1 -0
  28. hrns/client/tui/app.py +860 -0
  29. hrns/client/tui/approval.py +543 -0
  30. hrns/client/tui/blocks.py +305 -0
  31. hrns/client/tui/chat/__init__.py +1 -0
  32. hrns/client/tui/chat/actions.py +285 -0
  33. hrns/client/tui/chat/capabilities.py +470 -0
  34. hrns/client/tui/chat/chrome.py +157 -0
  35. hrns/client/tui/chat/commands.py +253 -0
  36. hrns/client/tui/chat/connection.py +93 -0
  37. hrns/client/tui/chat/model.py +464 -0
  38. hrns/client/tui/chat/prompts.py +645 -0
  39. hrns/client/tui/chat/screen.py +887 -0
  40. hrns/client/tui/chat/session.py +261 -0
  41. hrns/client/tui/choice_keys.py +55 -0
  42. hrns/client/tui/commands.py +96 -0
  43. hrns/client/tui/completion.py +97 -0
  44. hrns/client/tui/composer.py +247 -0
  45. hrns/client/tui/connection_screen.py +650 -0
  46. hrns/client/tui/dialogs.py +1002 -0
  47. hrns/client/tui/extensions.py +138 -0
  48. hrns/client/tui/formatting.py +77 -0
  49. hrns/client/tui/local.py +332 -0
  50. hrns/client/tui/messages.py +58 -0
  51. hrns/client/tui/panels/__init__.py +5 -0
  52. hrns/client/tui/panels/activity.py +57 -0
  53. hrns/client/tui/panels/capability.py +373 -0
  54. hrns/client/tui/panels/mcp.py +219 -0
  55. hrns/client/tui/panels/session.py +243 -0
  56. hrns/client/tui/panels/side.py +85 -0
  57. hrns/client/tui/panels/todo.py +62 -0
  58. hrns/client/tui/screen_base.py +23 -0
  59. hrns/client/tui/state.py +774 -0
  60. hrns/client/tui/status.py +277 -0
  61. hrns/client/tui/tool_views.py +404 -0
  62. hrns/client/tui/transcript.py +818 -0
  63. hrns/client/turn.py +169 -0
  64. hrns/client/updates.py +190 -0
  65. hrns/client/url_policy.py +57 -0
  66. hrns/config/__init__.py +30 -0
  67. hrns/config/mcp.py +288 -0
  68. hrns/config/settings.py +173 -0
  69. hrns/protocol/__init__.py +105 -0
  70. hrns/protocol/errors.py +11 -0
  71. hrns/protocol/extensions.py +161 -0
  72. hrns/protocol/models.py +166 -0
  73. hrns/protocol/py.typed +0 -0
  74. hrns/protocol/transport.py +145 -0
  75. hrns/py.typed +0 -0
  76. hrns/runtime/__init__.py +154 -0
  77. hrns/runtime/_model_usage.py +69 -0
  78. hrns/runtime/_unavailable_tools.py +103 -0
  79. hrns/runtime/_version.py +5 -0
  80. hrns/runtime/_warnings.py +9 -0
  81. hrns/runtime/acp.py +174 -0
  82. hrns/runtime/acp_store.py +398 -0
  83. hrns/runtime/ask_user.py +94 -0
  84. hrns/runtime/capabilities/__init__.py +34 -0
  85. hrns/runtime/capabilities/ask_user/__init__.py +60 -0
  86. hrns/runtime/capabilities/ask_user/_capability.py +36 -0
  87. hrns/runtime/capabilities/ask_user/config.py +22 -0
  88. hrns/runtime/capabilities/coder/__init__.py +60 -0
  89. hrns/runtime/capabilities/coder/_capability.py +91 -0
  90. hrns/runtime/capabilities/coder/_changes.py +208 -0
  91. hrns/runtime/capabilities/coder/_ids.py +19 -0
  92. hrns/runtime/capabilities/coder/_instructions.py +32 -0
  93. hrns/runtime/capabilities/coder/_needs.py +11 -0
  94. hrns/runtime/capabilities/coder/_presentation.py +52 -0
  95. hrns/runtime/capabilities/coder/_search.py +434 -0
  96. hrns/runtime/capabilities/coder/_tools.py +361 -0
  97. hrns/runtime/capabilities/coder/_workspace.py +107 -0
  98. hrns/runtime/capabilities/coder/config.py +41 -0
  99. hrns/runtime/capabilities/mcp/__init__.py +64 -0
  100. hrns/runtime/capabilities/mcp/_capability.py +121 -0
  101. hrns/runtime/capabilities/mcp/_status.py +92 -0
  102. hrns/runtime/capabilities/mcp/config.py +11 -0
  103. hrns/runtime/capabilities/skills/__init__.py +76 -0
  104. hrns/runtime/capabilities/skills/_capability.py +85 -0
  105. hrns/runtime/capabilities/skills/_catalog.py +144 -0
  106. hrns/runtime/capabilities/skills/_invocation.py +73 -0
  107. hrns/runtime/capabilities/skills/_needs.py +7 -0
  108. hrns/runtime/capabilities/skills/config.py +57 -0
  109. hrns/runtime/capabilities/todo/__init__.py +58 -0
  110. hrns/runtime/capabilities/todo/_capability.py +57 -0
  111. hrns/runtime/capabilities/todo/config.py +23 -0
  112. hrns/runtime/cli.py +406 -0
  113. hrns/runtime/command_service.py +209 -0
  114. hrns/runtime/commands.py +170 -0
  115. hrns/runtime/composition.py +526 -0
  116. hrns/runtime/configuration_service.py +164 -0
  117. hrns/runtime/definitions.py +671 -0
  118. hrns/runtime/deps.py +89 -0
  119. hrns/runtime/extensions.py +234 -0
  120. hrns/runtime/host.py +485 -0
  121. hrns/runtime/host_support.py +250 -0
  122. hrns/runtime/local.py +366 -0
  123. hrns/runtime/policy/__init__.py +23 -0
  124. hrns/runtime/policy/binding.py +156 -0
  125. hrns/runtime/policy/catalog.py +439 -0
  126. hrns/runtime/policy/validation.py +468 -0
  127. hrns/runtime/postgres.py +354 -0
  128. hrns/runtime/presentation.py +256 -0
  129. hrns/runtime/registry.py +631 -0
  130. hrns/runtime/resources.py +488 -0
  131. hrns/runtime/session_service.py +446 -0
  132. hrns/runtime/steering.py +59 -0
  133. hrns/runtime/store.py +355 -0
  134. hrns/runtime/todo.py +160 -0
  135. hrns/server/__init__.py +18 -0
  136. hrns/server/_version.py +5 -0
  137. hrns/server/api.py +66 -0
  138. hrns/server/app.py +213 -0
  139. hrns/server/auth.py +64 -0
  140. hrns/server/catalog.py +162 -0
  141. hrns/server/errors.py +123 -0
  142. hrns/server/models.py +67 -0
  143. hrns/server/proxy.py +157 -0
  144. hrns-0.0.1.dist-info/METADATA +262 -0
  145. hrns-0.0.1.dist-info/RECORD +148 -0
  146. hrns-0.0.1.dist-info/WHEEL +4 -0
  147. hrns-0.0.1.dist-info/entry_points.txt +4 -0
  148. 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
+ """
@@ -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
+ ]
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m hrns.client``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """Client package version. Kept separate so the CLI can report it without loading the core."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("hrns")
@@ -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)