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/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]: ...
|
connector/attachments.py
ADDED
|
@@ -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]
|