coding-bridge-agent 2026.6.8.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.
@@ -0,0 +1,8 @@
1
+ """Coding Bridge Agent — node daemon for AceDataCloud Coding Bridge.
2
+
3
+ Runs on a developer's own machine, connects out to the coding-bridge relay, and
4
+ drives local Claude Code sessions on behalf of an authenticated browser. All
5
+ code execution stays local; the bridge only relays messages.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """``python -m coding_bridge_agent`` entry point."""
2
+ from __future__ import annotations
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,185 @@
1
+ """Command-line interface: ``pair``, ``run``, ``up``, ``status``, ``logout``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import asyncio
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import store
11
+ from .config import Settings
12
+ from .connection import BridgeConnection
13
+ from .pairing import PairingError, poll_for_token, start_pairing
14
+
15
+
16
+ def _build_settings(args: argparse.Namespace) -> Settings:
17
+ settings = Settings.from_env()
18
+ if getattr(args, "bridge_url", None):
19
+ settings.bridge_url = args.bridge_url
20
+ if getattr(args, "name", None):
21
+ settings.node_name = args.name
22
+ if getattr(args, "config_dir", None):
23
+ settings.config_dir = Path(args.config_dir).expanduser()
24
+ if getattr(args, "model", None):
25
+ settings.default_model = args.model
26
+ if getattr(args, "cwd", None):
27
+ settings.default_cwd = args.cwd
28
+ if getattr(args, "permission_timeout", None) is not None:
29
+ settings.permission_timeout = args.permission_timeout
30
+ return settings
31
+
32
+
33
+ def _print_pairing(settings: Settings, pair_code: str) -> None:
34
+ claim_base = settings.claim_url_template.split("?", 1)[0]
35
+ claim_url = settings.claim_url_template.format(code=pair_code)
36
+ print()
37
+ print(" Pair this machine with your Ace account:")
38
+ print(f" 1. Open {claim_base}")
39
+ print(f" 2. Enter pair code: {pair_code}")
40
+ print(f" or open directly: {claim_url}")
41
+ _print_qr(claim_url)
42
+ print()
43
+
44
+
45
+ def _print_qr(data: str) -> None:
46
+ try:
47
+ import qrcode
48
+ except ImportError:
49
+ return
50
+ qr = qrcode.QRCode(border=1)
51
+ qr.add_data(data)
52
+ qr.make(fit=True)
53
+ qr.print_ascii(invert=True)
54
+
55
+
56
+ async def _do_pair(settings: Settings) -> str:
57
+ pair_code, expires_in = await start_pairing(settings)
58
+ _print_pairing(settings, pair_code)
59
+ print(f" Waiting for confirmation (expires in {expires_in}s)...")
60
+ deadline = asyncio.get_running_loop().time() + expires_in
61
+ token = await poll_for_token(settings, pair_code, deadline=deadline)
62
+ store.save(
63
+ settings.credentials_path,
64
+ {"node_token": token, "node_name": settings.node_name, "bridge_url": settings.bridge_url},
65
+ )
66
+ print(f" Paired. Credentials saved to {settings.credentials_path}")
67
+ return token
68
+
69
+
70
+ async def _run_connection(settings: Settings, token: str) -> None:
71
+ connection = BridgeConnection(settings, token)
72
+ print(f" Coding Bridge agent running. Node: {settings.node_name}. Press Ctrl-C to stop.")
73
+ try:
74
+ await connection.run()
75
+ finally:
76
+ await connection.aclose()
77
+
78
+
79
+ def cmd_pair(args: argparse.Namespace) -> None:
80
+ settings = _build_settings(args)
81
+ try:
82
+ asyncio.run(_do_pair(settings))
83
+ except PairingError as exc:
84
+ print(f"Pairing failed: {exc}", file=sys.stderr)
85
+ raise SystemExit(1) from exc
86
+
87
+
88
+ def cmd_run(args: argparse.Namespace) -> None:
89
+ settings = _build_settings(args)
90
+ creds = store.load(settings.credentials_path)
91
+ if not creds or not creds.get("node_token"):
92
+ print("Not paired. Run `coding-bridge-agent pair` first.", file=sys.stderr)
93
+ raise SystemExit(1)
94
+ asyncio.run(_run_connection(settings, creds["node_token"]))
95
+
96
+
97
+ def cmd_up(args: argparse.Namespace) -> None:
98
+ settings = _build_settings(args)
99
+ creds = store.load(settings.credentials_path)
100
+ token = creds.get("node_token") if creds else None
101
+
102
+ async def _go() -> None:
103
+ nonlocal token
104
+ if not token:
105
+ token = await _do_pair(settings)
106
+ await _run_connection(settings, token)
107
+
108
+ try:
109
+ asyncio.run(_go())
110
+ except PairingError as exc:
111
+ print(f"Pairing failed: {exc}", file=sys.stderr)
112
+ raise SystemExit(1) from exc
113
+
114
+
115
+ def cmd_logout(args: argparse.Namespace) -> None:
116
+ settings = _build_settings(args)
117
+ removed = store.clear(settings.credentials_path)
118
+ print("Credentials removed." if removed else "No credentials found.")
119
+
120
+
121
+ def cmd_status(args: argparse.Namespace) -> None:
122
+ settings = _build_settings(args)
123
+ creds = store.load(settings.credentials_path)
124
+ paired = bool(creds and creds.get("node_token"))
125
+ print(f"Bridge URL : {settings.bridge_url}")
126
+ print(f"Node name : {settings.node_name}")
127
+ print(f"Config dir : {settings.config_dir}")
128
+ print(f"Paired : {'yes' if paired else 'no'}")
129
+
130
+
131
+ def _add_run_args(parser: argparse.ArgumentParser) -> None:
132
+ parser.add_argument("--model", help="Default Claude model for new sessions")
133
+ parser.add_argument("--cwd", help="Default working directory for new sessions")
134
+ parser.add_argument(
135
+ "--permission-timeout",
136
+ type=float,
137
+ dest="permission_timeout",
138
+ help="Seconds to wait for a permission decision (0 = forever)",
139
+ )
140
+
141
+
142
+ def main(argv: list[str] | None = None) -> None:
143
+ common = argparse.ArgumentParser(add_help=False)
144
+ common.add_argument("--bridge-url", default=argparse.SUPPRESS, help="coding-bridge base URL")
145
+ common.add_argument("--name", default=argparse.SUPPRESS, help="Display name for this node")
146
+ common.add_argument(
147
+ "--config-dir", default=argparse.SUPPRESS, help="Where credentials are stored"
148
+ )
149
+
150
+ parser = argparse.ArgumentParser(
151
+ prog="coding-bridge-agent",
152
+ description="Run Claude Code on this machine, driven from the AceDataCloud web app.",
153
+ parents=[common],
154
+ )
155
+ parser.set_defaults(func=cmd_up)
156
+
157
+ sub = parser.add_subparsers(dest="command")
158
+
159
+ p_up = sub.add_parser("up", help="Pair if needed, then run (default)", parents=[common])
160
+ _add_run_args(p_up)
161
+ p_up.set_defaults(func=cmd_up)
162
+
163
+ sub.add_parser("pair", help="Pair this machine and exit", parents=[common]).set_defaults(
164
+ func=cmd_pair
165
+ )
166
+
167
+ p_run = sub.add_parser("run", help="Run using stored credentials", parents=[common])
168
+ _add_run_args(p_run)
169
+ p_run.set_defaults(func=cmd_run)
170
+
171
+ sub.add_parser(
172
+ "status", help="Show configuration and pairing state", parents=[common]
173
+ ).set_defaults(func=cmd_status)
174
+ sub.add_parser("logout", help="Remove stored credentials", parents=[common]).set_defaults(
175
+ func=cmd_logout
176
+ )
177
+
178
+ args = parser.parse_args(argv)
179
+ logging.basicConfig(
180
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
181
+ )
182
+ try:
183
+ args.func(args)
184
+ except KeyboardInterrupt:
185
+ print("\nStopped.")
@@ -0,0 +1,82 @@
1
+ """Runtime configuration for the node daemon."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import socket
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ DEFAULT_BRIDGE_URL = "https://coding-bridge.acedata.cloud"
10
+ DEFAULT_CONFIG_DIR = "~/.ace-bridge"
11
+ DEFAULT_CLAIM_URL = "https://nexior.acedata.cloud/bridge?code={code}"
12
+
13
+
14
+ def _default_node_name() -> str:
15
+ try:
16
+ return socket.gethostname() or "node"
17
+ except OSError:
18
+ return "node"
19
+
20
+
21
+ @dataclass
22
+ class Settings:
23
+ """All tunables, sourced from env or CLI flags."""
24
+
25
+ bridge_url: str = DEFAULT_BRIDGE_URL
26
+ node_name: str = ""
27
+ config_dir: Path = Path(DEFAULT_CONFIG_DIR)
28
+ heartbeat_interval: float = 15.0
29
+ reconnect_min: float = 1.0
30
+ reconnect_max: float = 30.0
31
+ permission_timeout: float = 300.0 # 0 → wait indefinitely for the user
32
+ default_cwd: str = ""
33
+ default_model: str | None = None
34
+ claim_url_template: str = DEFAULT_CLAIM_URL
35
+
36
+ def __post_init__(self) -> None:
37
+ if not self.node_name:
38
+ self.node_name = _default_node_name()
39
+ if not self.default_cwd:
40
+ self.default_cwd = os.getcwd()
41
+ self.config_dir = Path(self.config_dir).expanduser()
42
+
43
+ @property
44
+ def _base(self) -> str:
45
+ return self.bridge_url.rstrip("/")
46
+
47
+ @property
48
+ def ws_node_url(self) -> str:
49
+ base = self._base
50
+ if base.startswith("https://"):
51
+ return "wss://" + base[len("https://") :] + "/ws/node"
52
+ if base.startswith("http://"):
53
+ return "ws://" + base[len("http://") :] + "/ws/node"
54
+ return base + "/ws/node"
55
+
56
+ @property
57
+ def pair_start_url(self) -> str:
58
+ return f"{self._base}/pair/start"
59
+
60
+ @property
61
+ def pair_poll_url(self) -> str:
62
+ return f"{self._base}/pair/poll"
63
+
64
+ @property
65
+ def credentials_path(self) -> Path:
66
+ return self.config_dir / "credentials.json"
67
+
68
+ @classmethod
69
+ def from_env(cls) -> Settings:
70
+ def _f(name: str, default: float) -> float:
71
+ raw = os.environ.get(name)
72
+ return float(raw) if raw else default
73
+
74
+ return cls(
75
+ bridge_url=os.environ.get("CODING_BRIDGE_URL", DEFAULT_BRIDGE_URL),
76
+ node_name=os.environ.get("CODING_BRIDGE_NODE_NAME", ""),
77
+ config_dir=Path(os.environ.get("CODING_BRIDGE_CONFIG_DIR", DEFAULT_CONFIG_DIR)),
78
+ heartbeat_interval=_f("CODING_BRIDGE_HEARTBEAT_INTERVAL", 15.0),
79
+ permission_timeout=_f("CODING_BRIDGE_PERMISSION_TIMEOUT", 300.0),
80
+ default_model=os.environ.get("CODING_BRIDGE_MODEL") or None,
81
+ claim_url_template=os.environ.get("CODING_BRIDGE_CLAIM_URL", DEFAULT_CLAIM_URL),
82
+ )
@@ -0,0 +1,227 @@
1
+ """WebSocket client toward the coding-bridge relay.
2
+
3
+ Maintains a single outbound connection (``/ws/node?token=...``), heartbeats every
4
+ ``heartbeat_interval`` seconds, reconnects with backoff, and dispatches browser
5
+ commands to local sessions. The node never accepts inbound connections.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+
13
+ import websockets
14
+
15
+ from . import protocol
16
+ from .config import Settings
17
+ from .protocol import Action, Event, event_payload
18
+ from .providers import default_provider_factory
19
+ from .providers.base import ProviderFactory
20
+ from .session import Session
21
+
22
+ logger = logging.getLogger("coding-bridge-agent.connection")
23
+
24
+
25
+ class AuthFailed(Exception):
26
+ """The bridge rejected the node token; the user must re-pair."""
27
+
28
+
29
+ class BridgeConnection:
30
+ def __init__(
31
+ self,
32
+ settings: Settings,
33
+ node_token: str,
34
+ *,
35
+ provider_factory: ProviderFactory | None = None,
36
+ ) -> None:
37
+ self.settings = settings
38
+ self.node_token = node_token
39
+ self.provider_factory = provider_factory or default_provider_factory(settings)
40
+ self.sessions: dict[str, Session] = {}
41
+ self._ws: websockets.WebSocketClientProtocol | None = None
42
+ self._stop = asyncio.Event()
43
+
44
+ def capabilities(self) -> list[str]:
45
+ return ["claude"]
46
+
47
+ def stop(self) -> None:
48
+ self._stop.set()
49
+
50
+ async def run(self) -> None:
51
+ delay = self.settings.reconnect_min
52
+ while not self._stop.is_set():
53
+ try:
54
+ await self._connect_once()
55
+ delay = self.settings.reconnect_min
56
+ except AuthFailed:
57
+ logger.error("node token rejected by bridge; re-run `coding-bridge-agent pair`")
58
+ return
59
+ except asyncio.CancelledError:
60
+ raise
61
+ except Exception as exc: # noqa: BLE001 - keep retrying on transient errors
62
+ logger.warning("bridge connection error: %s", exc)
63
+ if self._stop.is_set():
64
+ break
65
+ await asyncio.sleep(delay)
66
+ delay = min(delay * 2, self.settings.reconnect_max)
67
+
68
+ async def _connect_once(self) -> None:
69
+ url = f"{self.settings.ws_node_url}?token={self.node_token}"
70
+ try:
71
+ async with websockets.connect(
72
+ url, max_size=None, ping_interval=20, ping_timeout=20
73
+ ) as ws:
74
+ self._ws = ws
75
+ logger.info("connected to bridge as node %s", self.node_token[:12])
76
+ heartbeat = asyncio.create_task(self._heartbeat())
77
+ try:
78
+ async for raw in ws:
79
+ await self._on_raw(raw)
80
+ finally:
81
+ heartbeat.cancel()
82
+ self._ws = None
83
+ except websockets.exceptions.ConnectionClosed as exc:
84
+ if getattr(exc, "code", None) == 4401:
85
+ raise AuthFailed() from exc
86
+ raise
87
+ except Exception as exc: # noqa: BLE001 - classify handshake auth failures
88
+ if _is_auth_error(exc):
89
+ raise AuthFailed() from exc
90
+ raise
91
+
92
+ async def _heartbeat(self) -> None:
93
+ try:
94
+ while True:
95
+ await asyncio.sleep(self.settings.heartbeat_interval)
96
+ await self._send_envelope(
97
+ protocol.envelope(
98
+ protocol.NODE_HEARTBEAT, {"capabilities": self.capabilities()}
99
+ )
100
+ )
101
+ except (asyncio.CancelledError, websockets.exceptions.ConnectionClosed):
102
+ return
103
+
104
+ async def _send_envelope(self, env: dict) -> None:
105
+ ws = self._ws
106
+ if ws is None:
107
+ return
108
+ await ws.send(json.dumps(env))
109
+
110
+ async def send_payload(self, payload: dict) -> None:
111
+ """Send an inner event payload toward the owner's browsers."""
112
+ await self._send_envelope(
113
+ protocol.envelope(protocol.NODE_TO_BROWSER, payload, from_node=self.node_token)
114
+ )
115
+
116
+ async def _on_raw(self, raw: str | bytes) -> None:
117
+ try:
118
+ message = json.loads(raw)
119
+ except (json.JSONDecodeError, TypeError):
120
+ return
121
+ msg_type = message.get("type")
122
+ if msg_type == protocol.BROWSER_TO_NODE:
123
+ await self._dispatch(message.get("payload") or {})
124
+ elif msg_type == protocol.NODE_REGISTERED:
125
+ logger.info("registered with bridge")
126
+
127
+ async def _dispatch(self, payload: dict) -> None:
128
+ action = payload.get("action")
129
+ session_id = payload.get("session_id")
130
+ try:
131
+ if action == Action.SESSION_START:
132
+ await self._start_session(payload)
133
+ elif action == Action.SESSION_SEND:
134
+ await self._send_to_session(session_id, payload.get("prompt", ""))
135
+ elif action == Action.SESSION_INTERRUPT:
136
+ await self._interrupt_session(session_id)
137
+ elif action == Action.SESSION_CLOSE:
138
+ await self._close_session(session_id)
139
+ elif action == Action.PERMISSION_RESOLVE:
140
+ self._resolve_permission(payload)
141
+ elif action == Action.SESSIONS_LIST:
142
+ await self._send_snapshot()
143
+ elif action == Action.PING:
144
+ await self.send_payload(event_payload(Event.PONG))
145
+ else:
146
+ await self.send_payload(
147
+ event_payload(
148
+ Event.SESSION_ERROR, session_id, message=f"unknown action: {action}"
149
+ )
150
+ )
151
+ except Exception as exc: # noqa: BLE001 - report, never crash the node
152
+ logger.warning("dispatch error (%s): %s", action, exc)
153
+ await self.send_payload(
154
+ event_payload(Event.SESSION_ERROR, session_id, message=str(exc))
155
+ )
156
+
157
+ async def _start_session(self, payload: dict) -> None:
158
+ session_id = payload.get("session_id")
159
+ if not session_id:
160
+ await self.send_payload(
161
+ event_payload(Event.SESSION_ERROR, None, message="session_id required")
162
+ )
163
+ return
164
+ existing = self.sessions.get(session_id)
165
+ if existing is not None:
166
+ await existing.send(payload.get("prompt", ""))
167
+ return
168
+ session = Session(
169
+ session_id,
170
+ self.provider_factory,
171
+ self.send_payload,
172
+ self.settings,
173
+ cwd=payload.get("cwd") or self.settings.default_cwd,
174
+ model=payload.get("model") or self.settings.default_model,
175
+ permission_mode=payload.get("permission_mode") or "default",
176
+ )
177
+ self.sessions[session_id] = session
178
+ await session.start(payload.get("prompt", ""))
179
+
180
+ async def _send_to_session(self, session_id: str | None, prompt: str) -> None:
181
+ session = self.sessions.get(session_id) if session_id else None
182
+ if session is None:
183
+ await self.send_payload(
184
+ event_payload(Event.SESSION_ERROR, session_id, message="unknown session")
185
+ )
186
+ return
187
+ await session.send(prompt)
188
+
189
+ async def _interrupt_session(self, session_id: str | None) -> None:
190
+ session = self.sessions.get(session_id) if session_id else None
191
+ if session is not None:
192
+ await session.interrupt()
193
+
194
+ async def _close_session(self, session_id: str | None) -> None:
195
+ session = self.sessions.pop(session_id, None) if session_id else None
196
+ if session is not None:
197
+ await session.close()
198
+
199
+ def _resolve_permission(self, payload: dict) -> None:
200
+ request_id = payload.get("request_id")
201
+ decision = "allow" if payload.get("decision") == "allow" else "deny"
202
+ if not request_id:
203
+ return
204
+ for session in self.sessions.values():
205
+ if session.resolve_permission(request_id, decision):
206
+ break
207
+
208
+ async def _send_snapshot(self) -> None:
209
+ await self.send_payload(
210
+ event_payload(
211
+ Event.SESSIONS_SNAPSHOT,
212
+ sessions=[s.info() for s in self.sessions.values()],
213
+ )
214
+ )
215
+
216
+ async def aclose(self) -> None:
217
+ for session in list(self.sessions.values()):
218
+ await session.close()
219
+ self.sessions.clear()
220
+
221
+
222
+ def _is_auth_error(exc: Exception) -> bool:
223
+ code = getattr(exc, "status_code", None) or getattr(exc, "status", None)
224
+ if code in (401, 403):
225
+ return True
226
+ response = getattr(exc, "response", None)
227
+ return response is not None and getattr(response, "status_code", None) in (401, 403)
@@ -0,0 +1,52 @@
1
+ """Device-flow pairing client.
2
+
3
+ The node calls ``POST /pair/start`` to obtain a short pair code, shows it to the
4
+ user, then polls ``POST /pair/poll`` until the user claims it from Nexior (which
5
+ calls ``/pair/claim`` with their Ace JWT). The poll then returns the node_token.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ import httpx
12
+
13
+ from .config import Settings
14
+
15
+
16
+ class PairingError(Exception):
17
+ pass
18
+
19
+
20
+ async def start_pairing(settings: Settings) -> tuple[str, int]:
21
+ async with httpx.AsyncClient(timeout=15) as client:
22
+ response = await client.post(
23
+ settings.pair_start_url, json={"node_name": settings.node_name}
24
+ )
25
+ if response.status_code != 200:
26
+ raise PairingError(f"pair/start failed: HTTP {response.status_code}")
27
+ data = response.json()
28
+ return data["pair_code"], int(data.get("expires_in", 600))
29
+
30
+
31
+ async def poll_for_token(
32
+ settings: Settings,
33
+ pair_code: str,
34
+ *,
35
+ interval: float = 2.0,
36
+ deadline: float | None = None,
37
+ ) -> str:
38
+ async with httpx.AsyncClient(timeout=15) as client:
39
+ while True:
40
+ response = await client.post(settings.pair_poll_url, json={"pair_code": pair_code})
41
+ if response.status_code == 200:
42
+ data = response.json()
43
+ status = data.get("status")
44
+ if status == "ready" and data.get("node_token"):
45
+ return data["node_token"]
46
+ if status in {"expired", "consumed"}:
47
+ raise PairingError(f"pairing {status}")
48
+ elif response.status_code == 404:
49
+ raise PairingError("pairing expired")
50
+ if deadline is not None and asyncio.get_running_loop().time() > deadline:
51
+ raise PairingError("pairing timed out")
52
+ await asyncio.sleep(interval)
@@ -0,0 +1,47 @@
1
+ """Pending tool-approval registry.
2
+
3
+ Each in-flight ``can_use_tool`` call parks on a future keyed by request id. The
4
+ browser's ``permission.resolve`` (or a timeout) settles it. This is the seam
5
+ that turns a local agent prompt into a remote approval.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import Literal
11
+
12
+ Decision = Literal["allow", "deny"]
13
+
14
+
15
+ class PermissionBroker:
16
+ def __init__(self) -> None:
17
+ self._pending: dict[str, asyncio.Future[Decision]] = {}
18
+
19
+ def pending_ids(self) -> list[str]:
20
+ return list(self._pending)
21
+
22
+ async def request(self, request_id: str, timeout: float | None = None) -> Decision:
23
+ """Block until the request is resolved; deny on timeout."""
24
+ loop = asyncio.get_running_loop()
25
+ future: asyncio.Future[Decision] = loop.create_future()
26
+ self._pending[request_id] = future
27
+ try:
28
+ if timeout and timeout > 0:
29
+ return await asyncio.wait_for(future, timeout)
30
+ return await future
31
+ except (TimeoutError, asyncio.TimeoutError):
32
+ return "deny"
33
+ finally:
34
+ self._pending.pop(request_id, None)
35
+
36
+ def resolve(self, request_id: str, decision: Decision) -> bool:
37
+ future = self._pending.get(request_id)
38
+ if future is None or future.done():
39
+ return False
40
+ future.set_result("allow" if decision == "allow" else "deny")
41
+ return True
42
+
43
+ def cancel_all(self, decision: Decision = "deny") -> None:
44
+ for future in list(self._pending.values()):
45
+ if not future.done():
46
+ future.set_result(decision)
47
+ self._pending.clear()
@@ -0,0 +1,78 @@
1
+ """WebSocket protocol shared with the coding-bridge relay.
2
+
3
+ The outer envelope and its ``type`` constants mirror coding-bridge's
4
+ ``worker/app/protocol.py`` exactly — the bridge routes on ``type`` and forwards
5
+ ``payload`` verbatim. The inner ``Action`` / ``Event`` sub-protocol is opaque to
6
+ the bridge and is carried inside ``payload`` between browser and node.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import uuid
12
+ from typing import Any
13
+
14
+ PROTOCOL_VERSION = 1
15
+
16
+ # --- Outer envelope types (must match coding-bridge) -----------------------
17
+ BROWSER_TO_NODE = "browser.to_node"
18
+ BROWSER_LIST_NODES = "browser.list_nodes"
19
+ NODE_TO_BROWSER = "node.to_browser"
20
+ NODES_SNAPSHOT = "nodes.snapshot"
21
+ NODE_STATUS = "node.status"
22
+ ERROR = "error"
23
+ NODE_REGISTERED = "node.registered"
24
+ NODE_HEARTBEAT = "node.heartbeat"
25
+ NODE_HEARTBEAT_ACK = "node.heartbeat_ack"
26
+
27
+
28
+ def envelope(
29
+ message_type: str, payload: dict[str, Any] | None = None, **extra: Any
30
+ ) -> dict[str, Any]:
31
+ """Build a protocol envelope with an id and millisecond timestamp."""
32
+ message: dict[str, Any] = {
33
+ "v": PROTOCOL_VERSION,
34
+ "id": uuid.uuid4().hex,
35
+ "ts": int(time.time() * 1000),
36
+ "type": message_type,
37
+ "payload": payload or {},
38
+ }
39
+ message.update(extra)
40
+ return message
41
+
42
+
43
+ class Action:
44
+ """Browser → node commands (inside ``payload``)."""
45
+
46
+ SESSION_START = "session.start"
47
+ SESSION_SEND = "session.send"
48
+ SESSION_INTERRUPT = "session.interrupt"
49
+ SESSION_CLOSE = "session.close"
50
+ PERMISSION_RESOLVE = "permission.resolve"
51
+ SESSIONS_LIST = "sessions.list"
52
+ PING = "ping"
53
+
54
+
55
+ class Event:
56
+ """Node → browser events (inside ``payload``)."""
57
+
58
+ SESSION_STARTED = "session.started"
59
+ SESSION_TEXT = "session.text"
60
+ SESSION_THINKING = "session.thinking"
61
+ SESSION_TOOL_USE = "session.tool_use"
62
+ SESSION_TOOL_RESULT = "session.tool_result"
63
+ PERMISSION_REQUEST = "permission.request"
64
+ PERMISSION_RESOLVED = "permission.resolved"
65
+ SESSION_RESULT = "session.result"
66
+ SESSION_ERROR = "session.error"
67
+ SESSION_CLOSED = "session.closed"
68
+ SESSIONS_SNAPSHOT = "sessions.snapshot"
69
+ PONG = "pong"
70
+
71
+
72
+ def event_payload(event: str, session_id: str | None = None, **fields: Any) -> dict[str, Any]:
73
+ """Build an inner event payload for node → browser traffic."""
74
+ payload: dict[str, Any] = {"event": event}
75
+ if session_id is not None:
76
+ payload["session_id"] = session_id
77
+ payload.update(fields)
78
+ return payload