ccoding 0.2.0__py3-none-any.whl → 0.2.2__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.
ccode/config.py CHANGED
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import copy
3
4
  import json
5
+ import socket
6
+ import uuid
4
7
  from pathlib import Path
5
8
  from typing import Any
6
9
 
@@ -23,11 +26,22 @@ DEFAULT_TOGGLES: dict[str, str] = {
23
26
 
24
27
  DEFAULT_REMOTE: dict[str, Any] = {
25
28
  "enabled": False,
26
- "host": "127.0.0.1",
27
- "port": 8765,
28
- "token": "",
29
+ "mode": "local",
29
30
  "session_name": "ccode-claude",
30
31
  "reuse_session": True,
32
+ "local": {
33
+ "host": "127.0.0.1",
34
+ "port": 8765,
35
+ "token": "",
36
+ },
37
+ "server": {
38
+ "url": "",
39
+ "token": "",
40
+ "device_name": "",
41
+ "device_id": "",
42
+ "auto_connect": True,
43
+ "verify_tls": True,
44
+ },
31
45
  }
32
46
 
33
47
  _ENV_SCHEMA: dict[str, Any] = {}
@@ -74,10 +88,75 @@ def default_config() -> dict[str, Any]:
74
88
  "haiku": {"owned_by": None, "id": None},
75
89
  },
76
90
  "toggles": DEFAULT_TOGGLES.copy(),
77
- "remote": DEFAULT_REMOTE.copy(),
91
+ "remote": copy.deepcopy(DEFAULT_REMOTE),
78
92
  }
79
93
 
80
94
 
95
+ def _device_name_default() -> str:
96
+ try:
97
+ return socket.gethostname() or "ccode-device"
98
+ except OSError:
99
+ return "ccode-device"
100
+
101
+
102
+ def _new_device_id() -> str:
103
+ return f"ccode-{uuid.uuid4().hex}"
104
+
105
+
106
+ def normalize_remote(remote: Any) -> dict[str, Any]:
107
+ normalized = copy.deepcopy(DEFAULT_REMOTE)
108
+ if not isinstance(remote, dict):
109
+ return normalized
110
+
111
+ enabled = remote.get("enabled")
112
+ if isinstance(enabled, bool):
113
+ normalized["enabled"] = enabled
114
+ mode = remote.get("mode")
115
+ if isinstance(mode, str) and mode in {"local", "server"}:
116
+ normalized["mode"] = mode
117
+ session_name = remote.get("session_name")
118
+ if isinstance(session_name, str):
119
+ normalized["session_name"] = session_name
120
+ reuse_session = remote.get("reuse_session")
121
+ if isinstance(reuse_session, bool):
122
+ normalized["reuse_session"] = reuse_session
123
+
124
+ local = remote.get("local") if isinstance(remote.get("local"), dict) else {}
125
+ host = local.get("host") if isinstance(local, dict) else None
126
+ if not isinstance(host, str):
127
+ host = remote.get("host")
128
+ if isinstance(host, str):
129
+ normalized["local"]["host"] = host
130
+ port = local.get("port") if isinstance(local, dict) else None
131
+ if not isinstance(port, int):
132
+ port = remote.get("port")
133
+ if isinstance(port, int):
134
+ normalized["local"]["port"] = port
135
+ token = local.get("token") if isinstance(local, dict) else None
136
+ if not isinstance(token, str):
137
+ token = remote.get("token")
138
+ if isinstance(token, str):
139
+ normalized["local"]["token"] = token
140
+
141
+ server = remote.get("server") if isinstance(remote.get("server"), dict) else {}
142
+ if isinstance(server, dict):
143
+ for key in ("url", "token", "device_name", "device_id"):
144
+ value = server.get(key)
145
+ if isinstance(value, str):
146
+ normalized["server"][key] = value
147
+ for key in ("auto_connect", "verify_tls"):
148
+ value = server.get(key)
149
+ if isinstance(value, bool):
150
+ normalized["server"][key] = value
151
+
152
+ if normalized["mode"] == "server":
153
+ if not normalized["server"].get("device_id"):
154
+ normalized["server"]["device_id"] = _new_device_id()
155
+ if not normalized["server"].get("device_name"):
156
+ normalized["server"]["device_name"] = _device_name_default()
157
+ return normalized
158
+
159
+
81
160
  def load_config() -> dict[str, Any]:
82
161
  config = default_config()
83
162
  try:
@@ -124,31 +203,13 @@ def load_config() -> dict[str, Any]:
124
203
  if new_toggles:
125
204
  config["toggles"] = new_toggles
126
205
 
127
- remote = data.get("remote")
128
- if isinstance(remote, dict):
129
- enabled = remote.get("enabled")
130
- if isinstance(enabled, bool):
131
- config["remote"]["enabled"] = enabled
132
- host = remote.get("host")
133
- if isinstance(host, str):
134
- config["remote"]["host"] = host
135
- port = remote.get("port")
136
- if isinstance(port, int):
137
- config["remote"]["port"] = port
138
- token = remote.get("token")
139
- if isinstance(token, str):
140
- config["remote"]["token"] = token
141
- session_name = remote.get("session_name")
142
- if isinstance(session_name, str):
143
- config["remote"]["session_name"] = session_name
144
- reuse_session = remote.get("reuse_session")
145
- if isinstance(reuse_session, bool):
146
- config["remote"]["reuse_session"] = reuse_session
206
+ config["remote"] = normalize_remote(data.get("remote"))
147
207
 
148
208
  return config
149
209
 
150
210
 
151
211
  def save_config(config: dict[str, Any]) -> None:
212
+ config["remote"] = normalize_remote(config.get("remote"))
152
213
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
153
214
  CONFIG_PATH.write_text(json.dumps(config, indent=2))
154
215
 
ccode/remote.py CHANGED
@@ -1,27 +1,35 @@
1
- from __future__ import annotations
2
-
3
1
  import asyncio
4
2
  import contextlib
5
- import datetime as dt
6
- import fcntl
7
3
  import json
8
4
  import os
9
- import re
10
- import secrets
11
5
  import shutil
12
6
  import subprocess
13
7
  import sys
14
8
  import time
15
- from importlib import resources
16
9
  from typing import Any, Iterator
17
- from urllib.parse import quote
18
10
  from urllib.request import urlopen
19
11
 
20
- from fastapi import FastAPI, Header, WebSocket, WebSocketDisconnect
21
- from fastapi.responses import HTMLResponse, JSONResponse, Response
22
-
23
12
  from .api import build_claude_command, build_env
24
13
  from .config import CONFIG_DIR, CONFIG_PATH, load_config
14
+ from .remote_common import (
15
+ _new_session_id,
16
+ _sanitize_session_name,
17
+ _utc_now,
18
+ asset_text,
19
+ auth_token_matches,
20
+ file_lock,
21
+ missing_extra_message,
22
+ remote_local_host_port,
23
+ remote_local_token,
24
+ remote_local_url,
25
+ remote_mode,
26
+ remote_server_config,
27
+ remote_server_url,
28
+ rotate_log,
29
+ token_matches,
30
+ web_asset_text,
31
+ websocket_auth_token,
32
+ )
25
33
 
26
34
  REMOTE_SESSIONS_PATH = CONFIG_DIR / "remote_sessions.json"
27
35
  REMOTE_SESSIONS_LOCK_PATH = CONFIG_DIR / "remote_sessions.lock"
@@ -78,17 +86,11 @@ class TerminalBridge:
78
86
 
79
87
 
80
88
  def remote_host_port(config: dict[str, Any]) -> tuple[str, int]:
81
- remote = config.get("remote", {})
82
- return str(remote.get("host") or "127.0.0.1"), int(remote.get("port") or 8765)
89
+ return remote_local_host_port(config)
83
90
 
84
91
 
85
92
  def remote_url(config: dict[str, Any], include_token: bool = False) -> str:
86
- host, port = remote_host_port(config)
87
- url = f"http://{host}:{port}/"
88
- token = str(config.get("remote", {}).get("token") or "")
89
- if include_token and token:
90
- return f"{url}?token={quote(token)}"
91
- return url
93
+ return remote_local_url(config, include_token=include_token)
92
94
 
93
95
 
94
96
  def _check_runtime() -> str | None:
@@ -111,22 +113,6 @@ def _session_exists(session_name: str) -> bool:
111
113
  return result.returncode == 0
112
114
 
113
115
 
114
- def _sanitize_session_name(value: str) -> str:
115
- sanitized = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-")
116
- return sanitized or "ccode-claude"
117
-
118
-
119
- def _new_session_id(config: dict[str, Any]) -> str:
120
- remote = config.get("remote", {})
121
- prefix = _sanitize_session_name(str(remote.get("session_name") or "ccode-claude"))
122
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d-%H%M%S")
123
- return f"{prefix}-{stamp}-{os.getpid()}"
124
-
125
-
126
- def _utc_now() -> str:
127
- return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
128
-
129
-
130
116
  def _log_line(message: str) -> str:
131
117
  return f"[{_utc_now()}] {message}"
132
118
 
@@ -141,26 +127,15 @@ def _append_log_event(message: str) -> None:
141
127
  log_file.write(_log_line(message) + "\n")
142
128
 
143
129
 
144
- @contextlib.contextmanager
145
- def _file_lock(path: Any) -> Iterator[None]:
146
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
147
- with path.open("a+") as lock_file:
148
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
149
- try:
150
- yield
151
- finally:
152
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
153
-
154
-
155
130
  @contextlib.contextmanager
156
131
  def with_session_registry_lock() -> Iterator[None]:
157
- with _file_lock(REMOTE_SESSIONS_LOCK_PATH):
132
+ with file_lock(REMOTE_SESSIONS_LOCK_PATH):
158
133
  yield
159
134
 
160
135
 
161
136
  @contextlib.contextmanager
162
137
  def with_remote_hub_lock() -> Iterator[None]:
163
- with _file_lock(REMOTE_HUB_LOCK_PATH):
138
+ with file_lock(REMOTE_HUB_LOCK_PATH):
164
139
  yield
165
140
 
166
141
 
@@ -213,30 +188,36 @@ def _running_tmux_sessions() -> set[str]:
213
188
  def scan_remote_sessions(prune: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
214
189
  with with_session_registry_lock():
215
190
  registry = load_session_registry()
216
- sessions = registry.setdefault("sessions", {})
217
- if not isinstance(sessions, dict):
191
+ raw_sessions = registry.setdefault("sessions", {})
192
+ if not isinstance(raw_sessions, dict):
218
193
  return [], []
219
- tmux_sessions = _running_tmux_sessions()
220
- running: list[dict[str, Any]] = []
221
- dead: list[dict[str, Any]] = []
222
- dead_ids: list[str] = []
223
- for session_id, session in sessions.items():
224
- if not isinstance(session, dict):
225
- dead_ids.append(session_id)
226
- continue
227
- tmux_session = session.get("tmux_session")
228
- if isinstance(tmux_session, str) and tmux_session in tmux_sessions:
229
- running.append(session)
230
- else:
231
- dead.append(session)
232
- dead_ids.append(session_id)
233
- if prune and dead_ids:
234
- for session_id in dead_ids:
235
- sessions.pop(session_id, None)
236
- save_session_registry(registry)
237
- running.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
238
- dead.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
239
- return running, dead
194
+ session_items = list(raw_sessions.items())
195
+
196
+ tmux_sessions = _running_tmux_sessions()
197
+ running: list[dict[str, Any]] = []
198
+ dead: list[dict[str, Any]] = []
199
+ dead_ids: list[str] = []
200
+ for session_id, session in session_items:
201
+ if not isinstance(session, dict):
202
+ dead_ids.append(session_id)
203
+ continue
204
+ tmux_session = session.get("tmux_session")
205
+ if isinstance(tmux_session, str) and tmux_session in tmux_sessions:
206
+ running.append(session)
207
+ else:
208
+ dead.append(session)
209
+ dead_ids.append(session_id)
210
+ if prune and dead_ids:
211
+ with with_session_registry_lock():
212
+ registry = load_session_registry()
213
+ sessions = registry.setdefault("sessions", {})
214
+ if isinstance(sessions, dict):
215
+ for session_id in dead_ids:
216
+ sessions.pop(session_id, None)
217
+ save_session_registry(registry)
218
+ running.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
219
+ dead.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
220
+ return running, dead
240
221
 
241
222
 
242
223
  def prune_dead_sessions() -> None:
@@ -300,21 +281,7 @@ def _hub_is_running(config: dict[str, Any]) -> bool:
300
281
 
301
282
 
302
283
  def rotate_remote_server_log() -> None:
303
- try:
304
- size = REMOTE_SERVER_LOG_PATH.stat().st_size
305
- except FileNotFoundError:
306
- return
307
- if size < REMOTE_SERVER_LOG_MAX_BYTES:
308
- return
309
-
310
- for index in range(REMOTE_SERVER_LOG_BACKUPS, 0, -1):
311
- source = REMOTE_SERVER_LOG_PATH if index == 1 else REMOTE_SERVER_LOG_PATH.with_name(f"{REMOTE_SERVER_LOG_PATH.name}.{index - 1}")
312
- target = REMOTE_SERVER_LOG_PATH.with_name(f"{REMOTE_SERVER_LOG_PATH.name}.{index}")
313
- if not source.exists():
314
- continue
315
- if index == REMOTE_SERVER_LOG_BACKUPS and target.exists():
316
- target.unlink()
317
- source.replace(target)
284
+ rotate_log(REMOTE_SERVER_LOG_PATH, REMOTE_SERVER_LOG_MAX_BYTES, REMOTE_SERVER_LOG_BACKUPS)
318
285
 
319
286
 
320
287
  def ensure_remote_hub(config: dict[str, Any]) -> str | None:
@@ -366,62 +333,20 @@ def _current_remote_token() -> str:
366
333
  cached_mtime, cached_token = _TOKEN_CACHE
367
334
  if cached_mtime == mtime:
368
335
  return cached_token
369
- token = str(load_config().get("remote", {}).get("token") or "").strip()
336
+ token = remote_local_token(load_config()).strip()
370
337
  _TOKEN_CACHE = (mtime, token)
371
338
  return token
372
339
 
373
340
 
374
- def _token_matches(expected: str, provided: str | None) -> bool:
375
- value = (provided or "").strip()
376
- return bool(value) and bool(expected) and secrets.compare_digest(value, expected)
377
-
378
-
379
341
  def _valid_token(token: str | None) -> bool:
380
- return _token_matches(_current_remote_token(), token)
342
+ return token_matches(_current_remote_token(), token)
381
343
 
382
344
 
383
345
  def _valid_auth_token(token: str | None, authorization: str | None = None) -> bool:
384
- expected = _current_remote_token()
385
- if _token_matches(expected, token):
386
- return True
387
- if not authorization:
388
- return False
389
- scheme, _, value = authorization.partition(" ")
390
- return scheme.lower() == "bearer" and _token_matches(expected, value)
391
-
392
-
393
- async def _websocket_token(websocket: WebSocket) -> str | None:
394
- try:
395
- raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
396
- payload = json.loads(raw)
397
- except (asyncio.TimeoutError, json.JSONDecodeError):
398
- return None
399
- if payload.get("type") != "auth":
400
- return None
401
- token = payload.get("token")
402
- return token if isinstance(token, str) else None
403
-
404
-
405
- _ASSET_TEXT_CACHE: dict[str, str] = {}
406
-
407
-
408
- def _cached_asset_text(key: str, path: Any) -> str:
409
- cached = _ASSET_TEXT_CACHE.get(key)
410
- if cached is None:
411
- cached = path.read_text(encoding="utf-8")
412
- _ASSET_TEXT_CACHE[key] = cached
413
- return cached
346
+ return auth_token_matches(_current_remote_token(), token, authorization)
414
347
 
415
348
 
416
- def _asset_text(name: str) -> str:
417
- return _cached_asset_text(name, resources.files("ccode.web").joinpath(name))
418
-
419
-
420
- def _web_asset_text(name: str) -> str:
421
- return _cached_asset_text(f"assets/{name}", resources.files("ccode.web").joinpath("assets", name))
422
-
423
-
424
- async def _attach_websocket_to_session(websocket: WebSocket, session: dict[str, Any]) -> None:
349
+ async def _attach_websocket_to_session(websocket: Any, session: dict[str, Any]) -> None:
425
350
  session_id = str(session.get("id") or "<unknown>")
426
351
  tmux_session = session.get("tmux_session")
427
352
  if not isinstance(tmux_session, str) or not _session_exists(tmux_session):
@@ -452,7 +377,7 @@ async def _attach_websocket_to_session(websocket: WebSocket, session: dict[str,
452
377
  cols = payload.get("cols")
453
378
  if isinstance(rows, int) and isinstance(cols, int):
454
379
  await bridge.resize(rows, cols)
455
- except WebSocketDisconnect:
380
+ except Exception:
456
381
  _log_event(f"websocket disconnected session_id={session_id}")
457
382
  finally:
458
383
  if reader is not None:
@@ -461,21 +386,27 @@ async def _attach_websocket_to_session(websocket: WebSocket, session: dict[str,
461
386
  _log_event(f"websocket bridge closed session_id={session_id}")
462
387
 
463
388
 
464
- def create_app() -> FastAPI:
389
+ def create_app() -> Any:
390
+ try:
391
+ from fastapi import FastAPI, Header, WebSocket as FastAPIWebSocket
392
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
393
+ except ImportError:
394
+ raise RuntimeError(missing_extra_message("remote-server", "fastapi, uvicorn")) from None
395
+
465
396
  app = FastAPI()
466
397
 
467
398
  @app.get("/")
468
399
  async def index() -> Response:
469
400
  _log_event("served remote index")
470
- return HTMLResponse(_asset_text("index.html"), headers={"Cache-Control": "no-store"})
401
+ return HTMLResponse(asset_text("index.html"), headers={"Cache-Control": "no-store"})
471
402
 
472
403
  @app.get("/app.js")
473
404
  async def app_js() -> Response:
474
- return Response(_asset_text("app.js"), media_type="application/javascript")
405
+ return Response(asset_text("app.js"), media_type="application/javascript")
475
406
 
476
407
  @app.get("/style.css")
477
408
  async def style_css() -> Response:
478
- return Response(_asset_text("style.css"), media_type="text/css")
409
+ return Response(asset_text("style.css"), media_type="text/css")
479
410
 
480
411
  @app.get("/assets/{asset_name}")
481
412
  async def web_asset(asset_name: str) -> Response:
@@ -487,7 +418,7 @@ def create_app() -> FastAPI:
487
418
  media_type = media_types.get(asset_name)
488
419
  if media_type is None:
489
420
  return Response(status_code=404)
490
- return Response(_web_asset_text(asset_name), media_type=media_type)
421
+ return Response(web_asset_text(asset_name), media_type=media_type)
491
422
 
492
423
  @app.get("/health")
493
424
  async def health() -> dict[str, Any]:
@@ -537,10 +468,10 @@ def create_app() -> FastAPI:
537
468
  return JSONResponse({"deprecated": True, "sessions": len(sessions)})
538
469
 
539
470
  @app.websocket("/ws/{session_id}")
540
- async def websocket_session(websocket: WebSocket, session_id: str) -> None:
471
+ async def websocket_session(websocket: FastAPIWebSocket, session_id: str) -> None:
541
472
  await websocket.accept()
542
473
  _log_event(f"websocket accepted session_id={session_id}")
543
- if not _valid_token(await _websocket_token(websocket)):
474
+ if not _valid_token(await websocket_auth_token(websocket)):
544
475
  _log_event(f"websocket unauthorized session_id={session_id}")
545
476
  await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
546
477
  await websocket.close(code=1008)
@@ -554,10 +485,10 @@ def create_app() -> FastAPI:
554
485
  await _attach_websocket_to_session(websocket, session)
555
486
 
556
487
  @app.websocket("/ws")
557
- async def legacy_websocket(websocket: WebSocket) -> None:
488
+ async def legacy_websocket(websocket: FastAPIWebSocket) -> None:
558
489
  await websocket.accept()
559
490
  _log_event("legacy websocket accepted")
560
- if not _valid_token(await _websocket_token(websocket)):
491
+ if not _valid_token(await websocket_auth_token(websocket)):
561
492
  _log_event("legacy websocket unauthorized")
562
493
  await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
563
494
  await websocket.close(code=1008)
@@ -587,7 +518,7 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
587
518
  try:
588
519
  import uvicorn
589
520
  except ImportError:
590
- return "Missing dependency 'uvicorn'. Run uv sync or install ccode with remote dependencies."
521
+ return missing_extra_message("remote-server", "uvicorn")
591
522
 
592
523
  host, port = remote_host_port(config)
593
524
  _log_event(f"remote hub serving host={host} port={port} pid={os.getpid()}")
@@ -598,8 +529,12 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
598
529
 
599
530
 
600
531
  def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
601
- if not str(config.get("remote", {}).get("token") or "").strip():
532
+ mode = remote_mode(config)
533
+ if mode == "local" and not remote_local_token(config).strip():
602
534
  return "Remote token is required. Set it in the config screen or press g on the TOKEN field to generate one."
535
+ server = remote_server_config(config)
536
+ if mode == "server" and (not remote_server_url(config) or not str(server.get("token") or "").strip()):
537
+ return "Remote server URL and client token are required in server mode."
603
538
  error = _check_runtime()
604
539
  if error is not None:
605
540
  return error
@@ -626,26 +561,52 @@ def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
626
561
  )
627
562
  _append_log_event(f"registered remote session id={session_name} cwd={os.getcwd()} args={len(args)}")
628
563
 
629
- error = ensure_remote_hub(config)
630
- if error is not None:
631
- return error
632
-
633
- host = str(remote.get("host") or "127.0.0.1")
634
- url = remote_url(config, include_token=False)
635
- base_url = remote_url(config, include_token=False).rstrip("/")
636
-
637
- print("ccode remote session started\n")
638
- print("Local:")
639
- print(f" {url}\n")
640
- print("Cloudflare Tunnel:")
641
- print(f" cloudflared tunnel --url {base_url}\n")
642
- print("Tmux session:")
643
- print(f" {session_name}\n")
644
- if host == "0.0.0.0":
645
- print("Security warning:")
646
- print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and token.\n")
647
- print("This terminal will attach to this tmux session. The web hub stays running independently.")
648
- print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
564
+ if mode == "server":
565
+ server_url = remote_server_url(config)
566
+ if server.get("auto_connect", True):
567
+ try:
568
+ from .remote_client import ensure_remote_client_connector
569
+ except ImportError:
570
+ return missing_extra_message("remote-client", "websockets, pexpect")
571
+ error = ensure_remote_client_connector(config)
572
+ if error is not None:
573
+ return error
574
+ print("ccode remote session started\n")
575
+ print("Server:")
576
+ print(f" {server_url}")
577
+ print(f" device: {server.get('device_name') or '<unnamed>'} ({server.get('device_id') or '<unset>'})\n")
578
+ print("Tmux session:")
579
+ print(f" {session_name}\n")
580
+ if server_url.startswith("http://"):
581
+ print("Security warning:")
582
+ print(" Server URL uses plain HTTP/WS. Use HTTPS/WSS in production.\n")
583
+ if server.get("auto_connect", True):
584
+ print("This terminal will attach to this tmux session. The connector stays running independently.")
585
+ print("Detach with Ctrl+B then D. Browser users can choose this device/session from the server.\n")
586
+ else:
587
+ print("This terminal will attach to this tmux session. Start the connector with `ccode-remote client start`.")
588
+ print("Detach with Ctrl+B then D.\n")
589
+ else:
590
+ error = ensure_remote_hub(config)
591
+ if error is not None:
592
+ return error
593
+
594
+ host, _port = remote_host_port(config)
595
+ url = remote_url(config, include_token=False)
596
+ base_url = remote_url(config, include_token=False).rstrip("/")
597
+
598
+ print("ccode remote session started\n")
599
+ print("Local:")
600
+ print(f" {url}\n")
601
+ print("Cloudflare Tunnel:")
602
+ print(f" cloudflared tunnel --url {base_url}\n")
603
+ print("Tmux session:")
604
+ print(f" {session_name}\n")
605
+ if host == "0.0.0.0":
606
+ print("Security warning:")
607
+ print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and token.\n")
608
+ print("This terminal will attach to this tmux session. The web hub stays running independently.")
609
+ print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
649
610
 
650
611
  try:
651
612
  return _attach_tmux_session(session_name)