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/acp/rpc.py ADDED
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Any
8
+
9
+ from connector.logging import logger
10
+
11
+
12
+ NotificationHandler = Callable[[dict[str, Any]], Awaitable[None]]
13
+ ServerRequestHandler = Callable[[str | int, str, dict[str, Any]], Awaitable[dict[str, Any] | None]]
14
+ ExitHandler = Callable[[], Awaitable[None]]
15
+
16
+ STREAM_LIMIT = 64 * 1024 * 1024
17
+
18
+
19
+ class AcpJsonRpcError(RuntimeError):
20
+ def __init__(self, message: str, *, code: int | None = None, data: Any = None) -> None:
21
+ super().__init__(message)
22
+ self.code = code
23
+ self.data = data
24
+
25
+
26
+ class AcpJsonRpcClient:
27
+ """Newline-delimited JSON-RPC 2.0 client over stdio (ACP transport).
28
+
29
+ Server-initiated requests are handled on background tasks so the stdout
30
+ reader never blocks on long operations (e.g. permission approval).
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ command: list[str],
36
+ *,
37
+ env: dict[str, str] | None = None,
38
+ cwd: str | None = None,
39
+ ) -> None:
40
+ self.command = list(command)
41
+ self.env = env
42
+ # Process cwd is launch context only; session cwd is passed via session/new.
43
+ self.cwd = cwd
44
+ self.process: asyncio.subprocess.Process | None = None
45
+ self._start_lock = asyncio.Lock()
46
+ self._write_lock = asyncio.Lock()
47
+ self._next_id = 1
48
+ self._pending: dict[int | str, asyncio.Future[dict[str, Any]]] = {}
49
+ self._notification_handler: NotificationHandler | None = None
50
+ self._server_request_handler: ServerRequestHandler | None = None
51
+ self._exit_handler: ExitHandler | None = None
52
+ self._stderr_lines: list[str] = []
53
+ self._readers: list[asyncio.Task[None]] = []
54
+ self._server_request_tasks: set[asyncio.Task[None]] = set()
55
+ self._closed = False
56
+
57
+ @property
58
+ def stderr_excerpt(self) -> str:
59
+ return "\n".join(self._stderr_lines[-40:]).strip()
60
+
61
+ @property
62
+ def alive(self) -> bool:
63
+ return self.process is not None and self.process.returncode is None and not self._closed
64
+
65
+ async def start(
66
+ self,
67
+ *,
68
+ notification_handler: NotificationHandler | None = None,
69
+ server_request_handler: ServerRequestHandler | None = None,
70
+ exit_handler: ExitHandler | None = None,
71
+ ) -> None:
72
+ async with self._start_lock:
73
+ if notification_handler is not None:
74
+ self._notification_handler = notification_handler
75
+ if server_request_handler is not None:
76
+ self._server_request_handler = server_request_handler
77
+ if exit_handler is not None:
78
+ self._exit_handler = exit_handler
79
+ if self.process is not None and self.process.returncode is None and not self._closed:
80
+ return
81
+ await self._spawn()
82
+
83
+ async def request(
84
+ self,
85
+ method: str,
86
+ params: dict[str, Any] | None = None,
87
+ *,
88
+ timeout: float | None = 120.0,
89
+ ) -> dict[str, Any]:
90
+ await self._ensure_started()
91
+ assert self.process is not None and self.process.stdin is not None
92
+ request_id = self._next_id
93
+ self._next_id += 1
94
+ loop = asyncio.get_running_loop()
95
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
96
+ self._pending[request_id] = future
97
+ payload: dict[str, Any] = {
98
+ "jsonrpc": "2.0",
99
+ "id": request_id,
100
+ "method": method,
101
+ "params": params if params is not None else {},
102
+ }
103
+ await self._write(payload)
104
+ try:
105
+ if timeout is None:
106
+ return await future
107
+ return await asyncio.wait_for(future, timeout=timeout)
108
+ except TimeoutError as exc:
109
+ raise AcpJsonRpcError(f"ACP request timed out: {method}") from exc
110
+ finally:
111
+ self._pending.pop(request_id, None)
112
+
113
+ async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
114
+ await self._ensure_started()
115
+ payload: dict[str, Any] = {
116
+ "jsonrpc": "2.0",
117
+ "method": method,
118
+ "params": params if params is not None else {},
119
+ }
120
+ await self._write(payload)
121
+
122
+ async def respond(self, request_id: str | int, result: dict[str, Any] | None = None) -> None:
123
+ await self._ensure_started()
124
+ payload: dict[str, Any] = {
125
+ "jsonrpc": "2.0",
126
+ "id": request_id,
127
+ "result": result if result is not None else {},
128
+ }
129
+ await self._write(payload)
130
+
131
+ async def respond_error(
132
+ self,
133
+ request_id: str | int,
134
+ *,
135
+ code: int,
136
+ message: str,
137
+ ) -> None:
138
+ await self._ensure_started()
139
+ payload: dict[str, Any] = {
140
+ "jsonrpc": "2.0",
141
+ "id": request_id,
142
+ "error": {"code": code, "message": message},
143
+ }
144
+ await self._write(payload)
145
+
146
+ async def close(self) -> None:
147
+ if self._closed and self.process is None:
148
+ return
149
+ self._closed = True
150
+ process = self.process
151
+ self.process = None
152
+ for task in list(self._server_request_tasks):
153
+ task.cancel()
154
+ self._server_request_tasks.clear()
155
+ for task in self._readers:
156
+ task.cancel()
157
+ self._readers.clear()
158
+ for future in list(self._pending.values()):
159
+ if not future.done():
160
+ future.set_exception(AcpJsonRpcError("ACP process closed"))
161
+ self._pending.clear()
162
+ if process is None:
163
+ return
164
+ if process.returncode is None:
165
+ process.terminate()
166
+ try:
167
+ await asyncio.wait_for(process.wait(), timeout=5)
168
+ except TimeoutError:
169
+ process.kill()
170
+ await process.wait()
171
+
172
+ async def _spawn(self) -> None:
173
+ self._closed = False
174
+ env = os.environ.copy()
175
+ if self.env:
176
+ env.update(self.env)
177
+ logger.info("starting ACP agent command={}", self.command)
178
+ self._stderr_lines.clear()
179
+ self.process = await asyncio.create_subprocess_exec(
180
+ *self.command,
181
+ stdin=asyncio.subprocess.PIPE,
182
+ stdout=asyncio.subprocess.PIPE,
183
+ stderr=asyncio.subprocess.PIPE,
184
+ cwd=self.cwd,
185
+ env=env,
186
+ limit=STREAM_LIMIT,
187
+ )
188
+ self._readers = [
189
+ asyncio.create_task(self._read_stdout()),
190
+ asyncio.create_task(self._read_stderr()),
191
+ ]
192
+
193
+ async def _ensure_started(self) -> None:
194
+ if self._closed or self.process is None or self.process.returncode is not None:
195
+ await self.start()
196
+
197
+ async def _write(self, payload: dict[str, Any]) -> None:
198
+ if self.process is None or self.process.stdin is None or self._closed:
199
+ raise AcpJsonRpcError("ACP process is not started")
200
+ data = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
201
+ async with self._write_lock:
202
+ self.process.stdin.write(data)
203
+ await self.process.stdin.drain()
204
+
205
+ async def _read_stdout(self) -> None:
206
+ assert self.process is not None and self.process.stdout is not None
207
+ try:
208
+ while True:
209
+ line = await self.process.stdout.readline()
210
+ if not line:
211
+ break
212
+ try:
213
+ payload = json.loads(line)
214
+ except json.JSONDecodeError:
215
+ logger.warning(
216
+ "ACP agent emitted non-json stdout: {}",
217
+ line.decode(errors="replace").strip(),
218
+ )
219
+ continue
220
+ if not isinstance(payload, dict):
221
+ continue
222
+ await self._dispatch_message(payload)
223
+ finally:
224
+ await self._on_stdout_closed()
225
+
226
+ async def _on_stdout_closed(self) -> None:
227
+ for future in list(self._pending.values()):
228
+ if not future.done():
229
+ future.set_exception(AcpJsonRpcError("ACP process stdout closed"))
230
+ self._pending.clear()
231
+ handler = self._exit_handler
232
+ if handler is not None and not self._closed:
233
+ try:
234
+ await handler()
235
+ except Exception:
236
+ logger.exception("ACP exit handler failed")
237
+
238
+ async def _read_stderr(self) -> None:
239
+ assert self.process is not None and self.process.stderr is not None
240
+ while True:
241
+ line = await self.process.stderr.readline()
242
+ if not line:
243
+ break
244
+ text = line.decode(errors="replace").rstrip()
245
+ if text:
246
+ self._stderr_lines.append(text)
247
+ if len(self._stderr_lines) > 200:
248
+ self._stderr_lines = self._stderr_lines[-100:]
249
+ logger.trace("ACP agent stderr: {}", text)
250
+
251
+ async def _dispatch_message(self, payload: dict[str, Any]) -> None:
252
+ request_id = payload.get("id")
253
+ if request_id in self._pending and ("result" in payload or "error" in payload):
254
+ future = self._pending.pop(request_id)
255
+ if future.done():
256
+ return
257
+ if "error" in payload:
258
+ error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
259
+ future.set_exception(
260
+ AcpJsonRpcError(
261
+ str(error.get("message") or "ACP request failed"),
262
+ code=error.get("code") if isinstance(error.get("code"), int) else None,
263
+ data=error.get("data"),
264
+ )
265
+ )
266
+ else:
267
+ result = payload.get("result")
268
+ future.set_result(result if isinstance(result, dict) else {})
269
+ return
270
+
271
+ method = payload.get("method")
272
+ if isinstance(method, str) and request_id is not None:
273
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
274
+ # Do not await the handler here — permission bridges can take minutes.
275
+ task = asyncio.create_task(
276
+ self._handle_server_request(request_id, method, params),
277
+ name=f"acp-server-req-{method}",
278
+ )
279
+ self._server_request_tasks.add(task)
280
+ task.add_done_callback(self._server_request_tasks.discard)
281
+ return
282
+
283
+ if isinstance(method, str) and self._notification_handler is not None:
284
+ await self._notification_handler(payload)
285
+
286
+ async def _handle_server_request(
287
+ self,
288
+ request_id: str | int,
289
+ method: str,
290
+ params: dict[str, Any],
291
+ ) -> None:
292
+ handler = self._server_request_handler
293
+ if handler is None:
294
+ try:
295
+ await self.respond_error(request_id, code=-32601, message=f"Method not found: {method}")
296
+ except Exception:
297
+ logger.exception("ACP respond_error failed method={}", method)
298
+ return
299
+ try:
300
+ result = await handler(request_id, method, params)
301
+ if result is not None:
302
+ await self.respond(request_id, result)
303
+ except Exception as exc:
304
+ logger.exception("ACP server request handler failed method={}", method)
305
+ try:
306
+ await self.respond_error(request_id, code=-32000, message=str(exc))
307
+ except Exception:
308
+ logger.exception("ACP respond_error failed method={}", method)
connector/adapter.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+
7
+ NotificationSink = Callable[[str, dict[str, Any]], Awaitable[None]] | None
8
+
9
+
10
+ @runtime_checkable
11
+ class Adapter(Protocol):
12
+ """Per-runtime backend client (Codex / Claude / OpenCode / ACP).
13
+
14
+ `BackendRpcClient` holds a dict of these keyed by runtime name and routes
15
+ incoming RPCs by `params["runtime"]`. Every adapter must accept a
16
+ `notification_sink` for pushing reduced backend notifications upstream
17
+ (set after construction by the client).
18
+ """
19
+
20
+ notification_sink: NotificationSink
21
+
22
+ async def create_session(self, params: dict[str, Any]) -> dict[str, Any]: ...
23
+
24
+ async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]: ...
25
+
26
+ async def sync_existing_sessions(
27
+ self,
28
+ connector_id: str,
29
+ *,
30
+ limit: int = 100,
31
+ force: bool = False,
32
+ notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
33
+ ) -> dict[str, Any]: ...
34
+
35
+ async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]: ...
36
+
37
+ async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]: ...
38
+
39
+ async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]: ...
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+
7
+ ATTACHMENTS_ROOT_ENV = "AGENT_CONNECTOR_ATTACHMENTS_ROOT"
8
+ DEFAULT_ATTACHMENTS_DIR = ".agent-link/attachments"
9
+ _SAFE_FILENAME_RE = re.compile(r"[^\w.\-+]+")
10
+
11
+
12
+ def attachments_root() -> Path:
13
+ """Return the connector-local root used for runtime attachment copies."""
14
+ import os
15
+
16
+ configured = os.environ.get(ATTACHMENTS_ROOT_ENV)
17
+ root = Path(configured).expanduser() if configured else Path.home() / DEFAULT_ATTACHMENTS_DIR
18
+ return root.resolve(strict=False)
19
+
20
+
21
+ def session_attachments_dir(session_id: str) -> Path:
22
+ session = _safe_filename(session_id) or "session"
23
+ return attachments_root() / session
24
+
25
+
26
+ def attachment_target(session_id: str, file_id: str, original_name: str | None) -> Path:
27
+ safe_file_id = _safe_filename(file_id) or "file"
28
+ safe_name = _safe_filename(original_name or "") or safe_file_id
29
+ return session_attachments_dir(session_id) / f"{safe_file_id}-{safe_name}"
30
+
31
+
32
+ def _safe_filename(name: str) -> str:
33
+ """Reduce arbitrary user/server values to safe single path components."""
34
+ name = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
35
+ sanitized = _SAFE_FILENAME_RE.sub("_", name).strip("._") or ""
36
+ return sanitized[:120]