agent-relay 3.2.21 β†’ 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.
Files changed (157) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +5065 -1422
  6. package/dist/src/cli/bootstrap.d.ts.map +1 -1
  7. package/dist/src/cli/bootstrap.js +2 -0
  8. package/dist/src/cli/bootstrap.js.map +1 -1
  9. package/dist/src/cli/commands/agent-management.d.ts.map +1 -1
  10. package/dist/src/cli/commands/agent-management.js +14 -4
  11. package/dist/src/cli/commands/agent-management.js.map +1 -1
  12. package/dist/src/cli/commands/core.d.ts +2 -6
  13. package/dist/src/cli/commands/core.d.ts.map +1 -1
  14. package/dist/src/cli/commands/core.js +30 -12
  15. package/dist/src/cli/commands/core.js.map +1 -1
  16. package/dist/src/cli/commands/messaging.d.ts.map +1 -1
  17. package/dist/src/cli/commands/messaging.js +10 -3
  18. package/dist/src/cli/commands/messaging.js.map +1 -1
  19. package/dist/src/cli/commands/monitoring.d.ts +2 -2
  20. package/dist/src/cli/commands/monitoring.d.ts.map +1 -1
  21. package/dist/src/cli/commands/monitoring.js +15 -6
  22. package/dist/src/cli/commands/monitoring.js.map +1 -1
  23. package/dist/src/cli/commands/on/dotfiles.d.ts +35 -0
  24. package/dist/src/cli/commands/on/dotfiles.d.ts.map +1 -0
  25. package/dist/src/cli/commands/on/dotfiles.js +157 -0
  26. package/dist/src/cli/commands/on/dotfiles.js.map +1 -0
  27. package/dist/src/cli/commands/on/prereqs.d.ts +15 -0
  28. package/dist/src/cli/commands/on/prereqs.d.ts.map +1 -0
  29. package/dist/src/cli/commands/on/prereqs.js +103 -0
  30. package/dist/src/cli/commands/on/prereqs.js.map +1 -0
  31. package/dist/src/cli/commands/on/provision.d.ts +22 -0
  32. package/dist/src/cli/commands/on/provision.d.ts.map +1 -0
  33. package/dist/src/cli/commands/on/provision.js +157 -0
  34. package/dist/src/cli/commands/on/provision.js.map +1 -0
  35. package/dist/src/cli/commands/on/scan.d.ts +8 -0
  36. package/dist/src/cli/commands/on/scan.d.ts.map +1 -0
  37. package/dist/src/cli/commands/on/scan.js +59 -0
  38. package/dist/src/cli/commands/on/scan.js.map +1 -0
  39. package/dist/src/cli/commands/on/services.d.ts +17 -0
  40. package/dist/src/cli/commands/on/services.d.ts.map +1 -0
  41. package/dist/src/cli/commands/on/services.js +328 -0
  42. package/dist/src/cli/commands/on/services.js.map +1 -0
  43. package/dist/src/cli/commands/on/start.d.ts +61 -0
  44. package/dist/src/cli/commands/on/start.d.ts.map +1 -0
  45. package/dist/src/cli/commands/on/start.js +1071 -0
  46. package/dist/src/cli/commands/on/start.js.map +1 -0
  47. package/dist/src/cli/commands/on/stop.d.ts +4 -0
  48. package/dist/src/cli/commands/on/stop.d.ts.map +1 -0
  49. package/dist/src/cli/commands/on/stop.js +11 -0
  50. package/dist/src/cli/commands/on/stop.js.map +1 -0
  51. package/dist/src/cli/commands/on/token.d.ts +8 -0
  52. package/dist/src/cli/commands/on/token.d.ts.map +1 -0
  53. package/dist/src/cli/commands/on/token.js +26 -0
  54. package/dist/src/cli/commands/on/token.js.map +1 -0
  55. package/dist/src/cli/commands/on/workspace.d.ts +4 -0
  56. package/dist/src/cli/commands/on/workspace.d.ts.map +1 -0
  57. package/dist/src/cli/commands/on/workspace.js +241 -0
  58. package/dist/src/cli/commands/on/workspace.js.map +1 -0
  59. package/dist/src/cli/commands/on.d.ts +10 -0
  60. package/dist/src/cli/commands/on.d.ts.map +1 -0
  61. package/dist/src/cli/commands/on.js +52 -0
  62. package/dist/src/cli/commands/on.js.map +1 -0
  63. package/dist/src/cli/commands/setup.d.ts.map +1 -1
  64. package/dist/src/cli/commands/setup.js +10 -21
  65. package/dist/src/cli/commands/setup.js.map +1 -1
  66. package/dist/src/cli/lib/bridge.js +1 -1
  67. package/dist/src/cli/lib/bridge.js.map +1 -1
  68. package/dist/src/cli/lib/broker-lifecycle.d.ts +14 -4
  69. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  70. package/dist/src/cli/lib/broker-lifecycle.js +82 -120
  71. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  72. package/dist/src/cli/lib/client-factory.d.ts +2 -2
  73. package/dist/src/cli/lib/client-factory.d.ts.map +1 -1
  74. package/dist/src/cli/lib/client-factory.js +14 -11
  75. package/dist/src/cli/lib/client-factory.js.map +1 -1
  76. package/dist/src/cli/lib/core-maintenance.d.ts.map +1 -1
  77. package/dist/src/cli/lib/core-maintenance.js +11 -22
  78. package/dist/src/cli/lib/core-maintenance.js.map +1 -1
  79. package/package.json +14 -11
  80. package/packages/acp-bridge/package.json +2 -2
  81. package/packages/brand/package.json +1 -1
  82. package/packages/cloud/package.json +2 -2
  83. package/packages/config/package.json +1 -1
  84. package/packages/hooks/package.json +4 -4
  85. package/packages/memory/package.json +2 -2
  86. package/packages/openclaw/package.json +2 -2
  87. package/packages/policy/package.json +2 -2
  88. package/packages/sdk/README.md +10 -3
  89. package/packages/sdk/dist/client.d.ts +108 -196
  90. package/packages/sdk/dist/client.d.ts.map +1 -1
  91. package/packages/sdk/dist/client.js +336 -824
  92. package/packages/sdk/dist/client.js.map +1 -1
  93. package/packages/sdk/dist/examples/example.js +2 -5
  94. package/packages/sdk/dist/examples/example.js.map +1 -1
  95. package/packages/sdk/dist/index.d.ts +3 -1
  96. package/packages/sdk/dist/index.d.ts.map +1 -1
  97. package/packages/sdk/dist/index.js +3 -1
  98. package/packages/sdk/dist/index.js.map +1 -1
  99. package/packages/sdk/dist/relay-adapter.d.ts +9 -26
  100. package/packages/sdk/dist/relay-adapter.d.ts.map +1 -1
  101. package/packages/sdk/dist/relay-adapter.js +75 -47
  102. package/packages/sdk/dist/relay-adapter.js.map +1 -1
  103. package/packages/sdk/dist/relay.d.ts +24 -5
  104. package/packages/sdk/dist/relay.d.ts.map +1 -1
  105. package/packages/sdk/dist/relay.js +213 -43
  106. package/packages/sdk/dist/relay.js.map +1 -1
  107. package/packages/sdk/dist/transport.d.ts +58 -0
  108. package/packages/sdk/dist/transport.d.ts.map +1 -0
  109. package/packages/sdk/dist/transport.js +184 -0
  110. package/packages/sdk/dist/transport.js.map +1 -0
  111. package/packages/sdk/dist/types.d.ts +69 -0
  112. package/packages/sdk/dist/types.d.ts.map +1 -0
  113. package/packages/sdk/dist/types.js +5 -0
  114. package/packages/sdk/dist/types.js.map +1 -0
  115. package/packages/sdk/dist/workflows/cli.js +46 -2
  116. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  117. package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
  118. package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
  119. package/packages/sdk/dist/workflows/file-db.js +20 -3
  120. package/packages/sdk/dist/workflows/file-db.js.map +1 -1
  121. package/packages/sdk/dist/workflows/runner.d.ts +6 -1
  122. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  123. package/packages/sdk/dist/workflows/runner.js +157 -11
  124. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  125. package/packages/sdk/dist/workflows/validator.d.ts.map +1 -1
  126. package/packages/sdk/dist/workflows/validator.js +17 -2
  127. package/packages/sdk/dist/workflows/validator.js.map +1 -1
  128. package/packages/sdk/package.json +2 -2
  129. package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
  130. package/packages/sdk/src/__tests__/unit.test.ts +100 -1
  131. package/packages/sdk/src/client.ts +422 -1072
  132. package/packages/sdk/src/examples/example.ts +2 -5
  133. package/packages/sdk/src/index.ts +8 -1
  134. package/packages/sdk/src/relay-adapter.ts +75 -55
  135. package/packages/sdk/src/relay.ts +260 -57
  136. package/packages/sdk/src/transport.ts +216 -0
  137. package/packages/sdk/src/types.ts +75 -0
  138. package/packages/sdk/src/workflows/cli.ts +53 -2
  139. package/packages/sdk/src/workflows/file-db.ts +22 -3
  140. package/packages/sdk/src/workflows/runner.ts +178 -11
  141. package/packages/sdk/src/workflows/validator.ts +20 -2
  142. package/packages/sdk-py/pyproject.toml +1 -1
  143. package/packages/sdk-py/src/agent_relay/__init__.py +0 -8
  144. package/packages/sdk-py/src/agent_relay/client.py +329 -522
  145. package/packages/sdk-py/src/agent_relay/protocol.py +2 -96
  146. package/packages/sdk-py/src/agent_relay/relay.py +1 -4
  147. package/packages/sdk-py/tests/test_wait_for_api_url.py +92 -0
  148. package/packages/sdk-py/uv.lock +5388 -0
  149. package/packages/telemetry/dist/client.d.ts.map +1 -1
  150. package/packages/telemetry/dist/client.js +1 -1
  151. package/packages/telemetry/dist/client.js.map +1 -1
  152. package/packages/telemetry/package.json +1 -1
  153. package/packages/telemetry/src/client.ts +3 -10
  154. package/packages/trajectory/package.json +2 -2
  155. package/packages/user-directory/package.json +2 -2
  156. package/packages/utils/package.json +2 -2
  157. package/scripts/postinstall.js +121 -1
@@ -1,7 +1,7 @@
1
- """Low-level async client for the Agent Relay broker subprocess.
1
+ """Low-level async client for the Agent Relay broker.
2
2
 
3
- Manages the broker process lifecycle, line-delimited JSON protocol,
4
- request/response correlation, and event dispatch.
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
- # ── CLI / model helpers ───────────────────────────────────────────────────────
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 _expand_tilde(p: str) -> str:
86
- if p == "~" or p.startswith("~/") or p.startswith("~\\"):
87
- return str(Path.home() / p[2:])
88
- return p
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
- def _is_explicit_path(binary_path: str) -> bool:
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 # Non-fatal β€” attribute may not exist
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 # Non-fatal β€” binary may still work
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
- """Manages a broker subprocess and communicates over line-delimited JSON."""
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
- request_timeout_ms: int = 10_000,
261
- shutdown_timeout_ms: int = 3_000,
262
- client_name: str = "agent-relay-sdk-py",
263
- client_version: str = "0.3.0",
264
- ):
265
- self._binary_path = binary_path or _resolve_default_binary_path()
266
- self._binary_args = binary_args or []
267
- self._broker_name = (
268
- broker_name or os.path.basename(cwd or os.getcwd()) or "project"
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
- self._process: Optional[asyncio.subprocess.Process] = None
279
- self._request_seq = 0
280
- self._pending: dict[str, _PendingRequest] = {}
281
- self._event_listeners: list[Callable[[BrokerEvent], None]] = []
282
- self._stderr_listeners: list[Callable[[str], None]] = []
283
- self._event_buffer: list[BrokerEvent] = []
284
- self._max_buffer_size = 1000
285
- self._last_stderr_line: Optional[str] = None
286
- self._starting_lock = asyncio.Lock()
287
- self._started = False
288
- self._reader_task: Optional[asyncio.Task[None]] = None
289
- self._stderr_task: Optional[asyncio.Task[None]] = None
290
- self._exit_future: Optional[asyncio.Future[None]] = None
291
- self.workspace_key: Optional[str] = None
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
- # ── Event subscription ────────────────────────────────────────────────
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
- def on_event(self, listener: Callable[[BrokerEvent], None]) -> Callable[[], None]:
302
- self._event_listeners.append(listener)
290
+ # ── WebSocket events ──────────────────────────────────────────────────
303
291
 
304
- def unsubscribe() -> None:
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._event_listeners.remove(listener)
307
- except ValueError:
322
+ await self.renew_lease()
323
+ except Exception:
308
324
  pass
309
325
 
310
- return unsubscribe
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._stderr_listeners.remove(listener)
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
- # ── Lifecycle ─────────────────────────────────────────────────────────
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
- env = dict(self._env) if self._env else dict(os.environ)
366
- if _is_explicit_path(self._binary_path):
367
- bin_dir = str(Path(resolved_binary).resolve().parent)
368
- current_path = env.get("PATH", "")
369
- if bin_dir not in current_path.split(os.pathsep):
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
- self._last_stderr_line = None
361
+ async def health_check(self) -> dict[str, Any]:
362
+ return await self._request("GET", "/health")
373
363
 
374
- self._process = await asyncio.create_subprocess_exec(
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
- await self.start_client()
572
- built_args = _build_pty_args_with_model(cli, args or [], model)
573
- from .protocol import RestartPolicy as ProtocolRestartPolicy
574
-
575
- rp = None
576
- if restart_policy:
577
- rp = ProtocolRestartPolicy(**restart_policy)
578
- agent = AgentSpec(
579
- name=name,
580
- runtime="pty",
581
- cli=cli,
582
- args=built_args,
583
- channels=channels or [],
584
- model=model,
585
- cwd=cwd or self._cwd,
586
- team=team,
587
- shadow_of=shadow_of,
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: AgentTransport = transport or (
647
- "headless" if provider == "opencode" else "pty"
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
- return await self.spawn_pty(
668
- name=name,
669
- cli=provider,
670
- args=args,
671
- channels=channels,
672
- task=task,
673
- model=model,
674
- cwd=cwd,
675
- team=team,
676
- shadow_of=shadow_of,
677
- shadow_mode=shadow_mode,
678
- idle_threshold_secs=idle_threshold_secs,
679
- restart_policy=restart_policy,
680
- continue_from=continue_from,
681
- skip_relay_prompt=skip_relay_prompt,
682
- )
683
-
684
- async def spawn_claude(self, **kwargs: Any) -> dict[str, Any]:
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
- await self.start_client()
692
- payload: dict[str, Any] = {"name": name}
447
+ kwargs: dict[str, Any] = {}
693
448
  if reason is not None:
694
- payload["reason"] = reason
695
- return await self._request_ok("release_agent", payload)
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.start_client()
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 set_model(
702
- self, name: str, model: str, *, timeout_ms: Optional[int] = None
703
- ) -> dict[str, Any]:
704
- await self.start_client()
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
- payload["from"] = from_
725
- if thread_id is not None:
726
- payload["thread_id"] = thread_id
727
- if priority is not None:
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._request_ok("send_message", payload)
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
- async def list_agents(self) -> list[dict[str, Any]]:
741
- await self.start_client()
742
- result = await self._request_ok("list_agents", {})
743
- return result.get("agents", []) if isinstance(result, dict) else []
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.start_client()
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
- await self.start_client()
751
- return await self._request_ok("get_metrics", {"agent": agent} if agent else {})
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.start_client()
755
- return await self._request_ok("get_crash_insights", {})
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.start_client()
761
- await self._request_ok("preflight_agents", {"agents": agents})
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._process:
765
- return
531
+ if self._lease_task and not self._lease_task.done():
532
+ self._lease_task.cancel()
533
+ self._lease_task = None
766
534
 
767
- try:
768
- await self._request_ok("shutdown", {})
769
- except Exception:
770
- pass
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
- process = self._process
773
- try:
774
- await asyncio.wait_for(
775
- self._exit_future if self._exit_future else asyncio.sleep(0),
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._process = None
793
- self._started = False
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._exit_future:
797
- await self._exit_future
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