agentlink-cli 0.1.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.
Files changed (55) hide show
  1. agentlink_cli-0.1.0.dist-info/METADATA +136 -0
  2. agentlink_cli-0.1.0.dist-info/RECORD +55 -0
  3. agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
  4. agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
  5. connector/__init__.py +3 -0
  6. connector/acp/__init__.py +6 -0
  7. connector/acp/adapter.py +1221 -0
  8. connector/acp/config_options.py +175 -0
  9. connector/acp/discovery.py +385 -0
  10. connector/acp/manifest.py +110 -0
  11. connector/acp/manifests/__init__.py +1 -0
  12. connector/acp/manifests/codebuddy.json +37 -0
  13. connector/acp/manifests/cursor.json +39 -0
  14. connector/acp/manifests/gemini.json +33 -0
  15. connector/acp/manifests/grok_build.json +31 -0
  16. connector/acp/reducer.py +615 -0
  17. connector/acp/rpc.py +308 -0
  18. connector/adapter.py +39 -0
  19. connector/attachments.py +36 -0
  20. connector/capabilities.py +603 -0
  21. connector/claude/__init__.py +8 -0
  22. connector/claude/history_adapter.py +642 -0
  23. connector/claude/normalized.py +23 -0
  24. connector/claude/normalizers.py +97 -0
  25. connector/claude/path_utils.py +13 -0
  26. connector/claude/preferences.py +38 -0
  27. connector/claude/sdk_adapter.py +1376 -0
  28. connector/claude/timeline_identity.py +47 -0
  29. connector/claude/timeline_reducer.py +379 -0
  30. connector/claude/trust.py +69 -0
  31. connector/cli.py +280 -0
  32. connector/codex/__init__.py +3 -0
  33. connector/codex/adapter.py +1150 -0
  34. connector/codex/history.py +199 -0
  35. connector/codex/reducer.py +1309 -0
  36. connector/codex/rpc.py +261 -0
  37. connector/control.py +298 -0
  38. connector/json_rpc.py +143 -0
  39. connector/launch.py +310 -0
  40. connector/local/__init__.py +6 -0
  41. connector/local/common.py +118 -0
  42. connector/local/file_ops.py +144 -0
  43. connector/local/ops.py +92 -0
  44. connector/local/shell.py +225 -0
  45. connector/local/terminal.py +658 -0
  46. connector/local_ops.py +5 -0
  47. connector/local_runtime.py +139 -0
  48. connector/logging.py +50 -0
  49. connector/perf.py +89 -0
  50. connector/protocol.py +26 -0
  51. connector/registry.py +49 -0
  52. connector/runtime.py +1309 -0
  53. connector/sync_state.py +155 -0
  54. connector/time.py +7 -0
  55. connector/version.py +13 -0
connector/codex/rpc.py ADDED
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from collections.abc import Awaitable, Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from connector.launch import launch_target
14
+ from connector.logging import logger
15
+ from connector.version import connector_version
16
+
17
+ NotificationHandler = Callable[[dict[str, Any]], Awaitable[None]]
18
+ APP_SERVER_STREAM_LIMIT = 64 * 1024 * 1024
19
+
20
+
21
+ class JsonRpcStdioClient:
22
+ """Line-delimited JSON-RPC client for `codex app-server --listen stdio://`."""
23
+
24
+ def __init__(self, command: list[str] | None = None) -> None:
25
+ self.command = command or _resolve_codex_command()
26
+ self.process: asyncio.subprocess.Process | None = None
27
+ self._start_lock = asyncio.Lock()
28
+ self._next_id = 1
29
+ self._pending: dict[int | str, asyncio.Future[dict[str, Any]]] = {}
30
+ self._server_request_ids: set[int | str] = set()
31
+ self._notification_handler: NotificationHandler | None = None
32
+ self._initialized = False
33
+
34
+ async def start(self, handler: NotificationHandler) -> None:
35
+ async with self._start_lock:
36
+ if self.process and self._initialized:
37
+ self._notification_handler = handler
38
+ return
39
+
40
+ self._notification_handler = handler
41
+ if self.process is None:
42
+ logger.info("starting codex app-server command={}", self.command)
43
+ self.process = await asyncio.create_subprocess_exec(
44
+ *self.command,
45
+ stdin=asyncio.subprocess.PIPE,
46
+ stdout=asyncio.subprocess.PIPE,
47
+ stderr=asyncio.subprocess.PIPE,
48
+ limit=APP_SERVER_STREAM_LIMIT,
49
+ )
50
+ self._track_reader(asyncio.create_task(self._read_stdout(self.process)), "stdout")
51
+ self._track_reader(asyncio.create_task(self._read_stderr(self.process)), "stderr")
52
+
53
+ await self.request(
54
+ "initialize",
55
+ {
56
+ "clientInfo": {
57
+ "name": "agent-server-connector",
58
+ "title": "Agent Server Connector",
59
+ "version": connector_version(),
60
+ },
61
+ "capabilities": {
62
+ "experimentalApi": True,
63
+ "requestAttestation": False,
64
+ },
65
+ },
66
+ )
67
+ await self.notify("initialized")
68
+ self._initialized = True
69
+
70
+ async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
71
+ if not self.process or not self.process.stdin:
72
+ raise RuntimeError("Codex app-server is not started")
73
+
74
+ request_id = self._next_id
75
+ self._next_id += 1
76
+ loop = asyncio.get_running_loop()
77
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
78
+ self._pending[request_id] = future
79
+ payload: dict[str, Any] = {
80
+ "jsonrpc": "2.0",
81
+ "id": request_id,
82
+ "method": method,
83
+ "params": params or {},
84
+ }
85
+
86
+ started = time.perf_counter()
87
+ self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
88
+ await self.process.stdin.drain()
89
+ try:
90
+ return await future
91
+ finally:
92
+ elapsed_ms = (time.perf_counter() - started) * 1000
93
+ logger.trace("codex rpc method={} id={} elapsed_ms={:.1f}", method, request_id, elapsed_ms)
94
+
95
+ async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
96
+ if not self.process or not self.process.stdin:
97
+ raise RuntimeError("Codex app-server is not started")
98
+
99
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}}
100
+ self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
101
+ await self.process.stdin.drain()
102
+
103
+ async def respond(self, request_id: str | int, result: dict[str, Any] | None = None) -> None:
104
+ if not self.process or not self.process.stdin:
105
+ raise RuntimeError("Codex app-server is not started")
106
+
107
+ response_id = self._response_id_for(request_id)
108
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "id": response_id, "result": result or {}}
109
+ self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
110
+ await self.process.stdin.drain()
111
+
112
+ async def close(self) -> None:
113
+ if self.process is None:
114
+ return
115
+ self.process.terminate()
116
+ try:
117
+ await asyncio.wait_for(self.process.wait(), timeout=5)
118
+ except TimeoutError:
119
+ self.process.kill()
120
+ await self.process.wait()
121
+ finally:
122
+ self.process = None
123
+ self._initialized = False
124
+
125
+ async def _read_stdout(self, process: asyncio.subprocess.Process) -> None:
126
+ assert process.stdout
127
+ while line := await process.stdout.readline():
128
+ try:
129
+ payload = json.loads(line)
130
+ except json.JSONDecodeError:
131
+ logger.warning("codex app-server emitted non-json stdout: {}", line.decode(errors="replace").strip())
132
+ continue
133
+
134
+ request_id = payload.get("id")
135
+ if request_id in self._pending and ("result" in payload or "error" in payload):
136
+ future = self._pending.pop(request_id)
137
+ self._settle_pending_future(future, payload)
138
+ continue
139
+
140
+ if request_id is not None and isinstance(payload.get("method"), str):
141
+ self._server_request_ids.add(request_id)
142
+
143
+ if self._notification_handler is not None:
144
+ await self._notification_handler(payload)
145
+
146
+ async def _read_stderr(self, process: asyncio.subprocess.Process) -> None:
147
+ assert process.stderr
148
+ while line := await process.stderr.readline():
149
+ logger.trace("codex app-server stderr: {}", line.decode(errors="replace").rstrip())
150
+
151
+ def _track_reader(self, task: asyncio.Task[None], name: str) -> None:
152
+ def done(completed: asyncio.Task[None]) -> None:
153
+ try:
154
+ completed.result()
155
+ except asyncio.CancelledError:
156
+ return
157
+ except Exception:
158
+ logger.exception("codex app-server {} reader stopped unexpectedly", name)
159
+
160
+ task.add_done_callback(done)
161
+
162
+ def _settle_pending_future(self, future: asyncio.Future[dict[str, Any]], payload: dict[str, Any]) -> None:
163
+ if future.done():
164
+ logger.trace("codex rpc received response for completed request id={}", payload.get("id"))
165
+ return
166
+ if "error" in payload:
167
+ future.set_exception(RuntimeError(json.dumps(payload["error"], ensure_ascii=False)))
168
+ else:
169
+ future.set_result(payload.get("result") or {})
170
+
171
+ def _response_id_for(self, request_id: str | int) -> str | int:
172
+ if request_id in self._server_request_ids:
173
+ self._server_request_ids.remove(request_id)
174
+ return request_id
175
+ if isinstance(request_id, str):
176
+ try:
177
+ numeric_request_id = int(request_id)
178
+ except ValueError:
179
+ numeric_request_id = None
180
+ if numeric_request_id is not None and numeric_request_id in self._server_request_ids:
181
+ self._server_request_ids.remove(numeric_request_id)
182
+ logger.trace(
183
+ "codex rpc coerced approval response id from string to number request_id={}",
184
+ request_id,
185
+ )
186
+ return numeric_request_id
187
+ logger.warning("codex rpc responding to unknown server request id={}", request_id)
188
+ return request_id
189
+
190
+
191
+ def _resolve_codex_bin() -> str:
192
+ for candidate in codex_candidate_paths():
193
+ if candidate["source"] == "custom":
194
+ return candidate["path"]
195
+ path = Path(candidate["path"])
196
+ if path.is_file():
197
+ return str(path)
198
+ return "codex"
199
+
200
+
201
+ def _resolve_codex_command() -> list[str]:
202
+ path = _resolve_codex_bin()
203
+ return launch_target("cli", path).command(["app-server", "--listen", "stdio://"])
204
+
205
+
206
+ def codex_candidate_paths() -> list[dict[str, str]]:
207
+ if sys.platform == "win32":
208
+ home = Path.home()
209
+ appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
210
+ candidates = [
211
+ {"source": "custom", "path": os.getenv("CODEX_BIN", "")},
212
+ *[
213
+ {"source": "nvm", "path": str(Path("C:/nvm4w/nodejs") / name)}
214
+ for name in ("codex.cmd", "codex.ps1", "codex.exe")
215
+ ],
216
+ {"source": "cli", "path": shutil.which("codex") or ""},
217
+ *[
218
+ {"source": "npm", "path": str(Path(appdata) / "npm" / name)}
219
+ for name in ("codex.cmd", "codex.ps1", "codex.exe")
220
+ ],
221
+ *[
222
+ {"source": "npm", "path": str(home / ".npm-global" / "bin" / name)}
223
+ for name in ("codex.cmd", "codex.ps1", "codex.exe")
224
+ ],
225
+ *[
226
+ {"source": "cli", "path": str(home / ".local" / "bin" / name)}
227
+ for name in ("codex.exe", "codex.cmd", "codex.ps1")
228
+ ],
229
+ *[
230
+ {"source": "scoop", "path": str(home / "scoop" / "shims" / name)}
231
+ for name in ("codex.exe", "codex.cmd", "codex.ps1")
232
+ ],
233
+ ]
234
+ seen: set[str] = set()
235
+ out: list[dict[str, str]] = []
236
+ for candidate in candidates:
237
+ path = candidate.get("path") or ""
238
+ if not path or path in seen:
239
+ continue
240
+ seen.add(path)
241
+ out.append(candidate)
242
+ return out
243
+
244
+ candidates = [
245
+ {"source": "custom", "path": os.getenv("CODEX_BIN", "")},
246
+ {"source": "app", "path": "/Applications/Codex.app/Contents/Resources/codex"},
247
+ {"source": "app", "path": str(Path.home() / "Applications" / "Codex.app" / "Contents" / "Resources" / "codex")},
248
+ {"source": "cli", "path": shutil.which("codex") or ""},
249
+ {"source": "cli", "path": "/opt/homebrew/bin/codex"},
250
+ {"source": "cli", "path": "/usr/local/bin/codex"},
251
+ ]
252
+
253
+ seen: set[str] = set()
254
+ out: list[dict[str, str]] = []
255
+ for candidate in candidates:
256
+ path = candidate.get("path") or ""
257
+ if not path or path in seen:
258
+ continue
259
+ seen.add(path)
260
+ out.append(candidate)
261
+ return out
connector/control.py ADDED
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from connector.local_runtime import (
11
+ ConnectorAlreadyRunningError,
12
+ assert_can_start,
13
+ clear_runtime,
14
+ runtime_path,
15
+ write_runtime,
16
+ )
17
+ from connector.logging import logger
18
+ from connector.runtime import BackendRpcClient, ConnectorAuthenticationError, ConnectorConfig
19
+
20
+
21
+ ControlNotifier = Callable[[str, Any], Awaitable[None]]
22
+
23
+
24
+ class ConnectorController:
25
+ def __init__(
26
+ self,
27
+ *,
28
+ config_path: str | Path | None = None,
29
+ notifier: ControlNotifier | None = None,
30
+ client_factory: Callable[[ConnectorConfig], BackendRpcClient] = BackendRpcClient,
31
+ ) -> None:
32
+ self.config_path = Path(config_path) if config_path is not None else ConnectorConfig.default_path()
33
+ self.notifier = notifier
34
+ self.client_factory = client_factory
35
+ self.runtime_path = runtime_path(self.config_path)
36
+ self._runtime_task: asyncio.Task[None] | None = None
37
+ self._pairing_task: asyncio.Task[None] | None = None
38
+ self._last_error: str | None = None
39
+ self._auth_failed = False
40
+
41
+ def get_state(self, _params: Any = None) -> dict[str, Any]:
42
+ return {
43
+ "status": self._status(),
44
+ "running": self._runtime_task is not None and not self._runtime_task.done(),
45
+ "pairing": self._pairing_task is not None and not self._pairing_task.done(),
46
+ "authFailed": self._auth_failed,
47
+ "lastError": self._last_error,
48
+ "configPath": str(self.config_path),
49
+ "runtimePath": str(self.runtime_path),
50
+ "hasConfig": self.config_path.exists(),
51
+ }
52
+
53
+ def get_paths(self, _params: Any = None) -> dict[str, str]:
54
+ return {
55
+ "configPath": str(self.config_path),
56
+ "configDir": str(self.config_path.parent),
57
+ "runtimePath": str(self.runtime_path),
58
+ }
59
+
60
+ def get_config(self, _params: Any = None) -> dict[str, Any]:
61
+ if not self.config_path.exists():
62
+ return default_config_payload()
63
+ return config_to_payload(ConnectorConfig.load(self.config_path))
64
+
65
+ async def save_config(self, params: Any) -> dict[str, Any]:
66
+ config = config_from_params(params)
67
+ saved_path = config.save(self.config_path)
68
+ self._auth_failed = False
69
+ self._last_error = None
70
+ logger.info("saved connector config path={}", saved_path)
71
+ await self._emit_state()
72
+ return config_to_payload(config)
73
+
74
+ async def start(self, params: Any = None) -> dict[str, Any]:
75
+ if self._runtime_task is not None and not self._runtime_task.done():
76
+ return self.get_state()
77
+
78
+ config = config_from_params(params) if isinstance(params, dict) and params else ConnectorConfig.load(self.config_path)
79
+ self._last_error = None
80
+ self._auth_failed = False
81
+ try:
82
+ assert_can_start(self.runtime_path, config)
83
+ except ConnectorAlreadyRunningError as exc:
84
+ self._last_error = str(exc)
85
+ await self._emit_state()
86
+ raise
87
+ write_runtime(self.runtime_path, config, kind="desktop")
88
+ self._runtime_task = asyncio.create_task(self._run_runtime(config))
89
+ logger.info("starting connector runtime")
90
+ await self._emit_state()
91
+ return self.get_state()
92
+
93
+ async def stop(self, _params: Any = None) -> dict[str, Any]:
94
+ if self._runtime_task is not None and not self._runtime_task.done():
95
+ self._runtime_task.cancel()
96
+ try:
97
+ await self._runtime_task
98
+ except asyncio.CancelledError:
99
+ pass
100
+ self._runtime_task = None
101
+ clear_runtime(self.runtime_path)
102
+ logger.info("stopped connector runtime")
103
+ await self._emit_state()
104
+ return self.get_state()
105
+
106
+ async def restart(self, params: Any = None) -> dict[str, Any]:
107
+ await self.stop()
108
+ return await self.start(params)
109
+
110
+ async def start_pairing(self, params: Any) -> dict[str, Any]:
111
+ if self._pairing_task is not None and not self._pairing_task.done():
112
+ self._pairing_task.cancel()
113
+ server = str_param(params, "server") or str_param(params, "serverUrl")
114
+ server_url = await resolve_pair_server_url(server, timeout=float_param(params, "resolveTimeout", 10))
115
+ timeout = float_param(params, "timeout", 600)
116
+ poll_interval = float_param(params, "pollInterval", 2)
117
+ self._pairing_task = asyncio.create_task(self._run_pairing(server_url, timeout=timeout, poll_interval=poll_interval))
118
+ payload = {"status": "starting", "serverUrl": server_url}
119
+ await self._emit_pairing(payload)
120
+ await self._emit_state()
121
+ return payload
122
+
123
+ async def cancel_pairing(self, _params: Any = None) -> dict[str, Any]:
124
+ if self._pairing_task is not None and not self._pairing_task.done():
125
+ self._pairing_task.cancel()
126
+ self._pairing_task = None
127
+ payload = {"status": "cancelled"}
128
+ await self._emit_pairing(payload)
129
+ await self._emit_state()
130
+ return payload
131
+
132
+ async def shutdown(self) -> None:
133
+ if self._pairing_task is not None and not self._pairing_task.done():
134
+ self._pairing_task.cancel()
135
+ await self.stop()
136
+
137
+ async def _run_runtime(self, config: ConnectorConfig) -> None:
138
+ try:
139
+ await self.client_factory(config).run_forever()
140
+ except asyncio.CancelledError:
141
+ raise
142
+ except ConnectorAuthenticationError as exc:
143
+ self._auth_failed = True
144
+ self._last_error = str(exc)
145
+ logger.error("connector authentication failed: {}", exc)
146
+ except Exception as exc:
147
+ self._last_error = str(exc) or exc.__class__.__name__
148
+ logger.exception("connector runtime failed")
149
+ finally:
150
+ clear_runtime(self.runtime_path)
151
+ if self._runtime_task is asyncio.current_task():
152
+ self._runtime_task = None
153
+ await self._emit_state()
154
+
155
+ async def _run_pairing(self, server_url: str, *, timeout: float, poll_interval: float) -> None:
156
+ try:
157
+ async with httpx.AsyncClient(timeout=30) as client:
158
+ start_response = await client.post(
159
+ f"{server_url}/pairing/start",
160
+ json={"serverUrl": server_url, "ttlSeconds": int(timeout)},
161
+ )
162
+ start_response.raise_for_status()
163
+ pairing = start_response.json()
164
+ pairing_id = pairing["pairingId"]
165
+ code = pairing["code"]
166
+ await self._emit_pairing(
167
+ {
168
+ "status": "waiting",
169
+ "serverUrl": server_url,
170
+ "pairingId": pairing_id,
171
+ "code": code,
172
+ }
173
+ )
174
+
175
+ deadline = asyncio.get_running_loop().time() + timeout
176
+ while asyncio.get_running_loop().time() < deadline:
177
+ poll_response = await client.post(f"{server_url}/pairing/poll", json={"pairingId": pairing_id})
178
+ poll_response.raise_for_status()
179
+ payload = poll_response.json()
180
+ if payload["status"] == "claimed" and payload.get("config"):
181
+ config = ConnectorConfig.from_mapping(payload["config"])
182
+ config.save(self.config_path)
183
+ await self._emit_pairing({"status": "claimed", "config": config_to_payload(config)})
184
+ await self.start()
185
+ return
186
+ if payload["status"] in {"expired", "consumed"}:
187
+ await self._emit_pairing({"status": payload["status"]})
188
+ return
189
+ await asyncio.sleep(poll_interval)
190
+ await self._emit_pairing({"status": "expired"})
191
+ except asyncio.CancelledError:
192
+ await self._emit_pairing({"status": "cancelled"})
193
+ raise
194
+ except Exception as exc:
195
+ self._last_error = str(exc) or exc.__class__.__name__
196
+ await self._emit_pairing({"status": "error", "error": self._last_error})
197
+ await self._emit_state()
198
+
199
+ def _status(self) -> str:
200
+ if self._runtime_task is not None and not self._runtime_task.done():
201
+ return "running"
202
+ if self._auth_failed:
203
+ return "expired credential"
204
+ if self._last_error:
205
+ return "error"
206
+ return "stopped"
207
+
208
+ async def _emit_state(self) -> None:
209
+ await self._notify("connector/state", self.get_state())
210
+
211
+ async def _emit_pairing(self, payload: dict[str, Any]) -> None:
212
+ await self._notify("connector/pairing", payload)
213
+
214
+ async def _notify(self, method: str, params: Any) -> None:
215
+ if self.notifier is not None:
216
+ await self.notifier(method, params)
217
+
218
+
219
+ def default_config_payload() -> dict[str, Any]:
220
+ return {
221
+ "serverUrl": "",
222
+ "connectorId": "",
223
+ "connectorToken": "",
224
+ "heartbeatSeconds": 20,
225
+ "reconnectSeconds": 3,
226
+ "syncExistingOnConnect": True,
227
+ "syncIntervalSeconds": 30,
228
+ "stateDbPath": None,
229
+ }
230
+
231
+
232
+ def config_to_payload(config: ConnectorConfig) -> dict[str, Any]:
233
+ return {
234
+ "serverUrl": config.server_url,
235
+ "connectorId": config.connector_id,
236
+ "connectorToken": config.connector_token,
237
+ "heartbeatSeconds": config.heartbeat_seconds,
238
+ "reconnectSeconds": config.reconnect_seconds,
239
+ "syncExistingOnConnect": config.sync_existing_on_connect,
240
+ "syncIntervalSeconds": config.sync_interval_seconds,
241
+ "stateDbPath": config.state_db_path,
242
+ }
243
+
244
+
245
+ def config_from_params(params: Any) -> ConnectorConfig:
246
+ if not isinstance(params, dict):
247
+ raise ValueError("config params must be an object")
248
+ server_url = str(params.get("serverUrl") or "").strip().rstrip("/")
249
+ connector_id = str(params.get("connectorId") or "").strip()
250
+ connector_token = str(params.get("connectorToken") or "").strip()
251
+ if not server_url or not connector_id or not connector_token:
252
+ raise ValueError("serverUrl, connectorId, and connectorToken are required")
253
+ return ConnectorConfig(
254
+ server_url=server_url,
255
+ connector_id=connector_id,
256
+ connector_token=connector_token,
257
+ heartbeat_seconds=float(params.get("heartbeatSeconds", 20)),
258
+ reconnect_seconds=float(params.get("reconnectSeconds", 3)),
259
+ sync_existing_on_connect=bool(params.get("syncExistingOnConnect", True)),
260
+ sync_interval_seconds=float(params.get("syncIntervalSeconds", 30)),
261
+ state_db_path=params.get("stateDbPath") if isinstance(params.get("stateDbPath"), str) else None,
262
+ )
263
+
264
+
265
+ async def resolve_pair_server_url(value: str | None, *, timeout: float = 10) -> str:
266
+ normalized = str(value or "").strip().rstrip("/")
267
+ if not normalized:
268
+ raise ValueError("server is required")
269
+ if normalized.startswith(("http://", "https://")):
270
+ return normalized
271
+ candidates = [f"https://{normalized}", f"http://{normalized}"]
272
+ errors: list[str] = []
273
+ for candidate in candidates:
274
+ try:
275
+ async with httpx.AsyncClient(timeout=timeout) as client:
276
+ response = await client.get(f"{candidate}/health")
277
+ if response.status_code < 500:
278
+ return candidate
279
+ errors.append(f"{candidate}: HTTP {response.status_code}")
280
+ except httpx.RequestError as exc:
281
+ errors.append(f"{candidate}: {exc}")
282
+ raise ValueError(f"could not reach server over https or http ({'; '.join(errors)})")
283
+
284
+
285
+ def str_param(params: Any, key: str) -> str | None:
286
+ if not isinstance(params, dict):
287
+ return None
288
+ value = params.get(key)
289
+ return value if isinstance(value, str) and value.strip() else None
290
+
291
+
292
+ def float_param(params: Any, key: str, default: float) -> float:
293
+ if not isinstance(params, dict):
294
+ return default
295
+ try:
296
+ return float(params.get(key, default))
297
+ except (TypeError, ValueError):
298
+ return default