agent-relay 3.2.22 β 4.0.0
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.
- package/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/index.cjs +4989 -1481
- package/dist/src/cli/bootstrap.d.ts.map +1 -1
- package/dist/src/cli/bootstrap.js +2 -0
- package/dist/src/cli/bootstrap.js.map +1 -1
- package/dist/src/cli/commands/agent-management.d.ts.map +1 -1
- package/dist/src/cli/commands/agent-management.js +14 -4
- package/dist/src/cli/commands/agent-management.js.map +1 -1
- package/dist/src/cli/commands/core.d.ts +2 -6
- package/dist/src/cli/commands/core.d.ts.map +1 -1
- package/dist/src/cli/commands/core.js +30 -12
- package/dist/src/cli/commands/core.js.map +1 -1
- package/dist/src/cli/commands/messaging.d.ts.map +1 -1
- package/dist/src/cli/commands/messaging.js +10 -3
- package/dist/src/cli/commands/messaging.js.map +1 -1
- package/dist/src/cli/commands/monitoring.d.ts +2 -2
- package/dist/src/cli/commands/monitoring.d.ts.map +1 -1
- package/dist/src/cli/commands/monitoring.js +15 -6
- package/dist/src/cli/commands/monitoring.js.map +1 -1
- package/dist/src/cli/commands/on/dotfiles.d.ts +35 -0
- package/dist/src/cli/commands/on/dotfiles.d.ts.map +1 -0
- package/dist/src/cli/commands/on/dotfiles.js +157 -0
- package/dist/src/cli/commands/on/dotfiles.js.map +1 -0
- package/dist/src/cli/commands/on/prereqs.d.ts +15 -0
- package/dist/src/cli/commands/on/prereqs.d.ts.map +1 -0
- package/dist/src/cli/commands/on/prereqs.js +103 -0
- package/dist/src/cli/commands/on/prereqs.js.map +1 -0
- package/dist/src/cli/commands/on/provision.d.ts +22 -0
- package/dist/src/cli/commands/on/provision.d.ts.map +1 -0
- package/dist/src/cli/commands/on/provision.js +157 -0
- package/dist/src/cli/commands/on/provision.js.map +1 -0
- package/dist/src/cli/commands/on/scan.d.ts +8 -0
- package/dist/src/cli/commands/on/scan.d.ts.map +1 -0
- package/dist/src/cli/commands/on/scan.js +59 -0
- package/dist/src/cli/commands/on/scan.js.map +1 -0
- package/dist/src/cli/commands/on/services.d.ts +17 -0
- package/dist/src/cli/commands/on/services.d.ts.map +1 -0
- package/dist/src/cli/commands/on/services.js +328 -0
- package/dist/src/cli/commands/on/services.js.map +1 -0
- package/dist/src/cli/commands/on/start.d.ts +61 -0
- package/dist/src/cli/commands/on/start.d.ts.map +1 -0
- package/dist/src/cli/commands/on/start.js +1071 -0
- package/dist/src/cli/commands/on/start.js.map +1 -0
- package/dist/src/cli/commands/on/stop.d.ts +4 -0
- package/dist/src/cli/commands/on/stop.d.ts.map +1 -0
- package/dist/src/cli/commands/on/stop.js +11 -0
- package/dist/src/cli/commands/on/stop.js.map +1 -0
- package/dist/src/cli/commands/on/token.d.ts +8 -0
- package/dist/src/cli/commands/on/token.d.ts.map +1 -0
- package/dist/src/cli/commands/on/token.js +26 -0
- package/dist/src/cli/commands/on/token.js.map +1 -0
- package/dist/src/cli/commands/on/workspace.d.ts +4 -0
- package/dist/src/cli/commands/on/workspace.d.ts.map +1 -0
- package/dist/src/cli/commands/on/workspace.js +241 -0
- package/dist/src/cli/commands/on/workspace.js.map +1 -0
- package/dist/src/cli/commands/on.d.ts +10 -0
- package/dist/src/cli/commands/on.d.ts.map +1 -0
- package/dist/src/cli/commands/on.js +52 -0
- package/dist/src/cli/commands/on.js.map +1 -0
- package/dist/src/cli/commands/setup.d.ts.map +1 -1
- package/dist/src/cli/commands/setup.js +10 -21
- package/dist/src/cli/commands/setup.js.map +1 -1
- package/dist/src/cli/lib/bridge.js +1 -1
- package/dist/src/cli/lib/bridge.js.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.d.ts +14 -4
- package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.js +82 -120
- package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
- package/dist/src/cli/lib/client-factory.d.ts +2 -2
- package/dist/src/cli/lib/client-factory.d.ts.map +1 -1
- package/dist/src/cli/lib/client-factory.js +14 -11
- package/dist/src/cli/lib/client-factory.js.map +1 -1
- package/dist/src/cli/lib/core-maintenance.d.ts.map +1 -1
- package/dist/src/cli/lib/core-maintenance.js +11 -22
- package/dist/src/cli/lib/core-maintenance.js.map +1 -1
- package/package.json +14 -11
- package/packages/acp-bridge/package.json +2 -2
- package/packages/brand/package.json +1 -1
- package/packages/cloud/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/sdk/README.md +10 -3
- package/packages/sdk/dist/client.d.ts +108 -196
- package/packages/sdk/dist/client.d.ts.map +1 -1
- package/packages/sdk/dist/client.js +336 -824
- package/packages/sdk/dist/client.js.map +1 -1
- package/packages/sdk/dist/examples/example.js +2 -5
- package/packages/sdk/dist/examples/example.js.map +1 -1
- package/packages/sdk/dist/index.d.ts +3 -1
- package/packages/sdk/dist/index.d.ts.map +1 -1
- package/packages/sdk/dist/index.js +3 -1
- package/packages/sdk/dist/index.js.map +1 -1
- package/packages/sdk/dist/relay-adapter.d.ts +9 -26
- package/packages/sdk/dist/relay-adapter.d.ts.map +1 -1
- package/packages/sdk/dist/relay-adapter.js +75 -47
- package/packages/sdk/dist/relay-adapter.js.map +1 -1
- package/packages/sdk/dist/relay.d.ts +24 -5
- package/packages/sdk/dist/relay.d.ts.map +1 -1
- package/packages/sdk/dist/relay.js +213 -43
- package/packages/sdk/dist/relay.js.map +1 -1
- package/packages/sdk/dist/transport.d.ts +58 -0
- package/packages/sdk/dist/transport.d.ts.map +1 -0
- package/packages/sdk/dist/transport.js +184 -0
- package/packages/sdk/dist/transport.js.map +1 -0
- package/packages/sdk/dist/types.d.ts +69 -0
- package/packages/sdk/dist/types.d.ts.map +1 -0
- package/packages/sdk/dist/types.js +5 -0
- package/packages/sdk/dist/types.js.map +1 -0
- package/packages/sdk/dist/workflows/validator.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/validator.js +17 -2
- package/packages/sdk/dist/workflows/validator.js.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/src/__tests__/unit.test.ts +100 -1
- package/packages/sdk/src/client.ts +422 -1072
- package/packages/sdk/src/examples/example.ts +2 -5
- package/packages/sdk/src/index.ts +8 -1
- package/packages/sdk/src/relay-adapter.ts +75 -55
- package/packages/sdk/src/relay.ts +260 -57
- package/packages/sdk/src/transport.ts +216 -0
- package/packages/sdk/src/types.ts +75 -0
- package/packages/sdk/src/workflows/validator.ts +20 -2
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/sdk-py/src/agent_relay/__init__.py +0 -8
- package/packages/sdk-py/src/agent_relay/client.py +329 -522
- package/packages/sdk-py/src/agent_relay/protocol.py +2 -96
- package/packages/sdk-py/src/agent_relay/relay.py +1 -4
- package/packages/sdk-py/tests/test_wait_for_api_url.py +92 -0
- package/packages/sdk-py/uv.lock +5388 -0
- package/packages/telemetry/dist/client.d.ts.map +1 -1
- package/packages/telemetry/dist/client.js +1 -1
- package/packages/telemetry/dist/client.js.map +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/telemetry/src/client.ts +3 -10
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
- package/scripts/postinstall.js +121 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
"""Low-level async client for the Agent Relay broker
|
|
1
|
+
"""Low-level async client for the Agent Relay broker.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Communicates with the broker over HTTP/WS. Can either connect to a
|
|
4
|
+
running broker (remote) or spawn a local broker process.
|
|
5
5
|
|
|
6
6
|
Mirrors packages/sdk/src/client.ts.
|
|
7
7
|
"""
|
|
@@ -12,21 +12,20 @@ import asyncio
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import platform
|
|
15
|
+
import secrets
|
|
15
16
|
import shutil
|
|
16
17
|
import stat
|
|
17
18
|
import subprocess
|
|
18
|
-
import sys
|
|
19
19
|
import urllib.request
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import Any, Callable, Literal, Optional
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
import aiohttp
|
|
22
25
|
|
|
23
26
|
from .protocol import (
|
|
24
|
-
PROTOCOL_VERSION,
|
|
25
|
-
AgentSpec,
|
|
26
27
|
BrokerEvent,
|
|
27
|
-
HeadlessProvider,
|
|
28
28
|
MessageInjectionMode,
|
|
29
|
-
ProtocolEnvelope,
|
|
30
29
|
)
|
|
31
30
|
|
|
32
31
|
# ββ Errors ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -51,54 +50,20 @@ class AgentRelayProcessError(Exception):
|
|
|
51
50
|
AgentTransport = Literal["pty", "headless"]
|
|
52
51
|
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
_CLI_MODEL_FLAG_CLIS = {"claude", "codex", "gemini", "goose", "aider"}
|
|
57
|
-
|
|
58
|
-
_CLI_DEFAULT_ARGS: dict[str, list[str]] = {
|
|
59
|
-
"codex": ["-c", "check_for_update_on_startup=false"],
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _has_model_arg(args: list[str]) -> bool:
|
|
64
|
-
for arg in args:
|
|
65
|
-
if arg == "--model" or arg.startswith("--model="):
|
|
66
|
-
return True
|
|
67
|
-
return False
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _build_pty_args_with_model(
|
|
71
|
-
cli: str, args: list[str], model: Optional[str] = None
|
|
72
|
-
) -> list[str]:
|
|
73
|
-
cli_name = cli.split(":")[0].strip().lower()
|
|
74
|
-
default_args = _CLI_DEFAULT_ARGS.get(cli_name, [])
|
|
75
|
-
base_args = [*default_args, *args]
|
|
76
|
-
if not model:
|
|
77
|
-
return base_args
|
|
78
|
-
if cli_name not in _CLI_MODEL_FLAG_CLIS:
|
|
79
|
-
return base_args
|
|
80
|
-
if _has_model_arg(base_args):
|
|
81
|
-
return base_args
|
|
82
|
-
return ["--model", model, *base_args]
|
|
53
|
+
def _is_headless_provider(value: str) -> bool:
|
|
54
|
+
return value in {"claude", "opencode"}
|
|
83
55
|
|
|
84
56
|
|
|
85
|
-
def
|
|
86
|
-
if
|
|
87
|
-
return
|
|
88
|
-
return
|
|
57
|
+
def _resolve_spawn_transport(provider: str, transport: Optional[AgentTransport]) -> AgentTransport:
|
|
58
|
+
if transport is not None:
|
|
59
|
+
return transport
|
|
60
|
+
return "headless" if provider == "opencode" else "pty"
|
|
89
61
|
|
|
90
62
|
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
"/" in binary_path
|
|
94
|
-
or "\\" in binary_path
|
|
95
|
-
or binary_path.startswith(".")
|
|
96
|
-
or binary_path.startswith("~")
|
|
97
|
-
)
|
|
63
|
+
# ββ Binary resolution βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
98
64
|
|
|
99
65
|
|
|
100
66
|
def _detect_platform() -> str:
|
|
101
|
-
"""Detect platform string matching GitHub release binary names."""
|
|
102
67
|
system = platform.system().lower()
|
|
103
68
|
machine = platform.machine().lower()
|
|
104
69
|
|
|
@@ -120,7 +85,6 @@ def _detect_platform() -> str:
|
|
|
120
85
|
|
|
121
86
|
|
|
122
87
|
def _get_latest_version() -> str:
|
|
123
|
-
"""Fetch the latest release version tag from GitHub."""
|
|
124
88
|
url = "https://api.github.com/repos/AgentWorkforce/relay/releases/latest"
|
|
125
89
|
headers = {"Accept": "application/vnd.github.v3+json"}
|
|
126
90
|
token = os.environ.get("GITHUB_TOKEN")
|
|
@@ -134,7 +98,6 @@ def _get_latest_version() -> str:
|
|
|
134
98
|
|
|
135
99
|
|
|
136
100
|
def _install_broker_binary() -> str:
|
|
137
|
-
"""Download the broker binary from GitHub releases. Returns the installed path."""
|
|
138
101
|
install_dir = Path.home() / ".agent-relay"
|
|
139
102
|
bin_dir = install_dir / "bin"
|
|
140
103
|
target_path = bin_dir / "agent-relay-broker"
|
|
@@ -152,56 +115,32 @@ def _install_broker_binary() -> str:
|
|
|
152
115
|
download_url = f"https://github.com/AgentWorkforce/relay/releases/download/v{version}/{binary_name}"
|
|
153
116
|
|
|
154
117
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
155
|
-
|
|
156
118
|
print(f"[agent-relay] Downloading v{version} from {download_url}")
|
|
157
119
|
try:
|
|
158
120
|
urllib.request.urlretrieve(download_url, str(target_path))
|
|
159
121
|
except Exception as e:
|
|
160
122
|
target_path.unlink(missing_ok=True)
|
|
161
|
-
raise AgentRelayProcessError(
|
|
162
|
-
f"Failed to download broker binary: {e}\n"
|
|
163
|
-
f"You can install manually: curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash"
|
|
164
|
-
) from e
|
|
123
|
+
raise AgentRelayProcessError(f"Failed to download broker binary: {e}") from e
|
|
165
124
|
|
|
166
|
-
# Make executable
|
|
167
125
|
target_path.chmod(
|
|
168
126
|
target_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
169
127
|
)
|
|
170
128
|
|
|
171
|
-
# macOS: strip quarantine and re-sign to avoid Gatekeeper issues
|
|
172
129
|
if platform.system() == "Darwin":
|
|
173
130
|
try:
|
|
174
131
|
subprocess.run(
|
|
175
132
|
["xattr", "-d", "com.apple.quarantine", str(target_path)],
|
|
176
|
-
capture_output=True,
|
|
177
|
-
timeout=10,
|
|
133
|
+
capture_output=True, timeout=10,
|
|
178
134
|
)
|
|
179
135
|
except Exception:
|
|
180
|
-
pass
|
|
136
|
+
pass
|
|
181
137
|
try:
|
|
182
138
|
subprocess.run(
|
|
183
139
|
["codesign", "--force", "--sign", "-", str(target_path)],
|
|
184
|
-
capture_output=True,
|
|
185
|
-
timeout=10,
|
|
140
|
+
capture_output=True, timeout=10,
|
|
186
141
|
)
|
|
187
142
|
except Exception:
|
|
188
|
-
pass
|
|
189
|
-
|
|
190
|
-
# Verify
|
|
191
|
-
try:
|
|
192
|
-
result = subprocess.run(
|
|
193
|
-
[str(target_path), "--help"],
|
|
194
|
-
capture_output=True,
|
|
195
|
-
timeout=10,
|
|
196
|
-
)
|
|
197
|
-
if result.returncode != 0:
|
|
198
|
-
target_path.unlink(missing_ok=True)
|
|
199
|
-
raise AgentRelayProcessError("Downloaded broker binary failed verification")
|
|
200
|
-
except subprocess.TimeoutExpired:
|
|
201
|
-
target_path.unlink(missing_ok=True)
|
|
202
|
-
raise AgentRelayProcessError(
|
|
203
|
-
"Downloaded broker binary timed out during verification"
|
|
204
|
-
)
|
|
143
|
+
pass
|
|
205
144
|
|
|
206
145
|
print(f"[agent-relay] Broker installed to {target_path}")
|
|
207
146
|
return str(target_path)
|
|
@@ -209,115 +148,190 @@ def _install_broker_binary() -> str:
|
|
|
209
148
|
|
|
210
149
|
def _resolve_default_binary_path() -> str:
|
|
211
150
|
broker_exe = "agent-relay-broker"
|
|
212
|
-
|
|
213
|
-
# 1. Check ~/.agent-relay/bin/
|
|
214
|
-
home = Path.home()
|
|
215
|
-
standalone = home / ".agent-relay" / "bin" / broker_exe
|
|
151
|
+
standalone = Path.home() / ".agent-relay" / "bin" / broker_exe
|
|
216
152
|
if standalone.exists():
|
|
217
153
|
return str(standalone)
|
|
218
|
-
|
|
219
|
-
# 2. Fall back to PATH
|
|
220
154
|
found = shutil.which(broker_exe)
|
|
221
155
|
if found:
|
|
222
156
|
return found
|
|
223
|
-
|
|
224
|
-
# 3. Auto-install from GitHub releases
|
|
225
157
|
return _install_broker_binary()
|
|
226
158
|
|
|
227
159
|
|
|
228
|
-
# ββ Pending request tracking βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
class _PendingRequest:
|
|
232
|
-
__slots__ = ("expected_type", "future", "timeout_handle")
|
|
233
|
-
|
|
234
|
-
def __init__(
|
|
235
|
-
self,
|
|
236
|
-
expected_type: str,
|
|
237
|
-
future: asyncio.Future[ProtocolEnvelope],
|
|
238
|
-
timeout_handle: asyncio.TimerHandle,
|
|
239
|
-
):
|
|
240
|
-
self.expected_type = expected_type
|
|
241
|
-
self.future = future
|
|
242
|
-
self.timeout_handle = timeout_handle
|
|
243
|
-
|
|
244
|
-
|
|
245
160
|
# ββ Client ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
246
161
|
|
|
247
162
|
|
|
248
163
|
class AgentRelayClient:
|
|
249
|
-
"""
|
|
164
|
+
"""Communicates with a broker over HTTP/WS.
|
|
165
|
+
|
|
166
|
+
Usage:
|
|
167
|
+
# Remote broker
|
|
168
|
+
client = AgentRelayClient(base_url="http://...", api_key="br_...")
|
|
169
|
+
|
|
170
|
+
# Local broker (spawn)
|
|
171
|
+
client = await AgentRelayClient.spawn(cwd="/my/project")
|
|
172
|
+
"""
|
|
250
173
|
|
|
251
174
|
def __init__(
|
|
252
175
|
self,
|
|
253
176
|
*,
|
|
177
|
+
base_url: str,
|
|
178
|
+
api_key: Optional[str] = None,
|
|
179
|
+
):
|
|
180
|
+
self._base_url = base_url.rstrip("/")
|
|
181
|
+
self._api_key = api_key
|
|
182
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
183
|
+
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
|
184
|
+
self._ws_task: Optional[asyncio.Task[None]] = None
|
|
185
|
+
self._lease_task: Optional[asyncio.Task[None]] = None
|
|
186
|
+
self._stderr_task: Optional[asyncio.Task[None]] = None
|
|
187
|
+
self._process: Optional[asyncio.subprocess.Process] = None
|
|
188
|
+
self._event_listeners: list[Callable[[BrokerEvent], None]] = []
|
|
189
|
+
self._event_buffer: list[BrokerEvent] = []
|
|
190
|
+
self._max_buffer_size = 1000
|
|
191
|
+
self.workspace_key: Optional[str] = None
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
async def spawn(
|
|
195
|
+
cls,
|
|
196
|
+
*,
|
|
254
197
|
binary_path: Optional[str] = None,
|
|
255
198
|
binary_args: Optional[list[str]] = None,
|
|
256
199
|
broker_name: Optional[str] = None,
|
|
257
200
|
channels: Optional[list[str]] = None,
|
|
258
201
|
cwd: Optional[str] = None,
|
|
259
202
|
env: Optional[dict[str, str]] = None,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
203
|
+
on_stderr: Optional[Callable[[str], None]] = None,
|
|
204
|
+
startup_timeout_ms: int = 15_000,
|
|
205
|
+
) -> AgentRelayClient:
|
|
206
|
+
"""Spawn a local broker process and return a connected client."""
|
|
207
|
+
resolved_binary = binary_path or _resolve_default_binary_path()
|
|
208
|
+
resolved_cwd = cwd or os.getcwd()
|
|
209
|
+
resolved_name = broker_name or os.path.basename(resolved_cwd) or "project"
|
|
210
|
+
resolved_channels = channels or ["general"]
|
|
211
|
+
user_args = binary_args or []
|
|
212
|
+
|
|
213
|
+
api_key = f"br_{secrets.token_hex(16)}"
|
|
214
|
+
|
|
215
|
+
spawn_env = {**os.environ, **env} if env else dict(os.environ)
|
|
216
|
+
spawn_env["RELAY_BROKER_API_KEY"] = api_key
|
|
217
|
+
|
|
218
|
+
args = [
|
|
219
|
+
"init",
|
|
220
|
+
"--name", resolved_name,
|
|
221
|
+
"--channels", ",".join(resolved_channels),
|
|
222
|
+
*user_args,
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
process = await asyncio.create_subprocess_exec(
|
|
226
|
+
resolved_binary, *args,
|
|
227
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
228
|
+
stdout=asyncio.subprocess.PIPE,
|
|
229
|
+
stderr=asyncio.subprocess.PIPE,
|
|
230
|
+
cwd=resolved_cwd,
|
|
231
|
+
env=spawn_env,
|
|
269
232
|
)
|
|
270
|
-
self._channels = channels or ["general"]
|
|
271
|
-
self._cwd = cwd or os.getcwd()
|
|
272
|
-
self._env = env
|
|
273
|
-
self._request_timeout_ms = request_timeout_ms
|
|
274
|
-
self._shutdown_timeout_ms = shutdown_timeout_ms
|
|
275
|
-
self._client_name = client_name
|
|
276
|
-
self._client_version = client_version
|
|
277
233
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
234
|
+
# Forward stderr
|
|
235
|
+
async def _read_stderr() -> None:
|
|
236
|
+
assert process.stderr
|
|
237
|
+
while True:
|
|
238
|
+
line = await process.stderr.readline()
|
|
239
|
+
if not line:
|
|
240
|
+
break
|
|
241
|
+
text = line.decode("utf-8", errors="replace").rstrip("\n")
|
|
242
|
+
if on_stderr:
|
|
243
|
+
on_stderr(text)
|
|
244
|
+
|
|
245
|
+
stderr_task = asyncio.create_task(_read_stderr())
|
|
246
|
+
|
|
247
|
+
# Parse API URL from stdout
|
|
248
|
+
base_url = await _wait_for_api_url(process, startup_timeout_ms)
|
|
249
|
+
|
|
250
|
+
client = cls(base_url=base_url, api_key=api_key)
|
|
251
|
+
client._stderr_task = stderr_task
|
|
252
|
+
client._process = process
|
|
253
|
+
|
|
254
|
+
# Get session metadata
|
|
255
|
+
session = await client.get_session()
|
|
256
|
+
client.workspace_key = session.get("workspace_key")
|
|
257
|
+
|
|
258
|
+
# Start event stream
|
|
259
|
+
await client._connect_ws()
|
|
260
|
+
|
|
261
|
+
# Start lease renewal
|
|
262
|
+
client._lease_task = asyncio.create_task(client._renew_lease_loop())
|
|
292
263
|
|
|
293
|
-
@classmethod
|
|
294
|
-
async def start(cls, **kwargs: Any) -> AgentRelayClient:
|
|
295
|
-
client = cls(**kwargs)
|
|
296
|
-
await client.start_client()
|
|
297
264
|
return client
|
|
298
265
|
|
|
299
|
-
# ββ
|
|
266
|
+
# ββ HTTP helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
267
|
+
|
|
268
|
+
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
269
|
+
if self._session is None or self._session.closed:
|
|
270
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
271
|
+
if self._api_key:
|
|
272
|
+
headers["X-API-Key"] = self._api_key
|
|
273
|
+
self._session = aiohttp.ClientSession(headers=headers)
|
|
274
|
+
return self._session
|
|
275
|
+
|
|
276
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
277
|
+
session = await self._ensure_session()
|
|
278
|
+
async with session.request(method, f"{self._base_url}{path}", **kwargs) as resp:
|
|
279
|
+
body = await resp.json() if resp.content_type == "application/json" else None
|
|
280
|
+
if not resp.ok:
|
|
281
|
+
code = body.get("code", f"http_{resp.status}") if body else f"http_{resp.status}"
|
|
282
|
+
message = body.get("message", resp.reason) if body else (resp.reason or "unknown error")
|
|
283
|
+
raise AgentRelayProtocolError(
|
|
284
|
+
code=code,
|
|
285
|
+
message=message,
|
|
286
|
+
retryable=resp.status >= 500,
|
|
287
|
+
)
|
|
288
|
+
return body
|
|
300
289
|
|
|
301
|
-
|
|
302
|
-
self._event_listeners.append(listener)
|
|
290
|
+
# ββ WebSocket events ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
303
291
|
|
|
304
|
-
|
|
292
|
+
async def _connect_ws(self) -> None:
|
|
293
|
+
session = await self._ensure_session()
|
|
294
|
+
ws_url = self._base_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws"
|
|
295
|
+
self._ws = await session.ws_connect(ws_url)
|
|
296
|
+
self._ws_task = asyncio.create_task(self._ws_reader())
|
|
297
|
+
|
|
298
|
+
async def _ws_reader(self) -> None:
|
|
299
|
+
if not self._ws:
|
|
300
|
+
return
|
|
301
|
+
async for msg in self._ws:
|
|
302
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
303
|
+
try:
|
|
304
|
+
event: BrokerEvent = json.loads(msg.data)
|
|
305
|
+
self._event_buffer.append(event)
|
|
306
|
+
if len(self._event_buffer) > self._max_buffer_size:
|
|
307
|
+
self._event_buffer.pop(0)
|
|
308
|
+
for listener in self._event_listeners:
|
|
309
|
+
try:
|
|
310
|
+
listener(event)
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
except (json.JSONDecodeError, ValueError):
|
|
314
|
+
pass
|
|
315
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
async def _renew_lease_loop(self) -> None:
|
|
319
|
+
while True:
|
|
320
|
+
await asyncio.sleep(60)
|
|
305
321
|
try:
|
|
306
|
-
self.
|
|
307
|
-
except
|
|
322
|
+
await self.renew_lease()
|
|
323
|
+
except Exception:
|
|
308
324
|
pass
|
|
309
325
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def on_broker_stderr(self, listener: Callable[[str], None]) -> Callable[[], None]:
|
|
313
|
-
self._stderr_listeners.append(listener)
|
|
326
|
+
# ββ Event subscription ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
314
327
|
|
|
328
|
+
def on_event(self, listener: Callable[[BrokerEvent], None]) -> Callable[[], None]:
|
|
329
|
+
self._event_listeners.append(listener)
|
|
315
330
|
def unsubscribe() -> None:
|
|
316
331
|
try:
|
|
317
|
-
self.
|
|
332
|
+
self._event_listeners.remove(listener)
|
|
318
333
|
except ValueError:
|
|
319
334
|
pass
|
|
320
|
-
|
|
321
335
|
return unsubscribe
|
|
322
336
|
|
|
323
337
|
def query_events(
|
|
@@ -336,219 +350,18 @@ class AgentRelayClient:
|
|
|
336
350
|
events = events[-limit:]
|
|
337
351
|
return events
|
|
338
352
|
|
|
339
|
-
# ββ
|
|
340
|
-
|
|
341
|
-
async def start_client(self) -> None:
|
|
342
|
-
if self._started:
|
|
343
|
-
return
|
|
344
|
-
async with self._starting_lock:
|
|
345
|
-
if self._started:
|
|
346
|
-
return
|
|
347
|
-
await self._start_internal()
|
|
348
|
-
|
|
349
|
-
async def _start_internal(self) -> None:
|
|
350
|
-
resolved_binary = _expand_tilde(self._binary_path)
|
|
351
|
-
if _is_explicit_path(self._binary_path) and not Path(resolved_binary).exists():
|
|
352
|
-
raise AgentRelayProcessError(
|
|
353
|
-
f"broker binary not found: {self._binary_path}"
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
args = [
|
|
357
|
-
"init",
|
|
358
|
-
"--name",
|
|
359
|
-
self._broker_name,
|
|
360
|
-
"--channels",
|
|
361
|
-
",".join(self._channels),
|
|
362
|
-
*self._binary_args,
|
|
363
|
-
]
|
|
353
|
+
# ββ Session βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
364
354
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
env["PATH"] = f"{bin_dir}{os.pathsep}{current_path}"
|
|
355
|
+
async def get_session(self) -> dict[str, Any]:
|
|
356
|
+
result = await self._request("GET", "/api/session")
|
|
357
|
+
if result and result.get("workspace_key"):
|
|
358
|
+
self.workspace_key = result["workspace_key"]
|
|
359
|
+
return result
|
|
371
360
|
|
|
372
|
-
|
|
361
|
+
async def health_check(self) -> dict[str, Any]:
|
|
362
|
+
return await self._request("GET", "/health")
|
|
373
363
|
|
|
374
|
-
|
|
375
|
-
resolved_binary,
|
|
376
|
-
*args,
|
|
377
|
-
stdin=asyncio.subprocess.PIPE,
|
|
378
|
-
stdout=asyncio.subprocess.PIPE,
|
|
379
|
-
stderr=asyncio.subprocess.PIPE,
|
|
380
|
-
cwd=self._cwd,
|
|
381
|
-
env=env,
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
loop = asyncio.get_running_loop()
|
|
385
|
-
self._exit_future = loop.create_future()
|
|
386
|
-
|
|
387
|
-
self._reader_task = asyncio.create_task(self._read_stdout())
|
|
388
|
-
self._stderr_task = asyncio.create_task(self._read_stderr())
|
|
389
|
-
|
|
390
|
-
# Monitor process exit
|
|
391
|
-
asyncio.create_task(self._monitor_exit())
|
|
392
|
-
|
|
393
|
-
# Hello handshake
|
|
394
|
-
hello_ack = await self._request_hello()
|
|
395
|
-
self._started = True
|
|
396
|
-
if hello_ack.get("workspace_key"):
|
|
397
|
-
self.workspace_key = hello_ack["workspace_key"]
|
|
398
|
-
|
|
399
|
-
async def _monitor_exit(self) -> None:
|
|
400
|
-
if not self._process:
|
|
401
|
-
return
|
|
402
|
-
code = await self._process.wait()
|
|
403
|
-
detail = f": {self._last_stderr_line}" if self._last_stderr_line else ""
|
|
404
|
-
error = AgentRelayProcessError(f"broker exited (code={code}){detail}")
|
|
405
|
-
self._fail_all_pending(error)
|
|
406
|
-
if self._exit_future and not self._exit_future.done():
|
|
407
|
-
self._exit_future.set_result(None)
|
|
408
|
-
|
|
409
|
-
async def _read_stdout(self) -> None:
|
|
410
|
-
assert self._process and self._process.stdout
|
|
411
|
-
while True:
|
|
412
|
-
line = await self._process.stdout.readline()
|
|
413
|
-
if not line:
|
|
414
|
-
break
|
|
415
|
-
self._handle_stdout_line(
|
|
416
|
-
line.decode("utf-8", errors="replace").rstrip("\n")
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
async def _read_stderr(self) -> None:
|
|
420
|
-
assert self._process and self._process.stderr
|
|
421
|
-
while True:
|
|
422
|
-
line = await self._process.stderr.readline()
|
|
423
|
-
if not line:
|
|
424
|
-
break
|
|
425
|
-
text = line.decode("utf-8", errors="replace").rstrip("\n")
|
|
426
|
-
trimmed = text.strip()
|
|
427
|
-
if trimmed:
|
|
428
|
-
self._last_stderr_line = trimmed
|
|
429
|
-
for listener in self._stderr_listeners:
|
|
430
|
-
listener(text)
|
|
431
|
-
|
|
432
|
-
def _handle_stdout_line(self, line: str) -> None:
|
|
433
|
-
try:
|
|
434
|
-
parsed = json.loads(line)
|
|
435
|
-
except (json.JSONDecodeError, ValueError):
|
|
436
|
-
return
|
|
437
|
-
|
|
438
|
-
if not isinstance(parsed, dict):
|
|
439
|
-
return
|
|
440
|
-
if parsed.get("v") != PROTOCOL_VERSION or not isinstance(
|
|
441
|
-
parsed.get("type"), str
|
|
442
|
-
):
|
|
443
|
-
return
|
|
444
|
-
|
|
445
|
-
envelope = ProtocolEnvelope.from_dict(parsed)
|
|
446
|
-
|
|
447
|
-
# Events are dispatched to listeners (no request_id)
|
|
448
|
-
if envelope.type == "event":
|
|
449
|
-
event: BrokerEvent = envelope.payload
|
|
450
|
-
self._event_buffer.append(event)
|
|
451
|
-
if len(self._event_buffer) > self._max_buffer_size:
|
|
452
|
-
self._event_buffer.pop(0)
|
|
453
|
-
for listener in self._event_listeners:
|
|
454
|
-
listener(event)
|
|
455
|
-
return
|
|
456
|
-
|
|
457
|
-
# Responses are correlated to pending requests
|
|
458
|
-
if not envelope.request_id:
|
|
459
|
-
return
|
|
460
|
-
|
|
461
|
-
pending = self._pending.pop(envelope.request_id, None)
|
|
462
|
-
if not pending:
|
|
463
|
-
return
|
|
464
|
-
|
|
465
|
-
pending.timeout_handle.cancel()
|
|
466
|
-
|
|
467
|
-
if envelope.type == "error":
|
|
468
|
-
payload = envelope.payload
|
|
469
|
-
pending.future.set_exception(
|
|
470
|
-
AgentRelayProtocolError(
|
|
471
|
-
code=payload.get("code", "unknown"),
|
|
472
|
-
message=payload.get("message", "unknown error"),
|
|
473
|
-
retryable=payload.get("retryable", False),
|
|
474
|
-
data=payload.get("data"),
|
|
475
|
-
)
|
|
476
|
-
)
|
|
477
|
-
return
|
|
478
|
-
|
|
479
|
-
if envelope.type != pending.expected_type:
|
|
480
|
-
pending.future.set_exception(
|
|
481
|
-
AgentRelayProcessError(
|
|
482
|
-
f"unexpected response type '{envelope.type}' for request "
|
|
483
|
-
f"'{envelope.request_id}' (expected '{pending.expected_type}')"
|
|
484
|
-
)
|
|
485
|
-
)
|
|
486
|
-
return
|
|
487
|
-
|
|
488
|
-
pending.future.set_result(envelope)
|
|
489
|
-
|
|
490
|
-
def _fail_all_pending(self, error: Exception) -> None:
|
|
491
|
-
for pending in self._pending.values():
|
|
492
|
-
pending.timeout_handle.cancel()
|
|
493
|
-
if not pending.future.done():
|
|
494
|
-
pending.future.set_exception(error)
|
|
495
|
-
self._pending.clear()
|
|
496
|
-
|
|
497
|
-
# ββ Request helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
498
|
-
|
|
499
|
-
async def _send_request(
|
|
500
|
-
self, type_: str, payload: Any, expected_type: str
|
|
501
|
-
) -> ProtocolEnvelope:
|
|
502
|
-
if not self._process or not self._process.stdin:
|
|
503
|
-
raise AgentRelayProcessError("broker is not running")
|
|
504
|
-
|
|
505
|
-
self._request_seq += 1
|
|
506
|
-
request_id = f"req_{self._request_seq}"
|
|
507
|
-
|
|
508
|
-
envelope = ProtocolEnvelope(
|
|
509
|
-
v=PROTOCOL_VERSION,
|
|
510
|
-
type=type_,
|
|
511
|
-
payload=payload,
|
|
512
|
-
request_id=request_id,
|
|
513
|
-
)
|
|
514
|
-
|
|
515
|
-
loop = asyncio.get_running_loop()
|
|
516
|
-
future: asyncio.Future[ProtocolEnvelope] = loop.create_future()
|
|
517
|
-
|
|
518
|
-
def on_timeout() -> None:
|
|
519
|
-
self._pending.pop(request_id, None)
|
|
520
|
-
if not future.done():
|
|
521
|
-
future.set_exception(
|
|
522
|
-
AgentRelayProcessError(
|
|
523
|
-
f"request timed out after {self._request_timeout_ms}ms "
|
|
524
|
-
f"(type='{type_}', request_id='{request_id}')"
|
|
525
|
-
)
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
timeout_handle = loop.call_later(self._request_timeout_ms / 1000, on_timeout)
|
|
529
|
-
self._pending[request_id] = _PendingRequest(
|
|
530
|
-
expected_type, future, timeout_handle
|
|
531
|
-
)
|
|
532
|
-
|
|
533
|
-
line = json.dumps(envelope.to_dict()) + "\n"
|
|
534
|
-
self._process.stdin.write(line.encode("utf-8"))
|
|
535
|
-
await self._process.stdin.drain()
|
|
536
|
-
|
|
537
|
-
return await future
|
|
538
|
-
|
|
539
|
-
async def _request_hello(self) -> dict[str, Any]:
|
|
540
|
-
payload = {
|
|
541
|
-
"client_name": self._client_name,
|
|
542
|
-
"client_version": self._client_version,
|
|
543
|
-
}
|
|
544
|
-
frame = await self._send_request("hello", payload, "hello_ack")
|
|
545
|
-
return frame.payload
|
|
546
|
-
|
|
547
|
-
async def _request_ok(self, type_: str, payload: Any) -> Any:
|
|
548
|
-
frame = await self._send_request(type_, payload, "ok")
|
|
549
|
-
return frame.payload.get("result")
|
|
550
|
-
|
|
551
|
-
# ββ Public API methods ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
364
|
+
# ββ Agent lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
552
365
|
|
|
553
366
|
async def spawn_pty(
|
|
554
367
|
self,
|
|
@@ -568,61 +381,23 @@ class AgentRelayClient:
|
|
|
568
381
|
continue_from: Optional[str] = None,
|
|
569
382
|
skip_relay_prompt: Optional[bool] = None,
|
|
570
383
|
) -> dict[str, Any]:
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
shadow_mode=shadow_mode,
|
|
589
|
-
restart_policy=rp,
|
|
590
|
-
)
|
|
591
|
-
request_payload: dict[str, Any] = {"agent": agent.to_dict()}
|
|
592
|
-
if task is not None:
|
|
593
|
-
request_payload["initial_task"] = task
|
|
594
|
-
if idle_threshold_secs is not None:
|
|
595
|
-
request_payload["idle_threshold_secs"] = idle_threshold_secs
|
|
596
|
-
if continue_from is not None:
|
|
597
|
-
request_payload["continue_from"] = continue_from
|
|
598
|
-
if skip_relay_prompt is not None:
|
|
599
|
-
request_payload["skip_relay_prompt"] = skip_relay_prompt
|
|
600
|
-
return await self._request_ok("spawn_agent", request_payload)
|
|
601
|
-
|
|
602
|
-
async def spawn_headless(
|
|
603
|
-
self,
|
|
604
|
-
*,
|
|
605
|
-
name: str,
|
|
606
|
-
provider: HeadlessProvider,
|
|
607
|
-
args: Optional[list[str]] = None,
|
|
608
|
-
channels: Optional[list[str]] = None,
|
|
609
|
-
task: Optional[str] = None,
|
|
610
|
-
skip_relay_prompt: Optional[bool] = None,
|
|
611
|
-
) -> dict[str, Any]:
|
|
612
|
-
await self.start_client()
|
|
613
|
-
agent = AgentSpec(
|
|
614
|
-
name=name,
|
|
615
|
-
runtime="headless",
|
|
616
|
-
provider=provider,
|
|
617
|
-
args=args or [],
|
|
618
|
-
channels=channels or [],
|
|
619
|
-
)
|
|
620
|
-
request_payload: dict[str, Any] = {"agent": agent.to_dict()}
|
|
621
|
-
if task is not None:
|
|
622
|
-
request_payload["initial_task"] = task
|
|
623
|
-
if skip_relay_prompt is not None:
|
|
624
|
-
request_payload["skip_relay_prompt"] = skip_relay_prompt
|
|
625
|
-
return await self._request_ok("spawn_agent", request_payload)
|
|
384
|
+
payload: dict[str, Any] = {
|
|
385
|
+
"name": name,
|
|
386
|
+
"cli": cli,
|
|
387
|
+
"args": args or [],
|
|
388
|
+
"channels": channels or [],
|
|
389
|
+
}
|
|
390
|
+
if task is not None: payload["task"] = task
|
|
391
|
+
if model is not None: payload["model"] = model
|
|
392
|
+
if cwd is not None: payload["cwd"] = cwd
|
|
393
|
+
if team is not None: payload["team"] = team
|
|
394
|
+
if shadow_of is not None: payload["shadowOf"] = shadow_of
|
|
395
|
+
if shadow_mode is not None: payload["shadowMode"] = shadow_mode
|
|
396
|
+
if idle_threshold_secs is not None: payload["idleThresholdSecs"] = idle_threshold_secs
|
|
397
|
+
if restart_policy is not None: payload["restartPolicy"] = restart_policy
|
|
398
|
+
if continue_from is not None: payload["continueFrom"] = continue_from
|
|
399
|
+
if skip_relay_prompt is not None: payload["skipRelayPrompt"] = skip_relay_prompt
|
|
400
|
+
return await self._request("POST", "/api/spawn", json=payload)
|
|
626
401
|
|
|
627
402
|
async def spawn_provider(
|
|
628
403
|
self,
|
|
@@ -643,69 +418,50 @@ class AgentRelayClient:
|
|
|
643
418
|
continue_from: Optional[str] = None,
|
|
644
419
|
skip_relay_prompt: Optional[bool] = None,
|
|
645
420
|
) -> dict[str, Any]:
|
|
646
|
-
resolved_transport
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if resolved_transport == "headless":
|
|
651
|
-
if provider not in ("claude", "opencode"):
|
|
652
|
-
raise AgentRelayProcessError(
|
|
653
|
-
f"provider '{provider}' does not support headless transport (supported: claude, opencode)"
|
|
654
|
-
)
|
|
655
|
-
headless_provider: HeadlessProvider = (
|
|
656
|
-
"claude" if provider == "claude" else "opencode"
|
|
657
|
-
)
|
|
658
|
-
return await self.spawn_headless(
|
|
659
|
-
name=name,
|
|
660
|
-
provider=headless_provider,
|
|
661
|
-
args=args,
|
|
662
|
-
channels=channels,
|
|
663
|
-
task=task,
|
|
664
|
-
skip_relay_prompt=skip_relay_prompt,
|
|
421
|
+
resolved_transport = _resolve_spawn_transport(provider, transport)
|
|
422
|
+
if resolved_transport == "headless" and not _is_headless_provider(provider):
|
|
423
|
+
raise AgentRelayProcessError(
|
|
424
|
+
f"provider '{provider}' does not support headless transport (supported: claude, opencode)"
|
|
665
425
|
)
|
|
666
426
|
|
|
667
|
-
|
|
668
|
-
name
|
|
669
|
-
cli
|
|
670
|
-
args
|
|
671
|
-
channels
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
return await self.spawn_provider(provider="claude", **kwargs)
|
|
686
|
-
|
|
687
|
-
async def spawn_opencode(self, **kwargs: Any) -> dict[str, Any]:
|
|
688
|
-
return await self.spawn_provider(provider="opencode", **kwargs)
|
|
427
|
+
payload: dict[str, Any] = {
|
|
428
|
+
"name": name,
|
|
429
|
+
"cli": provider,
|
|
430
|
+
"args": args or [],
|
|
431
|
+
"channels": channels or [],
|
|
432
|
+
"transport": resolved_transport,
|
|
433
|
+
}
|
|
434
|
+
if task is not None: payload["task"] = task
|
|
435
|
+
if model is not None: payload["model"] = model
|
|
436
|
+
if cwd is not None: payload["cwd"] = cwd
|
|
437
|
+
if team is not None: payload["team"] = team
|
|
438
|
+
if shadow_of is not None: payload["shadowOf"] = shadow_of
|
|
439
|
+
if shadow_mode is not None: payload["shadowMode"] = shadow_mode
|
|
440
|
+
if idle_threshold_secs is not None: payload["idleThresholdSecs"] = idle_threshold_secs
|
|
441
|
+
if restart_policy is not None: payload["restartPolicy"] = restart_policy
|
|
442
|
+
if continue_from is not None: payload["continueFrom"] = continue_from
|
|
443
|
+
if skip_relay_prompt is not None: payload["skipRelayPrompt"] = skip_relay_prompt
|
|
444
|
+
return await self._request("POST", "/api/spawn", json=payload)
|
|
689
445
|
|
|
690
446
|
async def release(self, name: str, reason: Optional[str] = None) -> dict[str, Any]:
|
|
691
|
-
|
|
692
|
-
payload: dict[str, Any] = {"name": name}
|
|
447
|
+
kwargs: dict[str, Any] = {}
|
|
693
448
|
if reason is not None:
|
|
694
|
-
|
|
695
|
-
return await self.
|
|
449
|
+
kwargs["json"] = {"reason": reason}
|
|
450
|
+
return await self._request("DELETE", f"/api/spawned/{quote(name, safe=str())}", **kwargs)
|
|
451
|
+
|
|
452
|
+
async def list_agents(self) -> list[dict[str, Any]]:
|
|
453
|
+
result = await self._request("GET", "/api/spawned")
|
|
454
|
+
return result.get("agents", []) if isinstance(result, dict) else []
|
|
455
|
+
|
|
456
|
+
# ββ PTY control βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
696
457
|
|
|
697
458
|
async def send_input(self, name: str, data: str) -> dict[str, Any]:
|
|
698
|
-
await self.
|
|
699
|
-
return await self._request_ok("send_input", {"name": name, "data": data})
|
|
459
|
+
return await self._request("POST", f"/api/input/{quote(name, safe=str())}", json={"data": data})
|
|
700
460
|
|
|
701
|
-
async def
|
|
702
|
-
self, name
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
payload: dict[str, Any] = {"name": name, "model": model}
|
|
706
|
-
if timeout_ms is not None:
|
|
707
|
-
payload["timeout_ms"] = timeout_ms
|
|
708
|
-
return await self._request_ok("set_model", payload)
|
|
461
|
+
async def resize_pty(self, name: str, rows: int, cols: int) -> dict[str, Any]:
|
|
462
|
+
return await self._request("POST", f"/api/resize/{quote(name, safe=str())}", json={"rows": rows, "cols": cols})
|
|
463
|
+
|
|
464
|
+
# ββ Messaging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
709
465
|
|
|
710
466
|
async def send_message(
|
|
711
467
|
self,
|
|
@@ -718,80 +474,131 @@ class AgentRelayClient:
|
|
|
718
474
|
data: Optional[dict[str, Any]] = None,
|
|
719
475
|
mode: Optional[MessageInjectionMode] = None,
|
|
720
476
|
) -> dict[str, Any]:
|
|
721
|
-
await self.start_client()
|
|
722
477
|
payload: dict[str, Any] = {"to": to, "text": text}
|
|
723
|
-
if from_ is not None:
|
|
724
|
-
|
|
725
|
-
if
|
|
726
|
-
|
|
727
|
-
if
|
|
728
|
-
payload["priority"] = priority
|
|
729
|
-
if data is not None:
|
|
730
|
-
payload["data"] = data
|
|
731
|
-
if mode is not None:
|
|
732
|
-
payload["mode"] = mode
|
|
478
|
+
if from_ is not None: payload["from"] = from_
|
|
479
|
+
if thread_id is not None: payload["threadId"] = thread_id
|
|
480
|
+
if priority is not None: payload["priority"] = priority
|
|
481
|
+
if data is not None: payload["data"] = data
|
|
482
|
+
if mode is not None: payload["mode"] = mode
|
|
733
483
|
try:
|
|
734
|
-
return await self.
|
|
484
|
+
return await self._request("POST", "/api/send", json=payload)
|
|
735
485
|
except AgentRelayProtocolError as e:
|
|
736
486
|
if e.code == "unsupported_operation":
|
|
737
487
|
return {"event_id": "unsupported_operation", "targets": []}
|
|
738
488
|
raise
|
|
739
489
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
490
|
+
# ββ Model control βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
491
|
+
|
|
492
|
+
async def set_model(
|
|
493
|
+
self, name: str, model: str, *, timeout_ms: Optional[int] = None
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
payload: dict[str, Any] = {"model": model}
|
|
496
|
+
if timeout_ms is not None:
|
|
497
|
+
payload["timeout_ms"] = timeout_ms
|
|
498
|
+
return await self._request("POST", f"/api/spawned/{quote(name, safe=str())}/model", json=payload)
|
|
499
|
+
|
|
500
|
+
# ββ Channels ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
501
|
+
|
|
502
|
+
async def subscribe_channels(self, name: str, channels: list[str]) -> None:
|
|
503
|
+
await self._request("POST", f"/api/spawned/{quote(name, safe=str())}/subscribe", json={"channels": channels})
|
|
504
|
+
|
|
505
|
+
async def unsubscribe_channels(self, name: str, channels: list[str]) -> None:
|
|
506
|
+
await self._request("POST", f"/api/spawned/{quote(name, safe=str())}/unsubscribe", json={"channels": channels})
|
|
507
|
+
|
|
508
|
+
# ββ Observability βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
744
509
|
|
|
745
510
|
async def get_status(self) -> dict[str, Any]:
|
|
746
|
-
await self.
|
|
747
|
-
return await self._request_ok("get_status", {})
|
|
511
|
+
return await self._request("GET", "/api/status")
|
|
748
512
|
|
|
749
513
|
async def get_metrics(self, agent: Optional[str] = None) -> dict[str, Any]:
|
|
750
|
-
|
|
751
|
-
return await self.
|
|
514
|
+
query = f"?agent={quote(agent, safe=str())}" if agent else ""
|
|
515
|
+
return await self._request("GET", f"/api/metrics{query}")
|
|
752
516
|
|
|
753
517
|
async def get_crash_insights(self) -> dict[str, Any]:
|
|
754
|
-
await self.
|
|
755
|
-
|
|
518
|
+
return await self._request("GET", "/api/crash-insights")
|
|
519
|
+
|
|
520
|
+
# ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
756
521
|
|
|
757
522
|
async def preflight_agents(self, agents: list[dict[str, str]]) -> None:
|
|
758
523
|
if not agents:
|
|
759
524
|
return
|
|
760
|
-
await self.
|
|
761
|
-
|
|
525
|
+
await self._request("POST", "/api/preflight", json={"agents": agents})
|
|
526
|
+
|
|
527
|
+
async def renew_lease(self) -> dict[str, Any]:
|
|
528
|
+
return await self._request("POST", "/api/session/renew")
|
|
762
529
|
|
|
763
530
|
async def shutdown(self) -> None:
|
|
764
|
-
if not self.
|
|
765
|
-
|
|
531
|
+
if self._lease_task and not self._lease_task.done():
|
|
532
|
+
self._lease_task.cancel()
|
|
533
|
+
self._lease_task = None
|
|
766
534
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
535
|
+
# Only send shutdown if we own the broker process
|
|
536
|
+
if self._process:
|
|
537
|
+
try:
|
|
538
|
+
await self._request("POST", "/api/shutdown")
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
771
541
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
timeout=self._shutdown_timeout_ms / 1000,
|
|
777
|
-
)
|
|
778
|
-
except asyncio.TimeoutError:
|
|
779
|
-
if process.returncode is None:
|
|
780
|
-
process.terminate()
|
|
781
|
-
try:
|
|
782
|
-
await asyncio.wait_for(process.wait(), timeout=2.0)
|
|
783
|
-
except asyncio.TimeoutError:
|
|
784
|
-
process.kill()
|
|
542
|
+
if self._ws and not self._ws.closed:
|
|
543
|
+
await self._ws.close()
|
|
544
|
+
if self._ws_task and not self._ws_task.done():
|
|
545
|
+
self._ws_task.cancel()
|
|
785
546
|
|
|
786
|
-
# Clean up reader tasks
|
|
787
|
-
if self._reader_task and not self._reader_task.done():
|
|
788
|
-
self._reader_task.cancel()
|
|
789
547
|
if self._stderr_task and not self._stderr_task.done():
|
|
790
548
|
self._stderr_task.cancel()
|
|
791
549
|
|
|
792
|
-
self.
|
|
793
|
-
|
|
550
|
+
if self._session and not self._session.closed:
|
|
551
|
+
await self._session.close()
|
|
552
|
+
|
|
553
|
+
if self._process:
|
|
554
|
+
try:
|
|
555
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
556
|
+
except asyncio.TimeoutError:
|
|
557
|
+
self._process.kill()
|
|
558
|
+
self._process = None
|
|
794
559
|
|
|
795
560
|
async def wait_for_exit(self) -> None:
|
|
796
|
-
if self.
|
|
797
|
-
await self.
|
|
561
|
+
if self._process:
|
|
562
|
+
await self._process.wait()
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def broker_pid(self) -> Optional[int]:
|
|
566
|
+
return self._process.pid if self._process else None
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
async def _wait_for_api_url(
|
|
573
|
+
process: asyncio.subprocess.Process,
|
|
574
|
+
timeout_ms: int,
|
|
575
|
+
) -> str:
|
|
576
|
+
"""Parse the API URL from the broker's stdout.
|
|
577
|
+
|
|
578
|
+
The broker prints: [agent-relay] API listening on http://{bind}:{port}
|
|
579
|
+
Returns the full URL (e.g. "http://127.0.0.1:3889").
|
|
580
|
+
"""
|
|
581
|
+
import re
|
|
582
|
+
|
|
583
|
+
assert process.stdout
|
|
584
|
+
pattern = re.compile(r"API listening on (https?://\S+)")
|
|
585
|
+
|
|
586
|
+
async def _read() -> str:
|
|
587
|
+
assert process.stdout
|
|
588
|
+
while True:
|
|
589
|
+
line_bytes = await process.stdout.readline()
|
|
590
|
+
if not line_bytes:
|
|
591
|
+
raise AgentRelayProcessError(
|
|
592
|
+
f"Broker process exited with code {process.returncode} before becoming ready"
|
|
593
|
+
)
|
|
594
|
+
line = line_bytes.decode("utf-8", errors="replace").rstrip("\n")
|
|
595
|
+
match = pattern.search(line)
|
|
596
|
+
if match:
|
|
597
|
+
return match.group(1)
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
return await asyncio.wait_for(_read(), timeout=timeout_ms / 1000)
|
|
601
|
+
except asyncio.TimeoutError:
|
|
602
|
+
raise AgentRelayProcessError(
|
|
603
|
+
f"Broker did not report API URL within {timeout_ms}ms"
|
|
604
|
+
) from None
|