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.
- agentlink_cli-0.1.0.dist-info/METADATA +136 -0
- agentlink_cli-0.1.0.dist-info/RECORD +55 -0
- agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
- agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/acp/__init__.py +6 -0
- connector/acp/adapter.py +1221 -0
- connector/acp/config_options.py +175 -0
- connector/acp/discovery.py +385 -0
- connector/acp/manifest.py +110 -0
- connector/acp/manifests/__init__.py +1 -0
- connector/acp/manifests/codebuddy.json +37 -0
- connector/acp/manifests/cursor.json +39 -0
- connector/acp/manifests/gemini.json +33 -0
- connector/acp/manifests/grok_build.json +31 -0
- connector/acp/reducer.py +615 -0
- connector/acp/rpc.py +308 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +603 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1376 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +280 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +1150 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1309 -0
- connector/codex/rpc.py +261 -0
- connector/control.py +298 -0
- connector/json_rpc.py +143 -0
- connector/launch.py +310 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +144 -0
- connector/local/ops.py +92 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +658 -0
- connector/local_ops.py +5 -0
- connector/local_runtime.py +139 -0
- connector/logging.py +50 -0
- connector/perf.py +89 -0
- connector/protocol.py +26 -0
- connector/registry.py +49 -0
- connector/runtime.py +1309 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
- connector/version.py +13 -0
connector/runtime.py
ADDED
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urljoin, urlparse, urlunparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import websockets
|
|
16
|
+
from websockets.asyncio.client import ClientConnection
|
|
17
|
+
from websockets.exceptions import ConnectionClosed
|
|
18
|
+
|
|
19
|
+
from connector.acp.adapter import AcpAdapter
|
|
20
|
+
from connector.adapter import Adapter
|
|
21
|
+
from connector.capabilities import (
|
|
22
|
+
discover_acp_capability,
|
|
23
|
+
discover_claude_capability,
|
|
24
|
+
discover_codex_capability,
|
|
25
|
+
discover_runtime_capabilities,
|
|
26
|
+
prepare_codex_launch,
|
|
27
|
+
)
|
|
28
|
+
from connector.claude.preferences import read_local_preferences
|
|
29
|
+
from connector.codex.adapter import CodexAdapter
|
|
30
|
+
from connector.codex.rpc import JsonRpcStdioClient
|
|
31
|
+
from connector.launch import (
|
|
32
|
+
LaunchCommand,
|
|
33
|
+
LaunchTarget,
|
|
34
|
+
launch_command_from_target,
|
|
35
|
+
launch_target,
|
|
36
|
+
parse_launch_command,
|
|
37
|
+
)
|
|
38
|
+
from connector.local_ops import create_local_ops
|
|
39
|
+
from connector.logging import logger
|
|
40
|
+
from connector.perf import elapsed_ms, log_stage
|
|
41
|
+
from connector.registry import build_default_adapters
|
|
42
|
+
from connector.sync_state import SqliteSyncStateStore, SyncStateStore
|
|
43
|
+
|
|
44
|
+
DEFAULT_RUNTIME = "codex"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60.0
|
|
48
|
+
RUNTIME_SYNC_TIMEOUT_SECONDS = 15.0
|
|
49
|
+
RUNTIME_CHANGED_SYNC_TIMEOUT_SECONDS = 60.0
|
|
50
|
+
|
|
51
|
+
# Notifications from the adapter are funneled through an in-memory queue and
|
|
52
|
+
# flushed in batches of up to FLUSH_MAX or after FLUSH_WINDOW_SECONDS,
|
|
53
|
+
# whichever comes first. This collapses N back-to-back Codex deltas (one
|
|
54
|
+
# token per delta) into 1 HTTP POST, while bounding the worst-case latency
|
|
55
|
+
# added at ~20ms.
|
|
56
|
+
_PERF_DISPATCH_METHODS = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"session.create",
|
|
59
|
+
"session.sync",
|
|
60
|
+
"session.discover",
|
|
61
|
+
"turn.start",
|
|
62
|
+
"turn.interrupt",
|
|
63
|
+
"approval.resolve",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
FLUSH_WINDOW_SECONDS = 0.02
|
|
67
|
+
FLUSH_MAX = 64
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True)
|
|
71
|
+
class ConnectorConfig:
|
|
72
|
+
server_url: str
|
|
73
|
+
connector_id: str
|
|
74
|
+
connector_token: str
|
|
75
|
+
heartbeat_seconds: float = 20
|
|
76
|
+
reconnect_seconds: float = 3
|
|
77
|
+
sync_existing_on_connect: bool = True
|
|
78
|
+
sync_interval_seconds: float = 30
|
|
79
|
+
state_db_path: str | None = None
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def default_path(cls) -> Path:
|
|
83
|
+
return Path(os.environ.get("AGENT_CONNECTOR_CONFIG", Path.home() / ".agent-server" / "connector.json"))
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_env(cls) -> ConnectorConfig:
|
|
87
|
+
missing = [
|
|
88
|
+
name
|
|
89
|
+
for name in ("AGENT_SERVER_URL", "AGENT_CONNECTOR_ID", "AGENT_CONNECTOR_TOKEN")
|
|
90
|
+
if not os.environ.get(name)
|
|
91
|
+
]
|
|
92
|
+
if missing:
|
|
93
|
+
raise RuntimeError(f"missing required environment variables: {', '.join(missing)}")
|
|
94
|
+
return cls(
|
|
95
|
+
server_url=os.environ["AGENT_SERVER_URL"].rstrip("/"),
|
|
96
|
+
connector_id=os.environ["AGENT_CONNECTOR_ID"],
|
|
97
|
+
connector_token=os.environ["AGENT_CONNECTOR_TOKEN"],
|
|
98
|
+
heartbeat_seconds=float(os.environ.get("AGENT_CONNECTOR_HEARTBEAT_SECONDS", "20")),
|
|
99
|
+
reconnect_seconds=float(os.environ.get("AGENT_CONNECTOR_RECONNECT_SECONDS", "3")),
|
|
100
|
+
sync_existing_on_connect=_bool_env("AGENT_CONNECTOR_SYNC_EXISTING", True),
|
|
101
|
+
sync_interval_seconds=float(os.environ.get("AGENT_CONNECTOR_SYNC_INTERVAL_SECONDS", "30")),
|
|
102
|
+
state_db_path=os.environ.get("AGENT_CONNECTOR_STATE_DB"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def load(cls, path: str | Path | None = None) -> ConnectorConfig:
|
|
107
|
+
config_path = Path(path) if path is not None else cls.default_path()
|
|
108
|
+
data = json.loads(config_path.read_text(encoding="utf-8-sig"))
|
|
109
|
+
return cls.from_mapping(data)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_mapping(cls, data: dict[str, Any]) -> ConnectorConfig:
|
|
113
|
+
return cls(
|
|
114
|
+
server_url=str(data["serverUrl"]).rstrip("/"),
|
|
115
|
+
connector_id=str(data["connectorId"]),
|
|
116
|
+
connector_token=str(data["connectorToken"]),
|
|
117
|
+
heartbeat_seconds=float(data.get("heartbeatSeconds", 20)),
|
|
118
|
+
reconnect_seconds=float(data.get("reconnectSeconds", 3)),
|
|
119
|
+
sync_existing_on_connect=bool(data.get("syncExistingOnConnect", True)),
|
|
120
|
+
sync_interval_seconds=float(data.get("syncIntervalSeconds", 30)),
|
|
121
|
+
state_db_path=data.get("stateDbPath") if isinstance(data.get("stateDbPath"), str) else None,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def save(self, path: str | Path | None = None) -> Path:
|
|
125
|
+
config_path = Path(path) if path is not None else self.default_path()
|
|
126
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
config_path.write_text(
|
|
128
|
+
json.dumps(
|
|
129
|
+
{
|
|
130
|
+
"serverUrl": self.server_url,
|
|
131
|
+
"connectorId": self.connector_id,
|
|
132
|
+
"connectorToken": self.connector_token,
|
|
133
|
+
"heartbeatSeconds": self.heartbeat_seconds,
|
|
134
|
+
"reconnectSeconds": self.reconnect_seconds,
|
|
135
|
+
"syncExistingOnConnect": self.sync_existing_on_connect,
|
|
136
|
+
"syncIntervalSeconds": self.sync_interval_seconds,
|
|
137
|
+
"stateDbPath": self.state_db_path,
|
|
138
|
+
},
|
|
139
|
+
ensure_ascii=False,
|
|
140
|
+
indent=2,
|
|
141
|
+
)
|
|
142
|
+
+ "\n",
|
|
143
|
+
encoding="utf-8",
|
|
144
|
+
)
|
|
145
|
+
config_path.chmod(0o600)
|
|
146
|
+
return config_path
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ConnectorAuthenticationError(RuntimeError):
|
|
150
|
+
"""Connector credentials are invalid or revoked; do not retry."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BackendRpcClient:
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
config: ConnectorConfig,
|
|
157
|
+
adapter: CodexAdapter | None = None,
|
|
158
|
+
*,
|
|
159
|
+
adapters: dict[str, Adapter] | None = None,
|
|
160
|
+
preferences_reader: Callable[[], dict[str, Any]] | None = None,
|
|
161
|
+
sync_state_store: SyncStateStore | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
self.config = config
|
|
164
|
+
self.sync_state_store = sync_state_store
|
|
165
|
+
if adapters is None and self.sync_state_store is None:
|
|
166
|
+
self.sync_state_store = SqliteSyncStateStore(config.state_db_path or SqliteSyncStateStore.default_path())
|
|
167
|
+
if adapters is not None:
|
|
168
|
+
self.adapters: dict[str, Adapter] = dict(adapters)
|
|
169
|
+
else:
|
|
170
|
+
# Default: native Codex/Claude + built-in ACP agent manifests.
|
|
171
|
+
# `adapter=` remains a single-adapter override for existing tests.
|
|
172
|
+
self.adapters = build_default_adapters(
|
|
173
|
+
notification_sink=self.send_backend_notification,
|
|
174
|
+
sync_state_store=self.sync_state_store,
|
|
175
|
+
)
|
|
176
|
+
if adapter is not None:
|
|
177
|
+
self.adapters["codex"] = adapter
|
|
178
|
+
for ad in self.adapters.values():
|
|
179
|
+
if getattr(ad, "notification_sink", None) is None:
|
|
180
|
+
ad.notification_sink = self.send_backend_notification
|
|
181
|
+
# Wire the user-uploaded-attachment downloader for adapters that
|
|
182
|
+
# support it (codex). Defensive getattr/try keeps adapters without
|
|
183
|
+
# the field (claude, older test fakes) working untouched.
|
|
184
|
+
if getattr(ad, "attachment_downloader", None) is None:
|
|
185
|
+
try:
|
|
186
|
+
ad.attachment_downloader = self.download_attachment
|
|
187
|
+
except AttributeError:
|
|
188
|
+
pass
|
|
189
|
+
# Back-compat alias so callers / tests that still reach for
|
|
190
|
+
# `client.adapter` get the default-routed adapter.
|
|
191
|
+
self.adapter = self.adapters[DEFAULT_RUNTIME]
|
|
192
|
+
self._preferences_reader = preferences_reader or read_local_preferences
|
|
193
|
+
self._last_preferences: dict[str, Any] | None = None
|
|
194
|
+
self._runtime_capabilities: dict[str, Any] | None = None
|
|
195
|
+
self._active_runtimes: set[str] = set()
|
|
196
|
+
self._blocked_runtimes: set[str] = set()
|
|
197
|
+
self._codex_launch_spec: dict[str, str] = {"mode": "auto"}
|
|
198
|
+
self._codex_launch_lock = asyncio.Lock()
|
|
199
|
+
self.local_ops = create_local_ops(notify=self.send_backend_notification)
|
|
200
|
+
self._ws: ClientConnection | None = None
|
|
201
|
+
self._access_token: str | None = None
|
|
202
|
+
self._access_token_expires_at: float = 0
|
|
203
|
+
self._auth_lock = asyncio.Lock()
|
|
204
|
+
self._send_lock = asyncio.Lock()
|
|
205
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
206
|
+
# Persistent HTTP client: a long-lived connection pool eliminates the
|
|
207
|
+
# 5–10ms TCP/TLS setup that the old `async with AsyncClient(...)`
|
|
208
|
+
# per-call pattern paid on every notification.
|
|
209
|
+
self._http_client: httpx.AsyncClient | None = None
|
|
210
|
+
# Notifications are funneled here and drained by `_flush_loop`. See
|
|
211
|
+
# FLUSH_WINDOW_SECONDS comment.
|
|
212
|
+
self._notify_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
213
|
+
# turn keys that already got a priority first-assistant ingest flush.
|
|
214
|
+
self._ttfb_flushed_turns: set[str] = set()
|
|
215
|
+
|
|
216
|
+
async def run_forever(self) -> None:
|
|
217
|
+
self._http_client = self._new_http_client(timeout=60)
|
|
218
|
+
flush_task = asyncio.create_task(self._flush_loop())
|
|
219
|
+
try:
|
|
220
|
+
while True:
|
|
221
|
+
try:
|
|
222
|
+
await self.run_once()
|
|
223
|
+
except asyncio.CancelledError:
|
|
224
|
+
raise
|
|
225
|
+
except ConnectorAuthenticationError as exc:
|
|
226
|
+
logger.error("connector authentication failed; stopping: {}", exc)
|
|
227
|
+
raise
|
|
228
|
+
except ConnectionClosed as exc:
|
|
229
|
+
close_code = _close_code(exc)
|
|
230
|
+
close_reason = _close_reason(exc)
|
|
231
|
+
if _is_auth_close(exc):
|
|
232
|
+
logger.error(
|
|
233
|
+
"backend websocket closed due to invalid connector credentials code={} reason={!r}; stopping",
|
|
234
|
+
close_code,
|
|
235
|
+
close_reason,
|
|
236
|
+
)
|
|
237
|
+
raise ConnectorAuthenticationError("connector credential no longer valid")
|
|
238
|
+
logger.warning(
|
|
239
|
+
"backend websocket closed code={} reason={!r}; reconnecting in {}s",
|
|
240
|
+
close_code,
|
|
241
|
+
close_reason,
|
|
242
|
+
self.config.reconnect_seconds,
|
|
243
|
+
)
|
|
244
|
+
await asyncio.sleep(self.config.reconnect_seconds)
|
|
245
|
+
except Exception:
|
|
246
|
+
logger.exception("connector loop failed; reconnecting in {}s", self.config.reconnect_seconds)
|
|
247
|
+
await asyncio.sleep(self.config.reconnect_seconds)
|
|
248
|
+
finally:
|
|
249
|
+
flush_task.cancel()
|
|
250
|
+
try:
|
|
251
|
+
await flush_task
|
|
252
|
+
except (asyncio.CancelledError, Exception):
|
|
253
|
+
pass
|
|
254
|
+
if self._http_client is not None:
|
|
255
|
+
await self._http_client.aclose()
|
|
256
|
+
self._http_client = None
|
|
257
|
+
|
|
258
|
+
async def run_once(self) -> None:
|
|
259
|
+
access_token = await self.ensure_access_token(force=True)
|
|
260
|
+
ws_url = _ws_url(self.config.server_url, "/connector/ws")
|
|
261
|
+
logger.info("connecting backend websocket {}", ws_url)
|
|
262
|
+
async with websockets.connect(
|
|
263
|
+
ws_url,
|
|
264
|
+
additional_headers={
|
|
265
|
+
"Authorization": f"Bearer {access_token}",
|
|
266
|
+
"X-Device-OS": _device_os(),
|
|
267
|
+
},
|
|
268
|
+
proxy=None if _is_loopback_url(self.config.server_url) else True,
|
|
269
|
+
# Keepalive so long ACP discovery does not look like a dead socket.
|
|
270
|
+
ping_interval=20,
|
|
271
|
+
ping_timeout=20,
|
|
272
|
+
) as ws:
|
|
273
|
+
self._ws = ws
|
|
274
|
+
# Heartbeat MUST start before discovery. Discovery of multiple ACP
|
|
275
|
+
# agents can exceed the server's 60s heartbeat timeout; previously
|
|
276
|
+
# we blocked here first, so the UI flapped "connector offline".
|
|
277
|
+
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
278
|
+
startup_task: asyncio.Task[None] | None = None
|
|
279
|
+
try:
|
|
280
|
+
try:
|
|
281
|
+
# Activation is intentionally loaded before request dispatch.
|
|
282
|
+
# A persisted custom Codex command must block the default adapter
|
|
283
|
+
# until that exact command has passed discovery.
|
|
284
|
+
await self._load_runtime_activation()
|
|
285
|
+
except asyncio.CancelledError:
|
|
286
|
+
raise
|
|
287
|
+
except Exception:
|
|
288
|
+
# Backward-compatible with servers that predate this endpoint.
|
|
289
|
+
logger.exception("loading runtime activation failed after connect")
|
|
290
|
+
startup_task = asyncio.create_task(self._post_connect_startup())
|
|
291
|
+
async for raw_message in ws:
|
|
292
|
+
message = json.loads(raw_message)
|
|
293
|
+
await self.handle_message(message)
|
|
294
|
+
finally:
|
|
295
|
+
heartbeat_task.cancel()
|
|
296
|
+
if startup_task is not None:
|
|
297
|
+
startup_task.cancel()
|
|
298
|
+
self._ws = None
|
|
299
|
+
|
|
300
|
+
async def _post_connect_startup(self) -> None:
|
|
301
|
+
"""Discover agents then start periodic session sync (non-blocking for WS)."""
|
|
302
|
+
try:
|
|
303
|
+
await self._discover_and_publish_capabilities()
|
|
304
|
+
except asyncio.CancelledError:
|
|
305
|
+
raise
|
|
306
|
+
except Exception:
|
|
307
|
+
logger.exception("runtime capability discovery failed after connect")
|
|
308
|
+
try:
|
|
309
|
+
await self._sync_existing_loop()
|
|
310
|
+
except asyncio.CancelledError:
|
|
311
|
+
raise
|
|
312
|
+
except Exception:
|
|
313
|
+
logger.exception("existing session sync loop exited")
|
|
314
|
+
|
|
315
|
+
async def authenticate(self) -> str:
|
|
316
|
+
client = self._http_client
|
|
317
|
+
# `authenticate()` may be called before `run_forever` initialized the
|
|
318
|
+
# shared client (e.g. tests that drive the client directly). Fall
|
|
319
|
+
# back to a one-shot client in that case.
|
|
320
|
+
owned = client is None
|
|
321
|
+
if client is None:
|
|
322
|
+
client = self._new_http_client(timeout=30)
|
|
323
|
+
try:
|
|
324
|
+
response = await client.post(
|
|
325
|
+
urljoin(self.config.server_url + "/", "connector/auth"),
|
|
326
|
+
headers={
|
|
327
|
+
"Authorization": f"Connector {self.config.connector_id}:{self.config.connector_token}",
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
if response.status_code == 401:
|
|
331
|
+
raise ConnectorAuthenticationError("invalid connector credential")
|
|
332
|
+
response.raise_for_status()
|
|
333
|
+
body = response.json()
|
|
334
|
+
access_token = body["accessToken"]
|
|
335
|
+
if not isinstance(access_token, str):
|
|
336
|
+
raise RuntimeError("backend returned invalid connector accessToken")
|
|
337
|
+
expires_in = body.get("expiresIn")
|
|
338
|
+
if not isinstance(expires_in, int | float):
|
|
339
|
+
raise RuntimeError("backend returned invalid connector expiresIn")
|
|
340
|
+
self._access_token = access_token
|
|
341
|
+
self._access_token_expires_at = time.monotonic() + float(expires_in)
|
|
342
|
+
return access_token
|
|
343
|
+
finally:
|
|
344
|
+
if owned:
|
|
345
|
+
await client.aclose()
|
|
346
|
+
|
|
347
|
+
async def ensure_access_token(self, *, force: bool = False) -> str:
|
|
348
|
+
async with self._auth_lock:
|
|
349
|
+
if not force and self._access_token and time.monotonic() < self._access_token_expires_at - ACCESS_TOKEN_REFRESH_SKEW_SECONDS:
|
|
350
|
+
return self._access_token
|
|
351
|
+
return await self.authenticate()
|
|
352
|
+
|
|
353
|
+
async def handle_message(self, message: dict[str, Any]) -> None:
|
|
354
|
+
if message.get("type") != "request":
|
|
355
|
+
return
|
|
356
|
+
request_id = message.get("id")
|
|
357
|
+
method = message.get("method")
|
|
358
|
+
params = message.get("params") if isinstance(message.get("params"), dict) else {}
|
|
359
|
+
if not isinstance(request_id, str) or not isinstance(method, str):
|
|
360
|
+
return
|
|
361
|
+
started = time.perf_counter()
|
|
362
|
+
outcome = "error"
|
|
363
|
+
try:
|
|
364
|
+
result = await self.dispatch(method, params)
|
|
365
|
+
outcome = "ok"
|
|
366
|
+
await self.send_response(request_id, ok=True, result=result)
|
|
367
|
+
except Exception as exc:
|
|
368
|
+
logger.exception("connector request failed method={} id={}", method, request_id)
|
|
369
|
+
# If the exception declares a `code` (e.g. StaleFileError → "stale"),
|
|
370
|
+
# surface that so the backend can translate it into a 412 etc.
|
|
371
|
+
code = getattr(exc, "code", None) or exc.__class__.__name__
|
|
372
|
+
await self.send_response(
|
|
373
|
+
request_id,
|
|
374
|
+
ok=False,
|
|
375
|
+
error={"code": code, "message": str(exc)},
|
|
376
|
+
)
|
|
377
|
+
finally:
|
|
378
|
+
if method in _PERF_DISPATCH_METHODS:
|
|
379
|
+
log_stage(
|
|
380
|
+
"connector.dispatch",
|
|
381
|
+
elapsed_ms(started),
|
|
382
|
+
method=method,
|
|
383
|
+
runtime=params.get("runtime") if isinstance(params.get("runtime"), str) else None,
|
|
384
|
+
session_id=params.get("sessionId") if isinstance(params.get("sessionId"), str) else None,
|
|
385
|
+
connector_id=self.config.connector_id,
|
|
386
|
+
outcome=outcome,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
async def dispatch(self, method: str, params: dict[str, Any]) -> Any:
|
|
390
|
+
if method == "session.discover":
|
|
391
|
+
adapter = self._resolve_adapter(params)
|
|
392
|
+
result = await adapter.sync_existing_sessions(
|
|
393
|
+
self.config.connector_id,
|
|
394
|
+
limit=int(params.get("limit", 100)),
|
|
395
|
+
force=bool(params.get("force", True)),
|
|
396
|
+
notification_sink=self.enqueue_backend_notifications,
|
|
397
|
+
)
|
|
398
|
+
return _strip_backend_notifications(result)
|
|
399
|
+
if method == "session.create":
|
|
400
|
+
adapter = self._resolve_adapter(params)
|
|
401
|
+
result = await adapter.create_session({**params, "connectorId": self.config.connector_id})
|
|
402
|
+
await self._send_backend_notifications(result)
|
|
403
|
+
return _strip_backend_notifications(result)
|
|
404
|
+
if method == "session.sync":
|
|
405
|
+
adapter = self._resolve_adapter(params)
|
|
406
|
+
result = await adapter.sync_session(params)
|
|
407
|
+
await self._send_backend_notifications(result)
|
|
408
|
+
return _strip_backend_notifications(result)
|
|
409
|
+
if method == "turn.start":
|
|
410
|
+
return await self._resolve_adapter(params).start_turn(
|
|
411
|
+
{**params, "connectorId": self.config.connector_id}
|
|
412
|
+
)
|
|
413
|
+
if method == "command.execute":
|
|
414
|
+
adapter = self._resolve_adapter(params)
|
|
415
|
+
execute_command = getattr(adapter, "execute_command", None)
|
|
416
|
+
if execute_command is None:
|
|
417
|
+
raise ValueError("command_unavailable")
|
|
418
|
+
result = await execute_command({**params, "connectorId": self.config.connector_id})
|
|
419
|
+
await self._send_backend_notifications(result)
|
|
420
|
+
return _strip_backend_notifications(result)
|
|
421
|
+
if method == "turn.interrupt":
|
|
422
|
+
return await self._resolve_adapter(params).interrupt_turn(params)
|
|
423
|
+
if method == "approval.resolve":
|
|
424
|
+
return await self._resolve_adapter(params).resolve_approval(params)
|
|
425
|
+
if method == "fs.prepareDownload":
|
|
426
|
+
return await self.local_ops.prepare_download(params)
|
|
427
|
+
if method == "fs.uploadPreparedDownload":
|
|
428
|
+
task = asyncio.create_task(self.upload_prepared_download(params))
|
|
429
|
+
self._background_tasks.add(task)
|
|
430
|
+
task.add_done_callback(self._on_background_upload_done)
|
|
431
|
+
return {"transferId": params.get("transferId"), "uploadStarted": True}
|
|
432
|
+
if method == "fs.writeFile":
|
|
433
|
+
return await self.local_ops.write_file(params)
|
|
434
|
+
if method == "fs.readDir":
|
|
435
|
+
return await self.local_ops.read_dir(params)
|
|
436
|
+
if method == "fs.readText":
|
|
437
|
+
return await self.local_ops.read_text(params)
|
|
438
|
+
if method == "shell.exec":
|
|
439
|
+
return await self.local_ops.shell_exec(params)
|
|
440
|
+
if method == "shell.task.start":
|
|
441
|
+
return await self.local_ops.shell_task_start(params)
|
|
442
|
+
if method == "shell.task.cancel":
|
|
443
|
+
return await self.local_ops.shell_task_cancel(params)
|
|
444
|
+
if method == "terminal.create":
|
|
445
|
+
return await self.local_ops.terminal_create(params)
|
|
446
|
+
if method == "terminal.write":
|
|
447
|
+
return await self.local_ops.terminal_write(params)
|
|
448
|
+
if method == "terminal.resize":
|
|
449
|
+
return await self.local_ops.terminal_resize(params)
|
|
450
|
+
if method == "terminal.close":
|
|
451
|
+
return await self.local_ops.terminal_close(params)
|
|
452
|
+
if method == "terminal.rename":
|
|
453
|
+
return await self.local_ops.terminal_rename(params)
|
|
454
|
+
if method == "terminal.list":
|
|
455
|
+
return await self.local_ops.terminal_list(params)
|
|
456
|
+
if method == "terminal.release":
|
|
457
|
+
return await self.local_ops.terminal_release(params)
|
|
458
|
+
if method == "terminal.snapshot":
|
|
459
|
+
return await self.local_ops.terminal_snapshot(params)
|
|
460
|
+
if method == "terminal.relay.connect":
|
|
461
|
+
return await self.start_terminal_relay(params)
|
|
462
|
+
if method == "capabilities.scanRuntime":
|
|
463
|
+
runtime = params.get("runtime")
|
|
464
|
+
if not isinstance(runtime, str) or not runtime:
|
|
465
|
+
raise ValueError("missing runtime")
|
|
466
|
+
path_value = params.get("path")
|
|
467
|
+
path = path_value if isinstance(path_value, str) and path_value else None
|
|
468
|
+
return await self._scan_runtime(runtime, path)
|
|
469
|
+
if method == "capabilities.invalidateRuntime":
|
|
470
|
+
runtime = params.get("runtime")
|
|
471
|
+
if not isinstance(runtime, str) or not runtime:
|
|
472
|
+
raise ValueError("missing runtime")
|
|
473
|
+
self._invalidate_runtime(runtime)
|
|
474
|
+
return {"runtime": runtime, "invalidated": True}
|
|
475
|
+
if method == "capabilities.setActiveRuntimes":
|
|
476
|
+
runtimes = params.get("runtimes")
|
|
477
|
+
if not isinstance(runtimes, list):
|
|
478
|
+
raise ValueError("missing runtimes")
|
|
479
|
+
launch_specs = params.get("launchCommands")
|
|
480
|
+
if isinstance(launch_specs, dict):
|
|
481
|
+
codex_spec = launch_specs.get("codex")
|
|
482
|
+
if isinstance(codex_spec, dict):
|
|
483
|
+
await self._apply_codex_launch_spec(codex_spec)
|
|
484
|
+
active = {runtime for runtime in runtimes if isinstance(runtime, str) and runtime}
|
|
485
|
+
previous = self._active_runtimes or set()
|
|
486
|
+
newly_active = active - previous
|
|
487
|
+
self._active_runtimes = active
|
|
488
|
+
revision = params.get("revision")
|
|
489
|
+
logger.info("active runtimes updated runtimes={} revision={}", sorted(active), revision)
|
|
490
|
+
# Warm adapters that expose warm_start (e.g. ACP) so first turn is not cold.
|
|
491
|
+
# First assignment (or after clear): warm the full active set; later only newly activated.
|
|
492
|
+
to_warm = active if not previous else newly_active
|
|
493
|
+
for runtime_id in to_warm:
|
|
494
|
+
adapter = self.adapters.get(runtime_id)
|
|
495
|
+
if adapter is None:
|
|
496
|
+
continue
|
|
497
|
+
warm_start = getattr(adapter, "warm_start", None)
|
|
498
|
+
if callable(warm_start):
|
|
499
|
+
asyncio.create_task(warm_start())
|
|
500
|
+
return {"runtimes": [runtime for runtime in runtimes if isinstance(runtime, str) and runtime], "revision": revision}
|
|
501
|
+
if method == "runtime.configureLaunch":
|
|
502
|
+
runtime = params.get("runtime")
|
|
503
|
+
if runtime != "codex":
|
|
504
|
+
raise ValueError("runtime.configureLaunch currently supports only codex")
|
|
505
|
+
return await self._configure_codex_launch(params)
|
|
506
|
+
if method == "capabilities.forceResyncRuntime":
|
|
507
|
+
# Backend fires this after it has committed user intent and sent
|
|
508
|
+
# the active runtime set. Fail-soft if the adapter isn't registered.
|
|
509
|
+
runtime = params.get("runtime")
|
|
510
|
+
if not isinstance(runtime, str) or not runtime:
|
|
511
|
+
raise ValueError("missing runtime")
|
|
512
|
+
await self._force_resync_runtime(runtime)
|
|
513
|
+
return {"runtime": runtime, "resynced": True}
|
|
514
|
+
if method == "runtime.authenticate":
|
|
515
|
+
# User-triggered interactive ACP login (browser OAuth). Never auto-run.
|
|
516
|
+
runtime = params.get("runtime")
|
|
517
|
+
if not isinstance(runtime, str) or not runtime:
|
|
518
|
+
raise ValueError("missing runtime")
|
|
519
|
+
adapter = self.adapters.get(runtime)
|
|
520
|
+
if not isinstance(adapter, AcpAdapter):
|
|
521
|
+
raise ValueError(
|
|
522
|
+
f"runtime.authenticate is only supported for ACP agents; got {runtime!r}"
|
|
523
|
+
)
|
|
524
|
+
result = await adapter.authenticate_interactive(params)
|
|
525
|
+
if isinstance(result, dict):
|
|
526
|
+
# Keep in-memory discovery report in sync for subsequent scans.
|
|
527
|
+
report_fields = {
|
|
528
|
+
key: result[key]
|
|
529
|
+
for key in (
|
|
530
|
+
"authStatus",
|
|
531
|
+
"authMethods",
|
|
532
|
+
"authHint",
|
|
533
|
+
"modelOptions",
|
|
534
|
+
"modeOptions",
|
|
535
|
+
"configOptions",
|
|
536
|
+
)
|
|
537
|
+
if result.get(key) is not None
|
|
538
|
+
}
|
|
539
|
+
if report_fields:
|
|
540
|
+
existing = {}
|
|
541
|
+
if self._runtime_capabilities and isinstance(
|
|
542
|
+
self._runtime_capabilities.get("runtimes"), dict
|
|
543
|
+
):
|
|
544
|
+
prev = self._runtime_capabilities["runtimes"].get(runtime)
|
|
545
|
+
if isinstance(prev, dict):
|
|
546
|
+
existing = dict(prev)
|
|
547
|
+
existing.update(report_fields)
|
|
548
|
+
self._record_runtime_report(runtime, existing)
|
|
549
|
+
return result
|
|
550
|
+
raise ValueError(f"unsupported connector method: {method}")
|
|
551
|
+
|
|
552
|
+
async def send_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
553
|
+
await self._send_json({"type": "notification", "method": method, "params": params})
|
|
554
|
+
|
|
555
|
+
async def send_backend_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
556
|
+
"""Enqueue a notification for the next flush window.
|
|
557
|
+
|
|
558
|
+
Background: Codex emits one stdout line per token chunk during
|
|
559
|
+
streaming. The old code POSTed each one synchronously, so the next
|
|
560
|
+
chunk's POST waited for the prior round-trip. Now we hand the
|
|
561
|
+
notification to `_flush_loop`, which batches and sends one POST per
|
|
562
|
+
~20ms window.
|
|
563
|
+
|
|
564
|
+
Exception: the first assistant text delta for a turn bypasses the
|
|
565
|
+
window so TTFB is not inflated by FLUSH_WINDOW_SECONDS.
|
|
566
|
+
"""
|
|
567
|
+
notification = {"method": method, "params": params}
|
|
568
|
+
turn_key = _assistant_ttfb_turn_key(method, params)
|
|
569
|
+
if turn_key is not None and turn_key not in self._ttfb_flushed_turns:
|
|
570
|
+
self._ttfb_flushed_turns.add(turn_key)
|
|
571
|
+
if len(self._ttfb_flushed_turns) > 256:
|
|
572
|
+
# Bound memory; oldest keys are irrelevant once turns finish.
|
|
573
|
+
self._ttfb_flushed_turns = set(list(self._ttfb_flushed_turns)[-128:])
|
|
574
|
+
try:
|
|
575
|
+
await self._post_batch([notification])
|
|
576
|
+
return
|
|
577
|
+
except Exception:
|
|
578
|
+
logger.exception("priority TTFB ingest failed; falling back to flush queue")
|
|
579
|
+
await self._notify_queue.put(notification)
|
|
580
|
+
|
|
581
|
+
async def send_response(
|
|
582
|
+
self,
|
|
583
|
+
request_id: str,
|
|
584
|
+
*,
|
|
585
|
+
ok: bool,
|
|
586
|
+
result: Any = None,
|
|
587
|
+
error: dict[str, str] | None = None,
|
|
588
|
+
) -> None:
|
|
589
|
+
payload: dict[str, Any] = {"id": request_id, "type": "response", "ok": ok}
|
|
590
|
+
if ok:
|
|
591
|
+
payload["result"] = result
|
|
592
|
+
else:
|
|
593
|
+
payload["error"] = error or {"code": "error", "message": "connector request failed"}
|
|
594
|
+
await self._send_json(payload)
|
|
595
|
+
|
|
596
|
+
async def _send_json(self, payload: dict[str, Any]) -> None:
|
|
597
|
+
if self._ws is None:
|
|
598
|
+
raise RuntimeError("backend websocket is not connected")
|
|
599
|
+
async with self._send_lock:
|
|
600
|
+
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
|
601
|
+
|
|
602
|
+
async def _heartbeat_loop(self) -> None:
|
|
603
|
+
# Immediate first beat so the server never waits a full interval after connect.
|
|
604
|
+
while True:
|
|
605
|
+
try:
|
|
606
|
+
await self.send_notification("connector.heartbeat", {})
|
|
607
|
+
except Exception:
|
|
608
|
+
logger.exception("connector heartbeat failed")
|
|
609
|
+
return
|
|
610
|
+
await asyncio.sleep(self.config.heartbeat_seconds)
|
|
611
|
+
|
|
612
|
+
async def _flush_loop(self) -> None:
|
|
613
|
+
"""Drain `_notify_queue` and POST in batches.
|
|
614
|
+
|
|
615
|
+
Pulls items via blocking `get()`. Once an item arrives, opens a
|
|
616
|
+
short FLUSH_WINDOW_SECONDS window during which additional items get
|
|
617
|
+
coalesced into the same POST. Flushes early when the batch hits
|
|
618
|
+
FLUSH_MAX. Errors are logged and the loop continues — losing a
|
|
619
|
+
notification is preferable to hanging the connector.
|
|
620
|
+
"""
|
|
621
|
+
while True:
|
|
622
|
+
try:
|
|
623
|
+
first = await self._notify_queue.get()
|
|
624
|
+
except asyncio.CancelledError:
|
|
625
|
+
return
|
|
626
|
+
batch: list[dict[str, Any]] = [first]
|
|
627
|
+
deadline = asyncio.get_event_loop().time() + FLUSH_WINDOW_SECONDS
|
|
628
|
+
while len(batch) < FLUSH_MAX:
|
|
629
|
+
remaining = deadline - asyncio.get_event_loop().time()
|
|
630
|
+
if remaining <= 0:
|
|
631
|
+
break
|
|
632
|
+
try:
|
|
633
|
+
item = await asyncio.wait_for(self._notify_queue.get(), timeout=remaining)
|
|
634
|
+
except asyncio.TimeoutError:
|
|
635
|
+
break
|
|
636
|
+
except asyncio.CancelledError:
|
|
637
|
+
# Best-effort: flush what we have, then exit.
|
|
638
|
+
try:
|
|
639
|
+
await self._post_batch(batch)
|
|
640
|
+
except Exception:
|
|
641
|
+
pass
|
|
642
|
+
return
|
|
643
|
+
batch.append(item)
|
|
644
|
+
try:
|
|
645
|
+
await self._post_batch(batch)
|
|
646
|
+
except Exception:
|
|
647
|
+
logger.exception("connector ingest flush failed (dropped {} notifications)", len(batch))
|
|
648
|
+
|
|
649
|
+
async def _sync_existing_loop(self) -> None:
|
|
650
|
+
if not self.config.sync_existing_on_connect:
|
|
651
|
+
return
|
|
652
|
+
while True:
|
|
653
|
+
for runtime, adapter in self.adapters.items():
|
|
654
|
+
if runtime not in self._active_runtimes:
|
|
655
|
+
logger.info("skipping {} existing session sync; runtime inactive", runtime)
|
|
656
|
+
continue
|
|
657
|
+
try:
|
|
658
|
+
sync_timeout = (
|
|
659
|
+
RUNTIME_CHANGED_SYNC_TIMEOUT_SECONDS
|
|
660
|
+
if runtime == "codex"
|
|
661
|
+
else RUNTIME_SYNC_TIMEOUT_SECONDS
|
|
662
|
+
)
|
|
663
|
+
await asyncio.wait_for(
|
|
664
|
+
adapter.sync_existing_sessions(
|
|
665
|
+
self.config.connector_id,
|
|
666
|
+
notification_sink=self.enqueue_backend_notifications,
|
|
667
|
+
),
|
|
668
|
+
timeout=sync_timeout,
|
|
669
|
+
)
|
|
670
|
+
except NotImplementedError:
|
|
671
|
+
# Stub adapters (e.g. Claude until Task 3) opt out by
|
|
672
|
+
# raising NotImplementedError — that's fine.
|
|
673
|
+
pass
|
|
674
|
+
except TimeoutError:
|
|
675
|
+
logger.warning("existing {} session sync timed out", runtime)
|
|
676
|
+
except Exception:
|
|
677
|
+
logger.exception("existing {} session sync failed", runtime)
|
|
678
|
+
await self._push_preferences_if_changed()
|
|
679
|
+
await asyncio.sleep(self.config.sync_interval_seconds)
|
|
680
|
+
|
|
681
|
+
async def _push_preferences_if_changed(self) -> None:
|
|
682
|
+
try:
|
|
683
|
+
current = self._preferences_reader()
|
|
684
|
+
except Exception:
|
|
685
|
+
logger.exception("reading local preferences failed")
|
|
686
|
+
return
|
|
687
|
+
if not isinstance(current, dict):
|
|
688
|
+
return
|
|
689
|
+
# readAt is a per-call timestamp — strip it before diffing so we don't
|
|
690
|
+
# push an "update" every cycle when nothing actually changed.
|
|
691
|
+
if _preferences_signature(current) == _preferences_signature(self._last_preferences or {}):
|
|
692
|
+
return
|
|
693
|
+
self._last_preferences = current
|
|
694
|
+
await self.send_notification("connector.preferencesUpdated", current)
|
|
695
|
+
|
|
696
|
+
async def _discover_and_publish_capabilities(self) -> None:
|
|
697
|
+
try:
|
|
698
|
+
spec = self._codex_launch_spec
|
|
699
|
+
configured = spec.get("command") if spec.get("mode") == "command" else None
|
|
700
|
+
discovery = (
|
|
701
|
+
await discover_runtime_capabilities(codex_launch_command=configured)
|
|
702
|
+
if configured
|
|
703
|
+
else await discover_runtime_capabilities()
|
|
704
|
+
)
|
|
705
|
+
except Exception:
|
|
706
|
+
logger.exception("runtime capability discovery failed")
|
|
707
|
+
return
|
|
708
|
+
self._runtime_capabilities = discovery.report
|
|
709
|
+
codex_target = getattr(discovery, "codex_target", None) or discovery.codex_bin
|
|
710
|
+
if codex_target:
|
|
711
|
+
await self._rewire_codex(codex_target)
|
|
712
|
+
else:
|
|
713
|
+
self._blocked_runtimes.add("codex")
|
|
714
|
+
self._active_runtimes.discard("codex")
|
|
715
|
+
self._rewire_claude(getattr(discovery, "claude_target", None) or discovery.claude_bin)
|
|
716
|
+
acp_targets = getattr(discovery, "acp_targets", None) or {}
|
|
717
|
+
for runtime_id, target in acp_targets.items():
|
|
718
|
+
self._rewire_acp(runtime_id, target)
|
|
719
|
+
await self.send_notification("connector.capabilitiesUpdated", discovery.report)
|
|
720
|
+
|
|
721
|
+
async def _rewire_codex(self, codex_target: LaunchCommand | LaunchTarget | str | None) -> None:
|
|
722
|
+
if not codex_target:
|
|
723
|
+
return
|
|
724
|
+
if isinstance(codex_target, LaunchCommand):
|
|
725
|
+
launch = codex_target
|
|
726
|
+
else:
|
|
727
|
+
target = codex_target if isinstance(codex_target, LaunchTarget) else launch_target("cli", codex_target)
|
|
728
|
+
launch = launch_command_from_target(target)
|
|
729
|
+
codex = self.adapters.get("codex")
|
|
730
|
+
if not isinstance(codex, CodexAdapter):
|
|
731
|
+
return
|
|
732
|
+
command = launch.command(["app-server", "--listen", "stdio://"])
|
|
733
|
+
if (
|
|
734
|
+
codex.rpc is not None
|
|
735
|
+
and codex.rpc.command == command
|
|
736
|
+
and getattr(codex, "_started", False)
|
|
737
|
+
):
|
|
738
|
+
self._blocked_runtimes.discard("codex")
|
|
739
|
+
return
|
|
740
|
+
if codex.rpc is not None:
|
|
741
|
+
try:
|
|
742
|
+
await codex.rpc.close()
|
|
743
|
+
except Exception:
|
|
744
|
+
logger.exception("closing previous codex app-server failed")
|
|
745
|
+
codex.rpc = JsonRpcStdioClient(command=command)
|
|
746
|
+
codex._started = False
|
|
747
|
+
self._blocked_runtimes.discard("codex")
|
|
748
|
+
|
|
749
|
+
async def _configure_codex_launch(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
750
|
+
mode = str(params.get("mode") or "auto")
|
|
751
|
+
if mode not in {"auto", "command"}:
|
|
752
|
+
raise ValueError("launch mode must be auto or command")
|
|
753
|
+
raw = params.get("command")
|
|
754
|
+
if mode == "command":
|
|
755
|
+
if not isinstance(raw, str):
|
|
756
|
+
raise ValueError("missing Codex launch command")
|
|
757
|
+
launch = parse_launch_command(raw)
|
|
758
|
+
else:
|
|
759
|
+
report, target = await discover_codex_capability()
|
|
760
|
+
if target is None:
|
|
761
|
+
raise RuntimeError(report.get("error", {}).get("message") or "Codex is unavailable")
|
|
762
|
+
launch = target if isinstance(target, LaunchCommand) else launch_command_from_target(target)
|
|
763
|
+
|
|
764
|
+
codex = self.adapters.get("codex")
|
|
765
|
+
if not isinstance(codex, CodexAdapter):
|
|
766
|
+
raise RuntimeError("Codex adapter is unavailable")
|
|
767
|
+
async with self._codex_launch_lock:
|
|
768
|
+
report, candidate = await prepare_codex_launch(launch, codex.handle_notification)
|
|
769
|
+
old_rpc = codex.rpc
|
|
770
|
+
try:
|
|
771
|
+
codex.rpc = candidate
|
|
772
|
+
codex._started = False
|
|
773
|
+
codex.forget_sync_state()
|
|
774
|
+
self._codex_launch_spec = {
|
|
775
|
+
"mode": mode,
|
|
776
|
+
**({"command": launch.raw} if mode == "command" else {}),
|
|
777
|
+
}
|
|
778
|
+
self._blocked_runtimes.discard("codex")
|
|
779
|
+
report["launch"] = launch.report(mode=mode)
|
|
780
|
+
self._record_runtime_report("codex", report)
|
|
781
|
+
except Exception:
|
|
782
|
+
codex.rpc = old_rpc
|
|
783
|
+
await candidate.close()
|
|
784
|
+
raise
|
|
785
|
+
if old_rpc is not None and old_rpc is not candidate:
|
|
786
|
+
try:
|
|
787
|
+
await old_rpc.close()
|
|
788
|
+
except Exception:
|
|
789
|
+
logger.exception("closing previous codex app-server after launch switch failed")
|
|
790
|
+
return {"runtime": "codex", "report": report, "launch": report["launch"]}
|
|
791
|
+
|
|
792
|
+
async def _apply_codex_launch_spec(self, spec: dict[str, Any]) -> None:
|
|
793
|
+
mode = str(spec.get("mode") or "auto")
|
|
794
|
+
command = spec.get("command") if isinstance(spec.get("command"), str) else None
|
|
795
|
+
normalized = {"mode": mode, **({"command": command} if command else {})}
|
|
796
|
+
if normalized == self._codex_launch_spec and "codex" not in self._blocked_runtimes:
|
|
797
|
+
return
|
|
798
|
+
await self._configure_codex_launch({"runtime": "codex", **normalized})
|
|
799
|
+
|
|
800
|
+
async def _load_runtime_activation(self) -> None:
|
|
801
|
+
payload = await self._get_json("connector/runtime-activation")
|
|
802
|
+
runtimes = payload.get("runtimes")
|
|
803
|
+
if isinstance(runtimes, list):
|
|
804
|
+
self._active_runtimes = {item for item in runtimes if isinstance(item, str) and item}
|
|
805
|
+
launch_specs = payload.get("launchCommands")
|
|
806
|
+
if isinstance(launch_specs, dict):
|
|
807
|
+
spec = launch_specs.get("codex")
|
|
808
|
+
if isinstance(spec, dict):
|
|
809
|
+
self._codex_launch_spec = {
|
|
810
|
+
"mode": str(spec.get("mode") or "auto"),
|
|
811
|
+
**({"command": spec["command"]} if isinstance(spec.get("command"), str) else {}),
|
|
812
|
+
}
|
|
813
|
+
if self._codex_launch_spec.get("mode") == "command":
|
|
814
|
+
self._blocked_runtimes.add("codex")
|
|
815
|
+
else:
|
|
816
|
+
self._blocked_runtimes.discard("codex")
|
|
817
|
+
|
|
818
|
+
def _rewire_claude(self, claude_target: LaunchTarget | str | None) -> None:
|
|
819
|
+
if not claude_target:
|
|
820
|
+
return
|
|
821
|
+
claude = self.adapters.get("claude")
|
|
822
|
+
target = claude_target if isinstance(claude_target, LaunchTarget) else launch_target("cli", claude_target)
|
|
823
|
+
if claude is not None and hasattr(claude, "claude_target"):
|
|
824
|
+
claude.claude_target = target # type: ignore[attr-defined]
|
|
825
|
+
|
|
826
|
+
def _rewire_acp(self, runtime: str, target: LaunchTarget | str | None) -> None:
|
|
827
|
+
adapter = self.adapters.get(runtime)
|
|
828
|
+
if not isinstance(adapter, AcpAdapter):
|
|
829
|
+
return
|
|
830
|
+
if not target:
|
|
831
|
+
return
|
|
832
|
+
launch = target if isinstance(target, LaunchTarget) else launch_target("cli", target)
|
|
833
|
+
adapter.rewire(launch)
|
|
834
|
+
|
|
835
|
+
async def _scan_runtime(
|
|
836
|
+
self, runtime: str, path: str | None
|
|
837
|
+
) -> dict[str, Any]:
|
|
838
|
+
"""Scan a single runtime (with optional custom path) and rewire its
|
|
839
|
+
adapter. Discovery only — does NOT push sessions.
|
|
840
|
+
|
|
841
|
+
Session sync is a separate `capabilities.forceResyncRuntime` RPC
|
|
842
|
+
that the backend fires AFTER it has committed user intent to DB.
|
|
843
|
+
Order matters: if we pushed sessions inside this dispatch, they'd
|
|
844
|
+
arrive at /connector/ingest while the runtime is still in
|
|
845
|
+
disabled user intent on the server (the user is re-adding after a Delete),
|
|
846
|
+
and the IngestFilter would drop them all.
|
|
847
|
+
"""
|
|
848
|
+
if runtime == "codex":
|
|
849
|
+
report, codex_target = await discover_codex_capability(extra_candidate=path)
|
|
850
|
+
await self._rewire_codex(codex_target)
|
|
851
|
+
self._record_runtime_report("codex", report)
|
|
852
|
+
return {"runtime": "codex", "report": report}
|
|
853
|
+
if runtime == "claude":
|
|
854
|
+
report, claude_target = await discover_claude_capability(extra_candidate=path)
|
|
855
|
+
self._rewire_claude(claude_target)
|
|
856
|
+
self._record_runtime_report("claude", report)
|
|
857
|
+
return {"runtime": "claude", "report": report}
|
|
858
|
+
# ACP / manifest-driven agents
|
|
859
|
+
report, acp_target = await discover_acp_capability(runtime, extra_candidate=path)
|
|
860
|
+
error = report.get("error") if isinstance(report.get("error"), dict) else {}
|
|
861
|
+
if error.get("code") == "unknown_acp_runtime":
|
|
862
|
+
raise ValueError(f"unsupported runtime {runtime!r}")
|
|
863
|
+
self._rewire_acp(runtime, acp_target)
|
|
864
|
+
self._record_runtime_report(runtime, report)
|
|
865
|
+
return {"runtime": runtime, "report": report}
|
|
866
|
+
|
|
867
|
+
async def _force_resync_runtime(self, runtime: str) -> None:
|
|
868
|
+
adapter = self.adapters.get(runtime)
|
|
869
|
+
if adapter is None:
|
|
870
|
+
return
|
|
871
|
+
try:
|
|
872
|
+
await asyncio.wait_for(
|
|
873
|
+
adapter.sync_existing_sessions(
|
|
874
|
+
self.config.connector_id,
|
|
875
|
+
force=True,
|
|
876
|
+
notification_sink=self.enqueue_backend_notifications,
|
|
877
|
+
),
|
|
878
|
+
timeout=RUNTIME_SYNC_TIMEOUT_SECONDS * 4,
|
|
879
|
+
)
|
|
880
|
+
except NotImplementedError:
|
|
881
|
+
# stub adapters (older tests) opt out — fine
|
|
882
|
+
pass
|
|
883
|
+
except TimeoutError:
|
|
884
|
+
logger.warning(
|
|
885
|
+
"forced {} session sync timed out during refresh/scan", runtime
|
|
886
|
+
)
|
|
887
|
+
except Exception:
|
|
888
|
+
logger.exception(
|
|
889
|
+
"forced {} session sync failed during refresh/scan", runtime
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
def _invalidate_runtime(self, runtime: str) -> None:
|
|
893
|
+
"""Clear adapter sync cursors after the server deleted this runtime."""
|
|
894
|
+
self._active_runtimes.discard(runtime)
|
|
895
|
+
adapter = self.adapters.get(runtime)
|
|
896
|
+
if adapter is None:
|
|
897
|
+
return
|
|
898
|
+
forget_persisted = getattr(adapter, "forget_persisted_sync_state", None)
|
|
899
|
+
if callable(forget_persisted):
|
|
900
|
+
try:
|
|
901
|
+
forget_persisted(self.config.connector_id)
|
|
902
|
+
return
|
|
903
|
+
except Exception:
|
|
904
|
+
logger.exception("forget_persisted_sync_state failed runtime={}", runtime)
|
|
905
|
+
forget = getattr(adapter, "forget_sync_state", None)
|
|
906
|
+
if callable(forget):
|
|
907
|
+
try:
|
|
908
|
+
forget()
|
|
909
|
+
except Exception:
|
|
910
|
+
logger.exception("forget_sync_state failed runtime={}", runtime)
|
|
911
|
+
|
|
912
|
+
async def _get_json(self, path: str) -> dict[str, Any]:
|
|
913
|
+
access_token = await self.ensure_access_token()
|
|
914
|
+
client = self._http_client
|
|
915
|
+
owned = client is None
|
|
916
|
+
if client is None:
|
|
917
|
+
client = self._new_http_client(timeout=30)
|
|
918
|
+
try:
|
|
919
|
+
response = await client.get(
|
|
920
|
+
urljoin(self.config.server_url + "/", path),
|
|
921
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
922
|
+
)
|
|
923
|
+
if getattr(response, "status_code", None) == 401:
|
|
924
|
+
access_token = await self.ensure_access_token(force=True)
|
|
925
|
+
response = await client.get(
|
|
926
|
+
urljoin(self.config.server_url + "/", path),
|
|
927
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
928
|
+
)
|
|
929
|
+
if getattr(response, "status_code", None) == 401:
|
|
930
|
+
raise ConnectorAuthenticationError("connector credential no longer valid")
|
|
931
|
+
response.raise_for_status()
|
|
932
|
+
payload = response.json()
|
|
933
|
+
return payload if isinstance(payload, dict) else {}
|
|
934
|
+
finally:
|
|
935
|
+
if owned:
|
|
936
|
+
await client.aclose()
|
|
937
|
+
|
|
938
|
+
def _record_runtime_report(self, runtime: str, report: dict[str, Any]) -> None:
|
|
939
|
+
if self._runtime_capabilities is None:
|
|
940
|
+
self._runtime_capabilities = {"version": 1, "runtimes": {}}
|
|
941
|
+
runtimes = self._runtime_capabilities.get("runtimes")
|
|
942
|
+
if not isinstance(runtimes, dict):
|
|
943
|
+
runtimes = {}
|
|
944
|
+
self._runtime_capabilities["runtimes"] = runtimes
|
|
945
|
+
runtimes[runtime] = report
|
|
946
|
+
|
|
947
|
+
def _resolve_adapter(self, params: dict[str, Any]) -> Adapter:
|
|
948
|
+
runtime = params.get("runtime") if isinstance(params, dict) else None
|
|
949
|
+
if not isinstance(runtime, str) or not runtime:
|
|
950
|
+
runtime = DEFAULT_RUNTIME
|
|
951
|
+
if runtime in self._blocked_runtimes:
|
|
952
|
+
raise RuntimeError(
|
|
953
|
+
f"runtime {runtime!r} is unavailable because its configured launch command did not validate"
|
|
954
|
+
)
|
|
955
|
+
adapter = self.adapters.get(runtime)
|
|
956
|
+
if adapter is None:
|
|
957
|
+
raise ValueError(f"no adapter registered for runtime {runtime!r}")
|
|
958
|
+
return adapter
|
|
959
|
+
|
|
960
|
+
async def _send_backend_notifications(self, result: dict[str, Any]) -> None:
|
|
961
|
+
for notification in result.get("backendNotifications", []):
|
|
962
|
+
if not isinstance(notification, dict):
|
|
963
|
+
continue
|
|
964
|
+
method = notification.get("method")
|
|
965
|
+
params = notification.get("params")
|
|
966
|
+
if isinstance(method, str) and isinstance(params, dict):
|
|
967
|
+
await self.ingest_notifications([{"method": method, "params": params}])
|
|
968
|
+
|
|
969
|
+
async def enqueue_backend_notifications(self, notifications: list[dict[str, Any]]) -> None:
|
|
970
|
+
for notification in notifications:
|
|
971
|
+
if not isinstance(notification, dict):
|
|
972
|
+
continue
|
|
973
|
+
method = notification.get("method")
|
|
974
|
+
params = notification.get("params")
|
|
975
|
+
if isinstance(method, str) and isinstance(params, dict):
|
|
976
|
+
await self.send_backend_notification(method, params)
|
|
977
|
+
|
|
978
|
+
async def ingest_notifications(self, notifications: list[dict[str, Any]]) -> None:
|
|
979
|
+
"""Send a batch synchronously, bypassing the flush queue.
|
|
980
|
+
|
|
981
|
+
Used by `sync_existing_sessions` which already builds a large
|
|
982
|
+
notification list. Going through the flush queue would force a
|
|
983
|
+
FLUSH_WINDOW_SECONDS delay on each batch with no upside.
|
|
984
|
+
"""
|
|
985
|
+
if not notifications:
|
|
986
|
+
return
|
|
987
|
+
await self._post_batch(list(notifications))
|
|
988
|
+
|
|
989
|
+
async def _post_batch(self, notifications: list[dict[str, Any]]) -> None:
|
|
990
|
+
if not notifications:
|
|
991
|
+
return
|
|
992
|
+
notifications = _coalesce_timeline_item_upserts(notifications)
|
|
993
|
+
if not notifications:
|
|
994
|
+
return
|
|
995
|
+
access_token = await self.ensure_access_token()
|
|
996
|
+
client = self._http_client
|
|
997
|
+
owned = client is None
|
|
998
|
+
if client is None:
|
|
999
|
+
client = self._new_http_client(timeout=60)
|
|
1000
|
+
try:
|
|
1001
|
+
response = await self._post_ingest_batch(client, access_token, notifications)
|
|
1002
|
+
if getattr(response, "status_code", None) == 401:
|
|
1003
|
+
logger.warning("connector ingest token rejected; refreshing access token and retrying")
|
|
1004
|
+
access_token = await self.ensure_access_token(force=True)
|
|
1005
|
+
response = await self._post_ingest_batch(client, access_token, notifications)
|
|
1006
|
+
if getattr(response, "status_code", None) == 401:
|
|
1007
|
+
raise ConnectorAuthenticationError("connector credential no longer valid")
|
|
1008
|
+
response.raise_for_status()
|
|
1009
|
+
finally:
|
|
1010
|
+
if owned:
|
|
1011
|
+
await client.aclose()
|
|
1012
|
+
|
|
1013
|
+
async def _post_ingest_batch(
|
|
1014
|
+
self,
|
|
1015
|
+
client: httpx.AsyncClient,
|
|
1016
|
+
access_token: str,
|
|
1017
|
+
notifications: list[dict[str, Any]],
|
|
1018
|
+
) -> httpx.Response:
|
|
1019
|
+
return await client.post(
|
|
1020
|
+
urljoin(self.config.server_url + "/", "connector/ingest"),
|
|
1021
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
1022
|
+
json={"notifications": notifications},
|
|
1023
|
+
timeout=60,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
async def download_attachment(self, session_id: str, file_id: str) -> tuple[bytes, str, str]:
|
|
1027
|
+
"""Pull a user-uploaded attachment by session_id and file_id.
|
|
1028
|
+
|
|
1029
|
+
Returns (data, filename, media_type). The backend keeps the durable
|
|
1030
|
+
platform file after runtime consumption; callers still persist a local
|
|
1031
|
+
copy before invoking the agent.
|
|
1032
|
+
"""
|
|
1033
|
+
access_token = await self.ensure_access_token()
|
|
1034
|
+
timeout = httpx.Timeout(300.0, connect=30.0)
|
|
1035
|
+
async with self._new_http_client(timeout=timeout) as client:
|
|
1036
|
+
response = await client.get(
|
|
1037
|
+
urljoin(
|
|
1038
|
+
self.config.server_url + "/",
|
|
1039
|
+
f"connector/sessions/{session_id}/attachments/{file_id}/content",
|
|
1040
|
+
),
|
|
1041
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
1042
|
+
)
|
|
1043
|
+
if getattr(response, "status_code", None) == 401:
|
|
1044
|
+
access_token = await self.ensure_access_token(force=True)
|
|
1045
|
+
response = await client.get(
|
|
1046
|
+
urljoin(
|
|
1047
|
+
self.config.server_url + "/",
|
|
1048
|
+
f"connector/sessions/{session_id}/attachments/{file_id}/content",
|
|
1049
|
+
),
|
|
1050
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
1051
|
+
)
|
|
1052
|
+
if getattr(response, "status_code", None) == 401:
|
|
1053
|
+
raise ConnectorAuthenticationError("connector credential no longer valid")
|
|
1054
|
+
response.raise_for_status()
|
|
1055
|
+
name = response.headers.get("X-File-Name") or file_id
|
|
1056
|
+
media_type = response.headers.get("Content-Type") or "application/octet-stream"
|
|
1057
|
+
logger.info(
|
|
1058
|
+
"downloaded user attachment file_id={} size={} mediaType={}",
|
|
1059
|
+
file_id,
|
|
1060
|
+
len(response.content),
|
|
1061
|
+
media_type,
|
|
1062
|
+
)
|
|
1063
|
+
return response.content, name, media_type
|
|
1064
|
+
|
|
1065
|
+
async def upload_prepared_download(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
1066
|
+
transfer_id = params.get("transferId")
|
|
1067
|
+
token = params.get("token")
|
|
1068
|
+
upload_url = params.get("uploadUrl")
|
|
1069
|
+
if not isinstance(transfer_id, str) or not transfer_id:
|
|
1070
|
+
raise ValueError("transferId is required")
|
|
1071
|
+
if not isinstance(token, str) or not token:
|
|
1072
|
+
raise ValueError("token is required")
|
|
1073
|
+
if not isinstance(upload_url, str) or not upload_url:
|
|
1074
|
+
raise ValueError("uploadUrl is required")
|
|
1075
|
+
path = Path(self.local_ops.prepared_download_path(params))
|
|
1076
|
+
if not path.is_file():
|
|
1077
|
+
raise FileNotFoundError(f"file not found: {path}")
|
|
1078
|
+
access_token = await self.ensure_access_token()
|
|
1079
|
+
timeout = httpx.Timeout(300.0, connect=30.0)
|
|
1080
|
+
target = urljoin(self.config.server_url + "/", upload_url.lstrip("/"))
|
|
1081
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1082
|
+
params_query = {"token": token}
|
|
1083
|
+
async with self._new_http_client(timeout=timeout) as client:
|
|
1084
|
+
response = await client.put(
|
|
1085
|
+
target,
|
|
1086
|
+
params=params_query,
|
|
1087
|
+
headers=headers,
|
|
1088
|
+
content=_file_chunks(path),
|
|
1089
|
+
)
|
|
1090
|
+
if getattr(response, "status_code", None) == 401:
|
|
1091
|
+
access_token = await self.ensure_access_token(force=True)
|
|
1092
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
1093
|
+
response = await client.put(
|
|
1094
|
+
target,
|
|
1095
|
+
params=params_query,
|
|
1096
|
+
headers=headers,
|
|
1097
|
+
content=_file_chunks(path),
|
|
1098
|
+
)
|
|
1099
|
+
if getattr(response, "status_code", None) == 401:
|
|
1100
|
+
raise ConnectorAuthenticationError("connector credential no longer valid")
|
|
1101
|
+
response.raise_for_status()
|
|
1102
|
+
return {"transferId": transfer_id, "uploaded": True}
|
|
1103
|
+
|
|
1104
|
+
async def start_terminal_relay(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
1105
|
+
terminal_id = params.get("terminalId")
|
|
1106
|
+
token = params.get("token")
|
|
1107
|
+
if not isinstance(terminal_id, str) or not terminal_id:
|
|
1108
|
+
raise ValueError("terminalId is required")
|
|
1109
|
+
if not isinstance(token, str) or not token:
|
|
1110
|
+
raise ValueError("token is required")
|
|
1111
|
+
task = asyncio.create_task(self._run_terminal_relay(terminal_id, token))
|
|
1112
|
+
self._background_tasks.add(task)
|
|
1113
|
+
task.add_done_callback(self._on_background_upload_done)
|
|
1114
|
+
return {"terminalId": terminal_id, "connecting": True}
|
|
1115
|
+
|
|
1116
|
+
async def _run_terminal_relay(self, terminal_id: str, token: str) -> None:
|
|
1117
|
+
relay_url = _ws_url(self.config.server_url, f"/connector/terminals/{terminal_id}/relay")
|
|
1118
|
+
relay_url = f"{relay_url}?token={token}"
|
|
1119
|
+
logger.info("connecting terminal relay terminal_id={}", terminal_id)
|
|
1120
|
+
send_lock = asyncio.Lock()
|
|
1121
|
+
async with websockets.connect(
|
|
1122
|
+
relay_url,
|
|
1123
|
+
proxy=None if _is_loopback_url(self.config.server_url) else True,
|
|
1124
|
+
) as ws:
|
|
1125
|
+
start_raw = await ws.recv()
|
|
1126
|
+
start = json.loads(start_raw)
|
|
1127
|
+
if not isinstance(start, dict) or start.get("type") != "start":
|
|
1128
|
+
raise RuntimeError("terminal relay missing start frame")
|
|
1129
|
+
|
|
1130
|
+
async def send_frame(frame: dict[str, Any]) -> None:
|
|
1131
|
+
async with send_lock:
|
|
1132
|
+
await ws.send(json.dumps(frame, ensure_ascii=False))
|
|
1133
|
+
|
|
1134
|
+
async def output(method: str, params: dict[str, Any]) -> None:
|
|
1135
|
+
if method == "terminal.output":
|
|
1136
|
+
await send_frame(
|
|
1137
|
+
{
|
|
1138
|
+
"type": "output",
|
|
1139
|
+
"seq": params.get("seq"),
|
|
1140
|
+
"data": params.get("dataBase64"),
|
|
1141
|
+
}
|
|
1142
|
+
)
|
|
1143
|
+
elif method == "terminal.exited":
|
|
1144
|
+
await send_frame(
|
|
1145
|
+
{
|
|
1146
|
+
"type": "exit",
|
|
1147
|
+
"exitCode": params.get("exitCode"),
|
|
1148
|
+
"reason": params.get("reason"),
|
|
1149
|
+
}
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
created = await self.local_ops.terminal.create(start, output=output)
|
|
1153
|
+
await send_frame({"type": "ready", "pid": created.get("pid")})
|
|
1154
|
+
try:
|
|
1155
|
+
async for raw in ws:
|
|
1156
|
+
message = json.loads(raw)
|
|
1157
|
+
if not isinstance(message, dict):
|
|
1158
|
+
continue
|
|
1159
|
+
mtype = message.get("type")
|
|
1160
|
+
if mtype == "input":
|
|
1161
|
+
data = message.get("data")
|
|
1162
|
+
if isinstance(data, str):
|
|
1163
|
+
await self.local_ops.terminal.write(
|
|
1164
|
+
{"terminalId": terminal_id, "dataBase64": data}
|
|
1165
|
+
)
|
|
1166
|
+
elif mtype == "resize":
|
|
1167
|
+
await self.local_ops.terminal.resize(
|
|
1168
|
+
{
|
|
1169
|
+
"terminalId": terminal_id,
|
|
1170
|
+
"cols": message.get("cols"),
|
|
1171
|
+
"rows": message.get("rows"),
|
|
1172
|
+
}
|
|
1173
|
+
)
|
|
1174
|
+
elif mtype == "close":
|
|
1175
|
+
await self.local_ops.terminal.close({"terminalId": terminal_id})
|
|
1176
|
+
break
|
|
1177
|
+
finally:
|
|
1178
|
+
await self.local_ops.terminal.release({"terminalId": terminal_id})
|
|
1179
|
+
|
|
1180
|
+
def _on_background_upload_done(self, task: asyncio.Task[Any]) -> None:
|
|
1181
|
+
self._background_tasks.discard(task)
|
|
1182
|
+
try:
|
|
1183
|
+
task.result()
|
|
1184
|
+
except asyncio.CancelledError:
|
|
1185
|
+
pass
|
|
1186
|
+
except Exception:
|
|
1187
|
+
logger.exception("fs prepared download upload failed")
|
|
1188
|
+
|
|
1189
|
+
def _new_http_client(self, *, timeout: httpx.Timeout | float) -> httpx.AsyncClient:
|
|
1190
|
+
return httpx.AsyncClient(timeout=timeout, trust_env=not _is_loopback_url(self.config.server_url))
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _assistant_ttfb_turn_key(method: str, params: dict[str, Any]) -> str | None:
|
|
1194
|
+
"""Return a turn key for the first assistant text upsert, else None."""
|
|
1195
|
+
if method != "timeline.itemUpsert" or not isinstance(params, dict):
|
|
1196
|
+
return None
|
|
1197
|
+
item = params.get("item")
|
|
1198
|
+
if not isinstance(item, dict):
|
|
1199
|
+
return None
|
|
1200
|
+
if item.get("type") != "message" or item.get("role") != "assistant":
|
|
1201
|
+
return None
|
|
1202
|
+
content = item.get("content")
|
|
1203
|
+
if not isinstance(content, dict):
|
|
1204
|
+
return None
|
|
1205
|
+
text = content.get("text") or content.get("rawText")
|
|
1206
|
+
if not isinstance(text, str) or not text.strip():
|
|
1207
|
+
return None
|
|
1208
|
+
session_id = item.get("sessionId") or params.get("sessionId")
|
|
1209
|
+
turn_id = item.get("turnId")
|
|
1210
|
+
if not isinstance(session_id, str) or not session_id:
|
|
1211
|
+
return None
|
|
1212
|
+
if not isinstance(turn_id, str) or not turn_id:
|
|
1213
|
+
turn_id = "unknown"
|
|
1214
|
+
return f"{session_id}:{turn_id}"
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _strip_backend_notifications(result: dict[str, Any]) -> dict[str, Any]:
|
|
1218
|
+
return {key: value for key, value in result.items() if key != "backendNotifications"}
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
async def _file_chunks(path: Path, chunk_size: int = 1024 * 1024):
|
|
1222
|
+
with path.open("rb") as fh:
|
|
1223
|
+
while True:
|
|
1224
|
+
chunk = fh.read(chunk_size)
|
|
1225
|
+
if not chunk:
|
|
1226
|
+
break
|
|
1227
|
+
yield chunk
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _is_auth_close(exc: ConnectionClosed) -> bool:
|
|
1231
|
+
return _close_code(exc) in {1008, 4001} and "connector" in _close_reason(exc).lower()
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _close_code(exc: ConnectionClosed) -> int | None:
|
|
1235
|
+
close = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
|
|
1236
|
+
code = getattr(close, "code", None)
|
|
1237
|
+
return code if isinstance(code, int) else None
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _close_reason(exc: ConnectionClosed) -> str:
|
|
1241
|
+
close = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
|
|
1242
|
+
reason = getattr(close, "reason", "")
|
|
1243
|
+
return reason if isinstance(reason, str) else ""
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _coalesce_timeline_item_upserts(notifications: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1247
|
+
"""Keep only the newest upsert per timeline item inside one outbound batch."""
|
|
1248
|
+
latest_index_by_key: dict[tuple[str, str], int] = {}
|
|
1249
|
+
dropped: set[int] = set()
|
|
1250
|
+
for index, notification in enumerate(notifications):
|
|
1251
|
+
if notification.get("method") != "timeline.itemUpsert":
|
|
1252
|
+
continue
|
|
1253
|
+
params = notification.get("params")
|
|
1254
|
+
if not isinstance(params, dict):
|
|
1255
|
+
continue
|
|
1256
|
+
session_id = params.get("sessionId")
|
|
1257
|
+
item = params.get("item")
|
|
1258
|
+
item_id = item.get("id") if isinstance(item, dict) else None
|
|
1259
|
+
if not isinstance(session_id, str) or not isinstance(item_id, str):
|
|
1260
|
+
continue
|
|
1261
|
+
key = (session_id, item_id)
|
|
1262
|
+
previous = latest_index_by_key.get(key)
|
|
1263
|
+
if previous is not None:
|
|
1264
|
+
dropped.add(previous)
|
|
1265
|
+
latest_index_by_key[key] = index
|
|
1266
|
+
if not dropped:
|
|
1267
|
+
return notifications
|
|
1268
|
+
return [
|
|
1269
|
+
notification
|
|
1270
|
+
for index, notification in enumerate(notifications)
|
|
1271
|
+
if index not in dropped
|
|
1272
|
+
]
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _ws_url(server_url: str, path: str) -> str:
|
|
1276
|
+
parsed = urlparse(server_url)
|
|
1277
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
1278
|
+
return urlunparse((scheme, parsed.netloc, path, "", "", ""))
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _is_loopback_url(url: str) -> bool:
|
|
1282
|
+
parsed = urlparse(url)
|
|
1283
|
+
host = (parsed.hostname or "").lower()
|
|
1284
|
+
return host in {"127.0.0.1", "localhost", "::1"}
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _device_os() -> str:
|
|
1288
|
+
if sys.platform == "darwin":
|
|
1289
|
+
return "macos"
|
|
1290
|
+
if sys.platform == "win32":
|
|
1291
|
+
return "windows"
|
|
1292
|
+
return "linux"
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _preferences_signature(prefs: dict[str, Any]) -> tuple[tuple[str, Any], ...]:
|
|
1296
|
+
"""Stable signature ignoring volatile `readAt`. Lets us detect real
|
|
1297
|
+
user-driven changes instead of re-pushing every poll cycle."""
|
|
1298
|
+
return tuple(sorted((k, v) for k, v in prefs.items() if k != "readAt"))
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _bool_env(name: str, default: bool) -> bool:
|
|
1302
|
+
value = os.environ.get(name)
|
|
1303
|
+
if value is None:
|
|
1304
|
+
return default
|
|
1305
|
+
return value.lower() in {"1", "true", "yes", "on"}
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def main() -> None:
|
|
1309
|
+
asyncio.run(BackendRpcClient(ConnectorConfig.load()).run_forever())
|