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/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())