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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from connector.runtime import ConnectorConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class RuntimeOwner:
|
|
14
|
+
pid: int
|
|
15
|
+
kind: str
|
|
16
|
+
connector_id: str
|
|
17
|
+
server_url: str
|
|
18
|
+
started_at: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectorAlreadyRunningError(RuntimeError):
|
|
22
|
+
def __init__(self, owner: RuntimeOwner) -> None:
|
|
23
|
+
super().__init__(f"connector {owner.connector_id} is already running in {owner.kind} pid {owner.pid}")
|
|
24
|
+
self.owner = owner
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def runtime_path(config_path: str | Path | None = None) -> Path:
|
|
28
|
+
base = Path(config_path) if config_path is not None else ConnectorConfig.default_path()
|
|
29
|
+
return base.with_name("connector-runtime.json")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_runtime(path: str | Path) -> RuntimeOwner | None:
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
return None
|
|
37
|
+
pid = data.get("pid")
|
|
38
|
+
kind = data.get("kind")
|
|
39
|
+
connector_id = data.get("connectorId")
|
|
40
|
+
server_url = data.get("serverUrl")
|
|
41
|
+
if not isinstance(pid, int) or not isinstance(kind, str) or not isinstance(connector_id, str) or not isinstance(server_url, str):
|
|
42
|
+
return None
|
|
43
|
+
return RuntimeOwner(
|
|
44
|
+
pid=pid,
|
|
45
|
+
kind=kind,
|
|
46
|
+
connector_id=connector_id,
|
|
47
|
+
server_url=server_url,
|
|
48
|
+
started_at=data.get("startedAt") if isinstance(data.get("startedAt"), str) else None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def assert_can_start(path: str | Path, config: ConnectorConfig, *, current_pid: int | None = None) -> None:
|
|
53
|
+
owner = read_runtime(path)
|
|
54
|
+
if owner is None:
|
|
55
|
+
return
|
|
56
|
+
if current_pid is not None and owner.pid == current_pid:
|
|
57
|
+
return
|
|
58
|
+
if not _pid_alive(owner.pid):
|
|
59
|
+
clear_runtime(path)
|
|
60
|
+
return
|
|
61
|
+
raise ConnectorAlreadyRunningError(owner)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_runtime(path: str | Path, config: ConnectorConfig, *, kind: str, pid: int | None = None) -> Path:
|
|
65
|
+
import datetime as _dt
|
|
66
|
+
|
|
67
|
+
runtime_file = Path(path)
|
|
68
|
+
runtime_file.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
runtime_file.write_text(
|
|
70
|
+
json.dumps(
|
|
71
|
+
{
|
|
72
|
+
"pid": int(pid if pid is not None else os.getpid()),
|
|
73
|
+
"kind": kind,
|
|
74
|
+
"connectorId": config.connector_id,
|
|
75
|
+
"serverUrl": config.server_url,
|
|
76
|
+
"startedAt": _dt.datetime.now(_dt.UTC).isoformat(),
|
|
77
|
+
},
|
|
78
|
+
indent=2,
|
|
79
|
+
)
|
|
80
|
+
+ "\n",
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
runtime_file.chmod(0o600)
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
return runtime_file
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clear_runtime(path: str | Path, *, pid: int | None = None) -> None:
|
|
91
|
+
runtime_file = Path(path)
|
|
92
|
+
if pid is not None:
|
|
93
|
+
owner = read_runtime(runtime_file)
|
|
94
|
+
if owner is not None and owner.pid != pid:
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
runtime_file.unlink()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
return
|
|
100
|
+
except OSError:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _pid_alive(pid: int) -> bool:
|
|
105
|
+
if pid <= 0:
|
|
106
|
+
return False
|
|
107
|
+
if pid == os.getpid():
|
|
108
|
+
return True
|
|
109
|
+
try:
|
|
110
|
+
os.kill(pid, 0)
|
|
111
|
+
except ProcessLookupError:
|
|
112
|
+
return False
|
|
113
|
+
except PermissionError:
|
|
114
|
+
return True
|
|
115
|
+
except OSError:
|
|
116
|
+
if sys.platform != "win32":
|
|
117
|
+
return False
|
|
118
|
+
return _windows_pid_alive(pid)
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _windows_pid_alive(pid: int) -> bool:
|
|
123
|
+
try:
|
|
124
|
+
import ctypes
|
|
125
|
+
from ctypes import wintypes
|
|
126
|
+
|
|
127
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
128
|
+
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
129
|
+
if not handle:
|
|
130
|
+
return False
|
|
131
|
+
exit_code = wintypes.DWORD()
|
|
132
|
+
try:
|
|
133
|
+
if not ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
134
|
+
return False
|
|
135
|
+
return exit_code.value == 259
|
|
136
|
+
finally:
|
|
137
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
138
|
+
except Exception:
|
|
139
|
+
return False
|
connector/logging.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger as logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RpcLogNotifier = Callable[[str, Any], Awaitable[None]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RpcLogSink:
|
|
14
|
+
def __init__(self, notifier: RpcLogNotifier) -> None:
|
|
15
|
+
self.notifier = notifier
|
|
16
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
17
|
+
self._sink_id: int | None = None
|
|
18
|
+
|
|
19
|
+
def install(self, *, level: str = "TRACE", remove_default_sink: bool = False) -> RpcLogSink:
|
|
20
|
+
if remove_default_sink:
|
|
21
|
+
logger.remove()
|
|
22
|
+
self._sink_id = logger.add(self._write, level=level, format="{message}")
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
async def close(self) -> None:
|
|
26
|
+
if self._sink_id is not None:
|
|
27
|
+
logger.remove(self._sink_id)
|
|
28
|
+
self._sink_id = None
|
|
29
|
+
if self._tasks:
|
|
30
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
31
|
+
|
|
32
|
+
def _write(self, message: Any) -> None:
|
|
33
|
+
record = message.record
|
|
34
|
+
payload = {
|
|
35
|
+
"time": record["time"].isoformat(),
|
|
36
|
+
"level": record["level"].name,
|
|
37
|
+
"name": record["name"],
|
|
38
|
+
"message": record["message"],
|
|
39
|
+
}
|
|
40
|
+
exception = record.get("exception")
|
|
41
|
+
if exception is not None:
|
|
42
|
+
payload["exception"] = str(exception)
|
|
43
|
+
|
|
44
|
+
task = asyncio.create_task(self.notifier("connector/log", payload))
|
|
45
|
+
self._tasks.add(task)
|
|
46
|
+
task.add_done_callback(self._tasks.discard)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def install_rpc_log_sink(notifier: RpcLogNotifier, *, level: str = "TRACE", remove_default_sink: bool = False) -> RpcLogSink:
|
|
50
|
+
return RpcLogSink(notifier).install(level=level, remove_default_sink=remove_default_sink)
|
connector/perf.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from connector.logging import logger
|
|
9
|
+
|
|
10
|
+
_FIELD_ORDER = (
|
|
11
|
+
"method",
|
|
12
|
+
"runtime",
|
|
13
|
+
"session_id",
|
|
14
|
+
"turn_id",
|
|
15
|
+
"connector_id",
|
|
16
|
+
"outcome",
|
|
17
|
+
"command",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def elapsed_ms(started: float) -> float:
|
|
22
|
+
return round((time.perf_counter() - started) * 1000, 1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def log_stage(stage: str, elapsed_ms_value: float, *, level: str = "info", **fields: Any) -> None:
|
|
26
|
+
parts = [f"stage={stage}", f"elapsed_ms={elapsed_ms_value:.1f}"]
|
|
27
|
+
for key in _FIELD_ORDER:
|
|
28
|
+
value = fields.pop(key, None)
|
|
29
|
+
if value is None or value == "":
|
|
30
|
+
continue
|
|
31
|
+
parts.append(f"{key}={value}")
|
|
32
|
+
for key, value in fields.items():
|
|
33
|
+
if value is None or value == "":
|
|
34
|
+
continue
|
|
35
|
+
parts.append(f"{key}={value}")
|
|
36
|
+
message = " ".join(parts)
|
|
37
|
+
logger.log(level.upper(), message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StageTimer:
|
|
41
|
+
"""Wall-clock timer for agent invoke stages."""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("_started", "_first_timeline_logged")
|
|
44
|
+
|
|
45
|
+
def __init__(self, started: float | None = None) -> None:
|
|
46
|
+
self._started = time.perf_counter() if started is None else started
|
|
47
|
+
self._first_timeline_logged = False
|
|
48
|
+
|
|
49
|
+
def elapsed_ms(self) -> float:
|
|
50
|
+
return elapsed_ms(self._started)
|
|
51
|
+
|
|
52
|
+
def mark(self, stage: str, *, level: str = "info", **fields: Any) -> float:
|
|
53
|
+
value = self.elapsed_ms()
|
|
54
|
+
log_stage(stage, value, level=level, **fields)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
def mark_first_timeline(self, *, level: str = "info", **fields: Any) -> float | None:
|
|
58
|
+
if self._first_timeline_logged:
|
|
59
|
+
return None
|
|
60
|
+
self._first_timeline_logged = True
|
|
61
|
+
# Prefer explicit alias; default is first assistant text token (TTFB).
|
|
62
|
+
stage = fields.pop("stage_alias", None) or "adapter.first_assistant_token"
|
|
63
|
+
return self.mark(str(stage), level=level, **fields)
|
|
64
|
+
|
|
65
|
+
def mark_turn_complete(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
outcome: str,
|
|
69
|
+
level: str = "info",
|
|
70
|
+
**fields: Any,
|
|
71
|
+
) -> float:
|
|
72
|
+
return self.mark("adapter.turn_complete", level=level, outcome=outcome, **fields)
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def span(self, stage: str, *, level: str = "info", **fields: Any) -> Iterator[None]:
|
|
76
|
+
started = time.perf_counter()
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
log_stage(stage, elapsed_ms(started), level=level, **fields)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextmanager
|
|
84
|
+
def span_stage(stage: str, *, level: str = "info", **fields: Any) -> Iterator[None]:
|
|
85
|
+
started = time.perf_counter()
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
finally:
|
|
89
|
+
log_stage(stage, elapsed_ms(started), level=level, **fields)
|
connector/protocol.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RpcRequest(BaseModel):
|
|
9
|
+
id: str
|
|
10
|
+
type: Literal["request"] = "request"
|
|
11
|
+
method: str
|
|
12
|
+
params: Any = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RpcResponse(BaseModel):
|
|
16
|
+
id: str
|
|
17
|
+
type: Literal["response"] = "response"
|
|
18
|
+
ok: bool
|
|
19
|
+
result: Any = None
|
|
20
|
+
error: dict[str, str] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RpcNotification(BaseModel):
|
|
24
|
+
type: Literal["notification"] = "notification"
|
|
25
|
+
method: str
|
|
26
|
+
params: Any = None
|
connector/registry.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from connector.acp.adapter import AcpAdapter
|
|
7
|
+
from connector.acp.manifest import AgentManifest, load_builtin_manifests
|
|
8
|
+
from connector.adapter import Adapter
|
|
9
|
+
from connector.claude.history_adapter import ClaudeHistoryAdapter
|
|
10
|
+
from connector.claude.sdk_adapter import ClaudeSdkAdapter
|
|
11
|
+
from connector.codex.adapter import CodexAdapter
|
|
12
|
+
from connector.sync_state import SyncStateStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
NotificationSink = Callable[[str, dict[str, Any]], Awaitable[None]] | None
|
|
16
|
+
AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_default_adapters(
|
|
20
|
+
*,
|
|
21
|
+
notification_sink: NotificationSink = None,
|
|
22
|
+
sync_state_store: SyncStateStore | None = None,
|
|
23
|
+
attachment_downloader: AttachmentDownloader | None = None,
|
|
24
|
+
acp_manifests: list[AgentManifest] | None = None,
|
|
25
|
+
) -> dict[str, Adapter]:
|
|
26
|
+
"""Assemble native + ACP adapters for BackendRpcClient."""
|
|
27
|
+
adapters: dict[str, Adapter] = {
|
|
28
|
+
"codex": CodexAdapter(
|
|
29
|
+
notification_sink=notification_sink,
|
|
30
|
+
sync_state_store=sync_state_store,
|
|
31
|
+
attachment_downloader=attachment_downloader,
|
|
32
|
+
),
|
|
33
|
+
"claude": ClaudeSdkAdapter(
|
|
34
|
+
notification_sink=notification_sink,
|
|
35
|
+
history_adapter=ClaudeHistoryAdapter(sync_state_store=sync_state_store),
|
|
36
|
+
attachment_downloader=attachment_downloader,
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
for manifest in acp_manifests if acp_manifests is not None else load_builtin_manifests():
|
|
40
|
+
adapters[manifest.id] = AcpAdapter(
|
|
41
|
+
manifest=manifest,
|
|
42
|
+
notification_sink=notification_sink,
|
|
43
|
+
attachment_downloader=attachment_downloader,
|
|
44
|
+
)
|
|
45
|
+
return adapters
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def builtin_acp_runtime_ids() -> list[str]:
|
|
49
|
+
return [manifest.id for manifest in load_builtin_manifests()]
|