coding-bridge 2026.6.20.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.
- coding_bridge/__init__.py +8 -0
- coding_bridge/__main__.py +7 -0
- coding_bridge/attachments.py +155 -0
- coding_bridge/capabilities.py +293 -0
- coding_bridge/cli.py +248 -0
- coding_bridge/config.py +132 -0
- coding_bridge/connection.py +534 -0
- coding_bridge/fs.py +84 -0
- coding_bridge/history.py +584 -0
- coding_bridge/images.py +95 -0
- coding_bridge/locking.py +88 -0
- coding_bridge/logs.py +99 -0
- coding_bridge/pairing.py +52 -0
- coding_bridge/permissions.py +86 -0
- coding_bridge/protocol.py +152 -0
- coding_bridge/providers/__init__.py +29 -0
- coding_bridge/providers/base.py +83 -0
- coding_bridge/providers/claude.py +671 -0
- coding_bridge/providers/codex.py +447 -0
- coding_bridge/session.py +361 -0
- coding_bridge/session_meta.py +49 -0
- coding_bridge/store.py +37 -0
- coding_bridge-2026.6.20.0.dist-info/METADATA +187 -0
- coding_bridge-2026.6.20.0.dist-info/RECORD +27 -0
- coding_bridge-2026.6.20.0.dist-info/WHEEL +4 -0
- coding_bridge-2026.6.20.0.dist-info/entry_points.txt +2 -0
- coding_bridge-2026.6.20.0.dist-info/licenses/LICENSE +661 -0
coding_bridge/cli.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
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 capabilities, logs, store
|
|
11
|
+
from .config import Settings
|
|
12
|
+
from .connection import BridgeConnection
|
|
13
|
+
from .locking import AlreadyRunning, SingleInstance
|
|
14
|
+
from .pairing import PairingError, poll_for_token, start_pairing
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_settings(args: argparse.Namespace) -> Settings:
|
|
18
|
+
settings = Settings.from_env()
|
|
19
|
+
if getattr(args, "bridge_url", None):
|
|
20
|
+
settings.bridge_url = args.bridge_url
|
|
21
|
+
if getattr(args, "name", None):
|
|
22
|
+
settings.node_name = args.name
|
|
23
|
+
if getattr(args, "config_dir", None):
|
|
24
|
+
settings.config_dir = Path(args.config_dir).expanduser()
|
|
25
|
+
settings.log_dir = settings.config_dir / "logs"
|
|
26
|
+
if getattr(args, "model", None):
|
|
27
|
+
settings.default_model = args.model
|
|
28
|
+
if getattr(args, "claude_path", None):
|
|
29
|
+
settings.claude_path = args.claude_path
|
|
30
|
+
if getattr(args, "codex_path", None):
|
|
31
|
+
settings.codex_path = args.codex_path
|
|
32
|
+
if getattr(args, "cwd", None):
|
|
33
|
+
settings.default_cwd = args.cwd
|
|
34
|
+
if getattr(args, "permission_timeout", None) is not None:
|
|
35
|
+
settings.permission_timeout = args.permission_timeout
|
|
36
|
+
if getattr(args, "log_dir", None):
|
|
37
|
+
settings.log_dir = Path(args.log_dir).expanduser()
|
|
38
|
+
if getattr(args, "log_level", None):
|
|
39
|
+
settings.log_level = args.log_level
|
|
40
|
+
if getattr(args, "verbose", False):
|
|
41
|
+
settings.log_level = "DEBUG"
|
|
42
|
+
return settings
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_pairing(settings: Settings, pair_code: str) -> None:
|
|
46
|
+
claim_base = settings.claim_url_template.split("?", 1)[0]
|
|
47
|
+
claim_url = settings.claim_url_template.format(code=pair_code)
|
|
48
|
+
print()
|
|
49
|
+
print(" Pair this machine with your Ace account:")
|
|
50
|
+
print(f" 1. Open {claim_base}")
|
|
51
|
+
print(f" 2. Enter pair code: {pair_code}")
|
|
52
|
+
print(f" or open directly: {claim_url}")
|
|
53
|
+
_print_qr(claim_url)
|
|
54
|
+
print()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _print_qr(data: str) -> None:
|
|
58
|
+
try:
|
|
59
|
+
import qrcode
|
|
60
|
+
except ImportError:
|
|
61
|
+
return
|
|
62
|
+
qr = qrcode.QRCode(border=1)
|
|
63
|
+
qr.add_data(data)
|
|
64
|
+
qr.make(fit=True)
|
|
65
|
+
qr.print_ascii(invert=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _do_pair(settings: Settings) -> str:
|
|
69
|
+
pair_code, expires_in = await start_pairing(settings)
|
|
70
|
+
_print_pairing(settings, pair_code)
|
|
71
|
+
print(f" Waiting for confirmation (expires in {expires_in}s)...")
|
|
72
|
+
deadline = asyncio.get_running_loop().time() + expires_in
|
|
73
|
+
token = await poll_for_token(settings, pair_code, deadline=deadline)
|
|
74
|
+
store.save(
|
|
75
|
+
settings.credentials_path,
|
|
76
|
+
{"node_token": token, "node_name": settings.node_name, "bridge_url": settings.bridge_url},
|
|
77
|
+
)
|
|
78
|
+
print(f" Paired. Credentials saved to {settings.credentials_path}")
|
|
79
|
+
return token
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _run_connection(settings: Settings, token: str) -> None:
|
|
83
|
+
# Refuse to start a second daemon for this device: two agents sharing one
|
|
84
|
+
# node token fight over the relay slot and tear down every session.
|
|
85
|
+
lock = SingleInstance(settings.config_dir / "agent.lock")
|
|
86
|
+
try:
|
|
87
|
+
lock.acquire()
|
|
88
|
+
except AlreadyRunning:
|
|
89
|
+
print(
|
|
90
|
+
"Another coding-bridge is already running for this device.\n"
|
|
91
|
+
"Stop it before starting a new one — two instances fight over the\n"
|
|
92
|
+
"connection and break every session. If it autostarts (a service or\n"
|
|
93
|
+
"scheduled task), do not also run it manually.",
|
|
94
|
+
file=sys.stderr,
|
|
95
|
+
)
|
|
96
|
+
raise SystemExit(1) from None
|
|
97
|
+
# A daemon launched outside the user's login shell (no nvm/volta/.local on
|
|
98
|
+
# PATH) can't see `claude`/`codex` even when installed; surface the resolved
|
|
99
|
+
# dirs onto PATH so the SDK and `codex exec` actually find them.
|
|
100
|
+
added = capabilities.ensure_clis_on_path(settings)
|
|
101
|
+
if added:
|
|
102
|
+
logging.getLogger(logs.ROOT_LOGGER).info("added to PATH for CLI discovery: %s", added)
|
|
103
|
+
connection = BridgeConnection(settings, token)
|
|
104
|
+
print(f" Coding Bridge agent running. Node: {settings.node_name}. Press Ctrl-C to stop.")
|
|
105
|
+
try:
|
|
106
|
+
await connection.run()
|
|
107
|
+
finally:
|
|
108
|
+
await connection.aclose()
|
|
109
|
+
lock.release()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_pair(args: argparse.Namespace) -> None:
|
|
113
|
+
settings = _build_settings(args)
|
|
114
|
+
try:
|
|
115
|
+
asyncio.run(_do_pair(settings))
|
|
116
|
+
except PairingError as exc:
|
|
117
|
+
print(f"Pairing failed: {exc}", file=sys.stderr)
|
|
118
|
+
raise SystemExit(1) from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
122
|
+
settings = _build_settings(args)
|
|
123
|
+
creds = store.load(settings.credentials_path)
|
|
124
|
+
if not creds or not creds.get("node_token"):
|
|
125
|
+
print("Not paired. Run `coding-bridge pair` first.", file=sys.stderr)
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
asyncio.run(_run_connection(settings, creds["node_token"]))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_up(args: argparse.Namespace) -> None:
|
|
131
|
+
settings = _build_settings(args)
|
|
132
|
+
creds = store.load(settings.credentials_path)
|
|
133
|
+
token = creds.get("node_token") if creds else None
|
|
134
|
+
|
|
135
|
+
async def _go() -> None:
|
|
136
|
+
nonlocal token
|
|
137
|
+
if not token:
|
|
138
|
+
token = await _do_pair(settings)
|
|
139
|
+
await _run_connection(settings, token)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
asyncio.run(_go())
|
|
143
|
+
except PairingError as exc:
|
|
144
|
+
print(f"Pairing failed: {exc}", file=sys.stderr)
|
|
145
|
+
raise SystemExit(1) from exc
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_logout(args: argparse.Namespace) -> None:
|
|
149
|
+
settings = _build_settings(args)
|
|
150
|
+
removed = store.clear(settings.credentials_path)
|
|
151
|
+
print("Credentials removed." if removed else "No credentials found.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
155
|
+
settings = _build_settings(args)
|
|
156
|
+
creds = store.load(settings.credentials_path)
|
|
157
|
+
paired = bool(creds and creds.get("node_token"))
|
|
158
|
+
print(f"Bridge URL : {settings.bridge_url}")
|
|
159
|
+
print(f"Node name : {settings.node_name}")
|
|
160
|
+
print(f"Config dir : {settings.config_dir}")
|
|
161
|
+
print(f"Paired : {'yes' if paired else 'no'}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _add_run_args(parser: argparse.ArgumentParser) -> None:
|
|
165
|
+
parser.add_argument("--model", help="Default Claude model for new sessions")
|
|
166
|
+
parser.add_argument("--cwd", help="Default working directory for new sessions")
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--claude-path",
|
|
169
|
+
dest="claude_path",
|
|
170
|
+
help="Path to the claude CLI (when PATH can't find it, e.g. nvm installs)",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--codex-path",
|
|
174
|
+
dest="codex_path",
|
|
175
|
+
help="Path to the codex CLI (when PATH can't find it)",
|
|
176
|
+
)
|
|
177
|
+
parser.add_argument(
|
|
178
|
+
"--permission-timeout",
|
|
179
|
+
type=float,
|
|
180
|
+
dest="permission_timeout",
|
|
181
|
+
help="Seconds to wait for a permission decision (0 = forever)",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main(argv: list[str] | None = None) -> None:
|
|
186
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
187
|
+
common.add_argument("--bridge-url", default=argparse.SUPPRESS, help="coding-bridge base URL")
|
|
188
|
+
common.add_argument("--name", default=argparse.SUPPRESS, help="Display name for this node")
|
|
189
|
+
common.add_argument(
|
|
190
|
+
"--config-dir", default=argparse.SUPPRESS, help="Where credentials are stored"
|
|
191
|
+
)
|
|
192
|
+
common.add_argument(
|
|
193
|
+
"-v",
|
|
194
|
+
"--verbose",
|
|
195
|
+
action="store_true",
|
|
196
|
+
default=argparse.SUPPRESS,
|
|
197
|
+
help="Enable debug logging",
|
|
198
|
+
)
|
|
199
|
+
common.add_argument(
|
|
200
|
+
"--log-level",
|
|
201
|
+
dest="log_level",
|
|
202
|
+
default=argparse.SUPPRESS,
|
|
203
|
+
help="Log level (DEBUG, INFO, WARNING, ERROR)",
|
|
204
|
+
)
|
|
205
|
+
common.add_argument(
|
|
206
|
+
"--log-dir",
|
|
207
|
+
dest="log_dir",
|
|
208
|
+
default=argparse.SUPPRESS,
|
|
209
|
+
help="Directory for rotating log files",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
parser = argparse.ArgumentParser(
|
|
213
|
+
prog="coding-bridge",
|
|
214
|
+
description="Run Claude Code on this machine, driven from the AceDataCloud web app.",
|
|
215
|
+
parents=[common],
|
|
216
|
+
)
|
|
217
|
+
parser.set_defaults(func=cmd_up)
|
|
218
|
+
|
|
219
|
+
sub = parser.add_subparsers(dest="command")
|
|
220
|
+
|
|
221
|
+
p_up = sub.add_parser("up", help="Pair if needed, then run (default)", parents=[common])
|
|
222
|
+
_add_run_args(p_up)
|
|
223
|
+
p_up.set_defaults(func=cmd_up)
|
|
224
|
+
|
|
225
|
+
sub.add_parser("pair", help="Pair this machine and exit", parents=[common]).set_defaults(
|
|
226
|
+
func=cmd_pair
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
p_run = sub.add_parser("run", help="Run using stored credentials", parents=[common])
|
|
230
|
+
_add_run_args(p_run)
|
|
231
|
+
p_run.set_defaults(func=cmd_run)
|
|
232
|
+
|
|
233
|
+
sub.add_parser(
|
|
234
|
+
"status", help="Show configuration and pairing state", parents=[common]
|
|
235
|
+
).set_defaults(func=cmd_status)
|
|
236
|
+
sub.add_parser("logout", help="Remove stored credentials", parents=[common]).set_defaults(
|
|
237
|
+
func=cmd_logout
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
args = parser.parse_args(argv)
|
|
241
|
+
settings = _build_settings(args)
|
|
242
|
+
log_path = logs.setup(settings.log_level, settings.log_dir)
|
|
243
|
+
if log_path is not None:
|
|
244
|
+
logging.getLogger(logs.ROOT_LOGGER).debug("logging to %s", log_path)
|
|
245
|
+
try:
|
|
246
|
+
args.func(args)
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
print("\nStopped.")
|
coding_bridge/config.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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://studio.acedata.cloud/coding-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
|
+
def _safe_default_cwd() -> str:
|
|
22
|
+
"""A sane default working directory.
|
|
23
|
+
|
|
24
|
+
Normally the directory the daemon was launched from. But when it runs as a
|
|
25
|
+
Windows service / from an OS launcher, ``os.getcwd()`` is ``C:\\Windows\\System32``
|
|
26
|
+
— never a place to run code in. Fall back to the user's home in that case so a
|
|
27
|
+
session that arrives without an explicit cwd (e.g. resumed-from-history when the
|
|
28
|
+
transcript carried no cwd) doesn't silently land in System32.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
cwd = Path(os.getcwd()).resolve()
|
|
32
|
+
except OSError:
|
|
33
|
+
return str(Path.home())
|
|
34
|
+
system_root = os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR")
|
|
35
|
+
if system_root:
|
|
36
|
+
try:
|
|
37
|
+
sys_dir = Path(system_root).resolve()
|
|
38
|
+
if cwd == sys_dir or sys_dir in cwd.parents:
|
|
39
|
+
return str(Path.home())
|
|
40
|
+
except OSError:
|
|
41
|
+
pass
|
|
42
|
+
return str(cwd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Settings:
|
|
47
|
+
"""All tunables, sourced from env or CLI flags."""
|
|
48
|
+
|
|
49
|
+
bridge_url: str = DEFAULT_BRIDGE_URL
|
|
50
|
+
node_name: str = ""
|
|
51
|
+
config_dir: Path = Path(DEFAULT_CONFIG_DIR)
|
|
52
|
+
heartbeat_interval: float = 15.0
|
|
53
|
+
reconnect_min: float = 1.0
|
|
54
|
+
reconnect_max: float = 30.0
|
|
55
|
+
# Remote approval may arrive via a push notification minutes after the
|
|
56
|
+
# prompt, so the window is generous. 0 → wait indefinitely for the user.
|
|
57
|
+
permission_timeout: float = 1800.0
|
|
58
|
+
turn_retry_limit: int = 1 # auto-retries when a provider subprocess crashes
|
|
59
|
+
turn_retry_backoff: float = 0.5 # seconds between turn retries
|
|
60
|
+
outbox_max: int = 5000 # max buffered node→browser events while disconnected
|
|
61
|
+
default_cwd: str = ""
|
|
62
|
+
default_model: str | None = None
|
|
63
|
+
# Explicit paths to the provider CLIs, for nodes whose daemon PATH can't see
|
|
64
|
+
# them (nvm/volta/.local installs). Empty → auto-resolve (PATH + known dirs).
|
|
65
|
+
claude_path: str | None = None
|
|
66
|
+
codex_path: str | None = None
|
|
67
|
+
claim_url_template: str = DEFAULT_CLAIM_URL
|
|
68
|
+
log_level: str = "INFO"
|
|
69
|
+
log_dir: Path | None = None
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
if not self.node_name:
|
|
73
|
+
self.node_name = _default_node_name()
|
|
74
|
+
if not self.default_cwd:
|
|
75
|
+
self.default_cwd = _safe_default_cwd()
|
|
76
|
+
self.config_dir = Path(self.config_dir).expanduser()
|
|
77
|
+
if self.log_dir is None:
|
|
78
|
+
self.log_dir = self.config_dir / "logs"
|
|
79
|
+
else:
|
|
80
|
+
self.log_dir = Path(self.log_dir).expanduser()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def _base(self) -> str:
|
|
84
|
+
return self.bridge_url.rstrip("/")
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def ws_node_url(self) -> str:
|
|
88
|
+
base = self._base
|
|
89
|
+
if base.startswith("https://"):
|
|
90
|
+
return "wss://" + base[len("https://") :] + "/ws/node"
|
|
91
|
+
if base.startswith("http://"):
|
|
92
|
+
return "ws://" + base[len("http://") :] + "/ws/node"
|
|
93
|
+
return base + "/ws/node"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def pair_start_url(self) -> str:
|
|
97
|
+
return f"{self._base}/pair/start"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def pair_poll_url(self) -> str:
|
|
101
|
+
return f"{self._base}/pair/poll"
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def credentials_path(self) -> Path:
|
|
105
|
+
return self.config_dir / "credentials.json"
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_env(cls) -> Settings:
|
|
109
|
+
def _f(name: str, default: float) -> float:
|
|
110
|
+
raw = os.environ.get(name)
|
|
111
|
+
return float(raw) if raw else default
|
|
112
|
+
|
|
113
|
+
return cls(
|
|
114
|
+
bridge_url=os.environ.get("CODING_BRIDGE_URL", DEFAULT_BRIDGE_URL),
|
|
115
|
+
node_name=os.environ.get("CODING_BRIDGE_NODE_NAME", ""),
|
|
116
|
+
config_dir=Path(os.environ.get("CODING_BRIDGE_CONFIG_DIR", DEFAULT_CONFIG_DIR)),
|
|
117
|
+
heartbeat_interval=_f("CODING_BRIDGE_HEARTBEAT_INTERVAL", 15.0),
|
|
118
|
+
permission_timeout=_f("CODING_BRIDGE_PERMISSION_TIMEOUT", 1800.0),
|
|
119
|
+
turn_retry_limit=int(_f("CODING_BRIDGE_TURN_RETRY_LIMIT", 1)),
|
|
120
|
+
turn_retry_backoff=_f("CODING_BRIDGE_TURN_RETRY_BACKOFF", 0.5),
|
|
121
|
+
outbox_max=int(_f("CODING_BRIDGE_OUTBOX_MAX", 5000)),
|
|
122
|
+
default_model=os.environ.get("CODING_BRIDGE_MODEL") or None,
|
|
123
|
+
claude_path=os.environ.get("CODING_BRIDGE_CLAUDE_PATH") or None,
|
|
124
|
+
codex_path=os.environ.get("CODING_BRIDGE_CODEX_PATH") or None,
|
|
125
|
+
claim_url_template=os.environ.get("CODING_BRIDGE_CLAIM_URL", DEFAULT_CLAIM_URL),
|
|
126
|
+
log_level=os.environ.get("CODING_BRIDGE_LOG_LEVEL", "INFO"),
|
|
127
|
+
log_dir=(
|
|
128
|
+
Path(os.environ["CODING_BRIDGE_LOG_DIR"])
|
|
129
|
+
if os.environ.get("CODING_BRIDGE_LOG_DIR")
|
|
130
|
+
else None
|
|
131
|
+
),
|
|
132
|
+
)
|