agent-relay 3.0.2 → 3.1.1

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 (266) hide show
  1. package/README.md +8 -0
  2. package/bin/agent-relay-broker-linux-x64 +0 -0
  3. package/dist/index.cjs +273 -56
  4. package/dist/src/cli/commands/core.d.ts +2 -0
  5. package/dist/src/cli/commands/core.d.ts.map +1 -1
  6. package/dist/src/cli/commands/core.js +9 -2
  7. package/dist/src/cli/commands/core.js.map +1 -1
  8. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  9. package/dist/src/cli/lib/broker-lifecycle.js +87 -28
  10. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  11. package/package.json +9 -9
  12. package/packages/acp-bridge/README.md +50 -67
  13. package/packages/acp-bridge/package.json +2 -2
  14. package/packages/config/package.json +1 -1
  15. package/packages/hooks/package.json +4 -4
  16. package/packages/memory/package.json +2 -2
  17. package/packages/openclaw/README.md +78 -0
  18. package/packages/openclaw/bin/relay-openclaw.mjs +2 -0
  19. package/packages/openclaw/bridge/bridge.mjs +305 -0
  20. package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts +2 -0
  21. package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts.map +1 -0
  22. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +320 -0
  23. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -0
  24. package/packages/openclaw/dist/__tests__/naming.test.d.ts +2 -0
  25. package/packages/openclaw/dist/__tests__/naming.test.d.ts.map +1 -0
  26. package/packages/openclaw/dist/__tests__/naming.test.js +21 -0
  27. package/packages/openclaw/dist/__tests__/naming.test.js.map +1 -0
  28. package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts +2 -0
  29. package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts.map +1 -0
  30. package/packages/openclaw/dist/__tests__/spawn-manager.test.js +126 -0
  31. package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -0
  32. package/packages/openclaw/dist/auth/converter.d.ts +28 -0
  33. package/packages/openclaw/dist/auth/converter.d.ts.map +1 -0
  34. package/packages/openclaw/dist/auth/converter.js +64 -0
  35. package/packages/openclaw/dist/auth/converter.js.map +1 -0
  36. package/packages/openclaw/dist/cli.d.ts +2 -0
  37. package/packages/openclaw/dist/cli.d.ts.map +1 -0
  38. package/packages/openclaw/dist/cli.js +230 -0
  39. package/packages/openclaw/dist/cli.js.map +1 -0
  40. package/packages/openclaw/dist/config.d.ts +27 -0
  41. package/packages/openclaw/dist/config.d.ts.map +1 -0
  42. package/packages/openclaw/dist/config.js +97 -0
  43. package/packages/openclaw/dist/config.js.map +1 -0
  44. package/packages/openclaw/dist/control.d.ts +22 -0
  45. package/packages/openclaw/dist/control.d.ts.map +1 -0
  46. package/packages/openclaw/dist/control.js +58 -0
  47. package/packages/openclaw/dist/control.js.map +1 -0
  48. package/packages/openclaw/dist/gateway.d.ts +71 -0
  49. package/packages/openclaw/dist/gateway.d.ts.map +1 -0
  50. package/packages/openclaw/dist/gateway.js +785 -0
  51. package/packages/openclaw/dist/gateway.js.map +1 -0
  52. package/packages/openclaw/dist/identity/contract.d.ts +11 -0
  53. package/packages/openclaw/dist/identity/contract.d.ts.map +1 -0
  54. package/packages/openclaw/dist/identity/contract.js +40 -0
  55. package/packages/openclaw/dist/identity/contract.js.map +1 -0
  56. package/packages/openclaw/dist/identity/files.d.ts +33 -0
  57. package/packages/openclaw/dist/identity/files.d.ts.map +1 -0
  58. package/packages/openclaw/dist/identity/files.js +145 -0
  59. package/packages/openclaw/dist/identity/files.js.map +1 -0
  60. package/packages/openclaw/dist/identity/model.d.ts +11 -0
  61. package/packages/openclaw/dist/identity/model.d.ts.map +1 -0
  62. package/packages/openclaw/dist/identity/model.js +28 -0
  63. package/packages/openclaw/dist/identity/model.js.map +1 -0
  64. package/packages/openclaw/dist/identity/naming.d.ts +5 -0
  65. package/packages/openclaw/dist/identity/naming.d.ts.map +1 -0
  66. package/packages/openclaw/dist/identity/naming.js +7 -0
  67. package/packages/openclaw/dist/identity/naming.js.map +1 -0
  68. package/packages/openclaw/dist/index.d.ts +20 -0
  69. package/packages/openclaw/dist/index.d.ts.map +1 -0
  70. package/packages/openclaw/dist/index.js +27 -0
  71. package/packages/openclaw/dist/index.js.map +1 -0
  72. package/packages/openclaw/dist/inject.d.ts +14 -0
  73. package/packages/openclaw/dist/inject.d.ts.map +1 -0
  74. package/packages/openclaw/dist/inject.js +66 -0
  75. package/packages/openclaw/dist/inject.js.map +1 -0
  76. package/packages/openclaw/dist/mcp/server.d.ts +8 -0
  77. package/packages/openclaw/dist/mcp/server.d.ts.map +1 -0
  78. package/packages/openclaw/dist/mcp/server.js +105 -0
  79. package/packages/openclaw/dist/mcp/server.js.map +1 -0
  80. package/packages/openclaw/dist/mcp/tools.d.ts +17 -0
  81. package/packages/openclaw/dist/mcp/tools.d.ts.map +1 -0
  82. package/packages/openclaw/dist/mcp/tools.js +145 -0
  83. package/packages/openclaw/dist/mcp/tools.js.map +1 -0
  84. package/packages/openclaw/dist/runtime/openclaw-config.d.ts +20 -0
  85. package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -0
  86. package/packages/openclaw/dist/runtime/openclaw-config.js +50 -0
  87. package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -0
  88. package/packages/openclaw/dist/runtime/patch.d.ts +24 -0
  89. package/packages/openclaw/dist/runtime/patch.d.ts.map +1 -0
  90. package/packages/openclaw/dist/runtime/patch.js +92 -0
  91. package/packages/openclaw/dist/runtime/patch.js.map +1 -0
  92. package/packages/openclaw/dist/runtime/setup.d.ts +26 -0
  93. package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -0
  94. package/packages/openclaw/dist/runtime/setup.js +58 -0
  95. package/packages/openclaw/dist/runtime/setup.js.map +1 -0
  96. package/packages/openclaw/dist/setup.d.ts +29 -0
  97. package/packages/openclaw/dist/setup.d.ts.map +1 -0
  98. package/packages/openclaw/dist/setup.js +300 -0
  99. package/packages/openclaw/dist/setup.js.map +1 -0
  100. package/packages/openclaw/dist/spawn/docker.d.ts +58 -0
  101. package/packages/openclaw/dist/spawn/docker.d.ts.map +1 -0
  102. package/packages/openclaw/dist/spawn/docker.js +222 -0
  103. package/packages/openclaw/dist/spawn/docker.js.map +1 -0
  104. package/packages/openclaw/dist/spawn/manager.d.ts +45 -0
  105. package/packages/openclaw/dist/spawn/manager.d.ts.map +1 -0
  106. package/packages/openclaw/dist/spawn/manager.js +140 -0
  107. package/packages/openclaw/dist/spawn/manager.js.map +1 -0
  108. package/packages/openclaw/dist/spawn/process.d.ts +16 -0
  109. package/packages/openclaw/dist/spawn/process.d.ts.map +1 -0
  110. package/packages/openclaw/dist/spawn/process.js +241 -0
  111. package/packages/openclaw/dist/spawn/process.js.map +1 -0
  112. package/packages/openclaw/dist/spawn/types.d.ts +42 -0
  113. package/packages/openclaw/dist/spawn/types.d.ts.map +1 -0
  114. package/packages/openclaw/dist/spawn/types.js +2 -0
  115. package/packages/openclaw/dist/spawn/types.js.map +1 -0
  116. package/packages/openclaw/dist/types.d.ts +37 -0
  117. package/packages/openclaw/dist/types.d.ts.map +1 -0
  118. package/packages/openclaw/dist/types.js +2 -0
  119. package/packages/openclaw/dist/types.js.map +1 -0
  120. package/packages/openclaw/package.json +63 -0
  121. package/packages/openclaw/skill/SKILL.md +194 -0
  122. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +384 -0
  123. package/packages/openclaw/src/__tests__/naming.test.ts +24 -0
  124. package/packages/openclaw/src/__tests__/spawn-manager.test.ts +152 -0
  125. package/packages/openclaw/src/auth/converter.ts +90 -0
  126. package/packages/openclaw/src/cli.ts +269 -0
  127. package/packages/openclaw/src/config.ts +124 -0
  128. package/packages/openclaw/src/control.ts +100 -0
  129. package/packages/openclaw/src/gateway.ts +941 -0
  130. package/packages/openclaw/src/identity/contract.ts +44 -0
  131. package/packages/openclaw/src/identity/files.ts +198 -0
  132. package/packages/openclaw/src/identity/model.ts +27 -0
  133. package/packages/openclaw/src/identity/naming.ts +6 -0
  134. package/packages/openclaw/src/index.ts +59 -0
  135. package/packages/openclaw/src/inject.ts +77 -0
  136. package/packages/openclaw/src/mcp/server.ts +121 -0
  137. package/packages/openclaw/src/mcp/tools.ts +174 -0
  138. package/packages/openclaw/src/runtime/openclaw-config.ts +64 -0
  139. package/packages/openclaw/src/runtime/patch.ts +103 -0
  140. package/packages/openclaw/src/runtime/setup.ts +89 -0
  141. package/packages/openclaw/src/setup.ts +336 -0
  142. package/packages/openclaw/src/spawn/docker.ts +261 -0
  143. package/packages/openclaw/src/spawn/manager.ts +181 -0
  144. package/packages/openclaw/src/spawn/process.ts +272 -0
  145. package/packages/openclaw/src/spawn/types.ts +43 -0
  146. package/packages/openclaw/src/types.ts +38 -0
  147. package/packages/openclaw/templates/SOUL.md.template +34 -0
  148. package/packages/openclaw/tsconfig.json +12 -0
  149. package/packages/policy/package.json +2 -2
  150. package/packages/sdk/README.md +169 -64
  151. package/packages/sdk/dist/__tests__/contract-fixtures.test.js +76 -9
  152. package/packages/sdk/dist/__tests__/contract-fixtures.test.js.map +1 -1
  153. package/packages/sdk/dist/__tests__/integration.test.js +5 -4
  154. package/packages/sdk/dist/__tests__/integration.test.js.map +1 -1
  155. package/packages/sdk/dist/client.d.ts +34 -3
  156. package/packages/sdk/dist/client.d.ts.map +1 -1
  157. package/packages/sdk/dist/client.js +120 -10
  158. package/packages/sdk/dist/client.js.map +1 -1
  159. package/packages/sdk/dist/protocol.d.ts +7 -1
  160. package/packages/sdk/dist/protocol.d.ts.map +1 -1
  161. package/packages/sdk/dist/relay.d.ts +47 -11
  162. package/packages/sdk/dist/relay.d.ts.map +1 -1
  163. package/packages/sdk/dist/relay.js +114 -23
  164. package/packages/sdk/dist/relay.js.map +1 -1
  165. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  166. package/packages/sdk/dist/workflows/runner.js +71 -36
  167. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  168. package/packages/sdk/dist/workflows/types.d.ts +1 -1
  169. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  170. package/packages/sdk/package.json +2 -2
  171. package/packages/sdk/src/__tests__/contract-fixtures.test.ts +88 -9
  172. package/packages/sdk/src/__tests__/error-scenarios.test.ts +1 -1
  173. package/packages/sdk/src/__tests__/idle-nudge.test.ts +205 -257
  174. package/packages/sdk/src/__tests__/integration.test.ts +5 -4
  175. package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +277 -13
  176. package/packages/sdk/src/__tests__/swarm-coordinator.test.ts +1 -0
  177. package/packages/sdk/src/__tests__/workflow-runner.test.ts +67 -7
  178. package/packages/sdk/src/__tests__/workflow-trajectory.test.ts +4 -5
  179. package/packages/sdk/src/client.ts +171 -14
  180. package/packages/sdk/src/examples/workflows/runner-idle-refactor.yaml +306 -0
  181. package/packages/sdk/src/protocol.ts +7 -2
  182. package/packages/sdk/src/relay.ts +196 -34
  183. package/packages/sdk/src/workflows/runner.ts +73 -42
  184. package/packages/sdk/src/workflows/schema.json +1 -1
  185. package/packages/sdk/src/workflows/types.ts +1 -1
  186. package/packages/sdk/vitest.config.ts +1 -0
  187. package/packages/sdk-py/README.md +89 -102
  188. package/packages/sdk-py/agent_relay/__init__.py +16 -19
  189. package/packages/sdk-py/pyproject.toml +5 -1
  190. package/packages/sdk-py/src/agent_relay/__init__.py +35 -1
  191. package/packages/sdk-py/src/agent_relay/client.py +776 -0
  192. package/packages/sdk-py/src/agent_relay/models.py +27 -0
  193. package/packages/sdk-py/src/agent_relay/protocol.py +114 -0
  194. package/packages/sdk-py/src/agent_relay/relay.py +860 -0
  195. package/packages/sdk-py/tests/test_relay_lifecycle_hooks.py +250 -0
  196. package/packages/telemetry/package.json +1 -1
  197. package/packages/trajectory/package.json +2 -2
  198. package/packages/user-directory/package.json +2 -2
  199. package/packages/utils/package.json +2 -2
  200. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  201. package/bin/agent-relay-broker-darwin-x64 +0 -0
  202. package/bin/agent-relay-broker-linux-arm64 +0 -0
  203. package/packages/sdk/.trajectories/active/traj_1771875803391_84ca57b2.json +0 -50
  204. package/packages/sdk/.trajectories/active/traj_1771891934534_06504121.json +0 -50
  205. package/packages/sdk/.trajectories/active/traj_1771891957929_211afc4e.json +0 -50
  206. package/packages/sdk/.trajectories/active/traj_1771891982509_38c84638.json +0 -50
  207. package/packages/sdk/.trajectories/completed/traj_1771875803188_cd6d181c.json +0 -80
  208. package/packages/sdk/.trajectories/completed/traj_1771875803204_f2aeb8c8.json +0 -80
  209. package/packages/sdk/.trajectories/completed/traj_1771875803210_d65f3f1a.json +0 -80
  210. package/packages/sdk/.trajectories/completed/traj_1771875803218_e454a25d.json +0 -80
  211. package/packages/sdk/.trajectories/completed/traj_1771875803223_d7a64815.json +0 -80
  212. package/packages/sdk/.trajectories/completed/traj_1771875803227_7e56da5b.json +0 -80
  213. package/packages/sdk/.trajectories/completed/traj_1771875803235_4fbf93b4.json +0 -80
  214. package/packages/sdk/.trajectories/completed/traj_1771875803243_47931c71.json +0 -80
  215. package/packages/sdk/.trajectories/completed/traj_1771875803258_3816f3fe.json +0 -80
  216. package/packages/sdk/.trajectories/completed/traj_1771875803268_8061140e.json +0 -80
  217. package/packages/sdk/.trajectories/completed/traj_1771875803326_ae6f9c78.json +0 -80
  218. package/packages/sdk/.trajectories/completed/traj_1771875808396_cbde0a6c.json +0 -91
  219. package/packages/sdk/.trajectories/completed/traj_1771875812026_aa2442bb.json +0 -91
  220. package/packages/sdk/.trajectories/completed/traj_1771875815431_c2c656c5.json +0 -91
  221. package/packages/sdk/.trajectories/completed/traj_1771875818645_3a4dbf02.json +0 -91
  222. package/packages/sdk/.trajectories/completed/traj_1771891934403_24923c03.json +0 -80
  223. package/packages/sdk/.trajectories/completed/traj_1771891934421_dca16e24.json +0 -80
  224. package/packages/sdk/.trajectories/completed/traj_1771891934430_057706f7.json +0 -80
  225. package/packages/sdk/.trajectories/completed/traj_1771891934442_faf97382.json +0 -80
  226. package/packages/sdk/.trajectories/completed/traj_1771891934454_5542ecd5.json +0 -80
  227. package/packages/sdk/.trajectories/completed/traj_1771891934464_12202a08.json +0 -80
  228. package/packages/sdk/.trajectories/completed/traj_1771891934487_94378275.json +0 -80
  229. package/packages/sdk/.trajectories/completed/traj_1771891934503_ca728c13.json +0 -80
  230. package/packages/sdk/.trajectories/completed/traj_1771891934519_100af69a.json +0 -80
  231. package/packages/sdk/.trajectories/completed/traj_1771891934536_62ad39d9.json +0 -80
  232. package/packages/sdk/.trajectories/completed/traj_1771891934553_d6798a52.json +0 -80
  233. package/packages/sdk/.trajectories/completed/traj_1771891939537_541c8096.json +0 -91
  234. package/packages/sdk/.trajectories/completed/traj_1771891942985_36ab9a4d.json +0 -91
  235. package/packages/sdk/.trajectories/completed/traj_1771891946453_e8a6e05f.json +0 -91
  236. package/packages/sdk/.trajectories/completed/traj_1771891949838_5de0de84.json +0 -91
  237. package/packages/sdk/.trajectories/completed/traj_1771891957807_0ecfb4f4.json +0 -80
  238. package/packages/sdk/.trajectories/completed/traj_1771891957827_c4539239.json +0 -80
  239. package/packages/sdk/.trajectories/completed/traj_1771891957836_91168b48.json +0 -80
  240. package/packages/sdk/.trajectories/completed/traj_1771891957848_8c5cad0b.json +0 -80
  241. package/packages/sdk/.trajectories/completed/traj_1771891957857_0986b293.json +0 -80
  242. package/packages/sdk/.trajectories/completed/traj_1771891957872_8a3113af.json +0 -80
  243. package/packages/sdk/.trajectories/completed/traj_1771891957884_0bb85208.json +0 -80
  244. package/packages/sdk/.trajectories/completed/traj_1771891957892_86c75e2e.json +0 -80
  245. package/packages/sdk/.trajectories/completed/traj_1771891957907_98ca0e6f.json +0 -80
  246. package/packages/sdk/.trajectories/completed/traj_1771891957918_d9091231.json +0 -80
  247. package/packages/sdk/.trajectories/completed/traj_1771891957931_dcaf77ed.json +0 -80
  248. package/packages/sdk/.trajectories/completed/traj_1771891962931_eb1fdee2.json +0 -91
  249. package/packages/sdk/.trajectories/completed/traj_1771891966262_9061a93f.json +0 -91
  250. package/packages/sdk/.trajectories/completed/traj_1771891969915_1adaba19.json +0 -91
  251. package/packages/sdk/.trajectories/completed/traj_1771891973588_f08b79e9.json +0 -91
  252. package/packages/sdk/.trajectories/completed/traj_1771891982421_f1985bce.json +0 -80
  253. package/packages/sdk/.trajectories/completed/traj_1771891982432_e7a84163.json +0 -80
  254. package/packages/sdk/.trajectories/completed/traj_1771891982447_369b842a.json +0 -80
  255. package/packages/sdk/.trajectories/completed/traj_1771891982469_5fc45199.json +0 -80
  256. package/packages/sdk/.trajectories/completed/traj_1771891982495_454c7cb3.json +0 -80
  257. package/packages/sdk/.trajectories/completed/traj_1771891982514_08098e03.json +0 -80
  258. package/packages/sdk/.trajectories/completed/traj_1771891982526_b351d778.json +0 -80
  259. package/packages/sdk/.trajectories/completed/traj_1771891982533_fa542d83.json +0 -80
  260. package/packages/sdk/.trajectories/completed/traj_1771891982540_18ab24dc.json +0 -80
  261. package/packages/sdk/.trajectories/completed/traj_1771891982544_5b4fa163.json +0 -80
  262. package/packages/sdk/.trajectories/completed/traj_1771891982548_c13f089a.json +0 -80
  263. package/packages/sdk/.trajectories/completed/traj_1771891987510_23f6da1f.json +0 -91
  264. package/packages/sdk/.trajectories/completed/traj_1771891991466_912c2e04.json +0 -91
  265. package/packages/sdk/.trajectories/completed/traj_1771891994891_60604be2.json +0 -91
  266. package/packages/sdk/.trajectories/completed/traj_1771891998370_cfaf9b8b.json +0 -91
@@ -0,0 +1,860 @@
1
+ """High-level facade for the Agent Relay SDK.
2
+
3
+ Provides a clean, property-based API on top of the lower-level
4
+ AgentRelayClient protocol client.
5
+
6
+ Mirrors packages/sdk/src/relay.ts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import inspect
13
+ import os
14
+ import secrets
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Awaitable, Callable, Optional
17
+
18
+ from .client import AgentRelayClient
19
+ from .protocol import AgentRuntime, BrokerEvent
20
+
21
+ # ── Public types ──────────────────────────────────────────────────────────────
22
+
23
+ AgentStatus = str # "spawning" | "ready" | "idle" | "exited"
24
+
25
+ EventHook = Optional[Callable[..., None]]
26
+ LifecycleHook = Optional[Callable[[dict[str, Any]], None | Awaitable[None]]]
27
+
28
+
29
+ @dataclass
30
+ class Message:
31
+ """A relay message between agents."""
32
+
33
+ event_id: str
34
+ from_name: str
35
+ to: str
36
+ text: str
37
+ thread_id: Optional[str] = None
38
+ data: Optional[dict[str, Any]] = None
39
+
40
+
41
+ @dataclass
42
+ class SpawnOptions:
43
+ """Options for spawning an agent."""
44
+
45
+ args: list[str] = field(default_factory=list)
46
+ channels: list[str] = field(default_factory=list)
47
+ model: Optional[str] = None
48
+ cwd: Optional[str] = None
49
+ team: Optional[str] = None
50
+ shadow_of: Optional[str] = None
51
+ shadow_mode: Optional[str] = None
52
+ idle_threshold_secs: Optional[int] = None
53
+ restart_policy: Optional[dict[str, Any]] = None
54
+ on_start: LifecycleHook = None
55
+ on_success: LifecycleHook = None
56
+ on_error: LifecycleHook = None
57
+
58
+
59
+ # ── Agent handle ──────────────────────────────────────────────────────────────
60
+
61
+
62
+ class Agent:
63
+ """Handle for a spawned agent with lifecycle methods."""
64
+
65
+ def __init__(
66
+ self,
67
+ name: str,
68
+ runtime: AgentRuntime,
69
+ channels: list[str],
70
+ relay: AgentRelay,
71
+ ):
72
+ self._name = name
73
+ self._runtime = runtime
74
+ self._channels = channels
75
+ self._relay = relay
76
+ self.exit_code: Optional[int] = None
77
+ self.exit_signal: Optional[str] = None
78
+ self.exit_reason: Optional[str] = None
79
+
80
+ @property
81
+ def name(self) -> str:
82
+ return self._name
83
+
84
+ @property
85
+ def runtime(self) -> AgentRuntime:
86
+ return self._runtime
87
+
88
+ @property
89
+ def channels(self) -> list[str]:
90
+ return self._channels
91
+
92
+ @property
93
+ def status(self) -> AgentStatus:
94
+ if self._name in self._relay._exited_agents:
95
+ return "exited"
96
+ if self._name in self._relay._idle_agents:
97
+ return "idle"
98
+ if self._name in self._relay._ready_agents:
99
+ return "ready"
100
+ return "spawning"
101
+
102
+ async def release(
103
+ self,
104
+ reason: Optional[str] = None,
105
+ *,
106
+ on_start: LifecycleHook = None,
107
+ on_success: LifecycleHook = None,
108
+ on_error: LifecycleHook = None,
109
+ ) -> None:
110
+ context = {
111
+ "name": self._name,
112
+ "reason": reason,
113
+ }
114
+ client = await self._relay._ensure_started()
115
+ await self._relay._invoke_lifecycle_hook(
116
+ on_start,
117
+ context,
118
+ f'release("{self._name}") on_start',
119
+ )
120
+ try:
121
+ await client.release(self._name, reason)
122
+ await self._relay._invoke_lifecycle_hook(
123
+ on_success,
124
+ context,
125
+ f'release("{self._name}") on_success',
126
+ )
127
+ except Exception as error:
128
+ await self._relay._invoke_lifecycle_hook(
129
+ on_error,
130
+ {
131
+ **context,
132
+ "error": error,
133
+ },
134
+ f'release("{self._name}") on_error',
135
+ )
136
+ raise
137
+
138
+ async def wait_for_ready(self, timeout_ms: int = 60_000) -> None:
139
+ await self._relay.wait_for_agent_ready(self._name, timeout_ms)
140
+
141
+ async def wait_for_exit(self, timeout_ms: Optional[int] = None) -> str:
142
+ """Wait for agent to exit. Returns 'exited', 'released', or 'timeout'."""
143
+ if self._name not in self._relay._known_agents:
144
+ return "exited"
145
+ if timeout_ms == 0:
146
+ return "timeout"
147
+
148
+ future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
149
+ self._relay._exit_resolvers.setdefault(self._name, []).append(future)
150
+
151
+ if timeout_ms is not None:
152
+ try:
153
+ return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
154
+ except asyncio.TimeoutError:
155
+ futures = self._relay._exit_resolvers.get(self._name, [])
156
+ try:
157
+ futures.remove(future)
158
+ except ValueError:
159
+ pass
160
+ if not futures:
161
+ self._relay._exit_resolvers.pop(self._name, None)
162
+ return "timeout"
163
+ else:
164
+ return await future
165
+
166
+ async def wait_for_idle(self, timeout_ms: Optional[int] = None) -> str:
167
+ """Wait for agent to go idle. Returns 'idle', 'exited', or 'timeout'."""
168
+ if self._name not in self._relay._known_agents:
169
+ return "exited"
170
+ if timeout_ms == 0:
171
+ return "timeout"
172
+
173
+ future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
174
+ self._relay._idle_resolvers.setdefault(self._name, []).append(future)
175
+
176
+ if timeout_ms is not None:
177
+ try:
178
+ return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
179
+ except asyncio.TimeoutError:
180
+ futures = self._relay._idle_resolvers.get(self._name, [])
181
+ try:
182
+ futures.remove(future)
183
+ except ValueError:
184
+ pass
185
+ if not futures:
186
+ self._relay._idle_resolvers.pop(self._name, None)
187
+ return "timeout"
188
+ else:
189
+ return await future
190
+
191
+ async def send_message(
192
+ self,
193
+ *,
194
+ to: str,
195
+ text: str,
196
+ thread_id: Optional[str] = None,
197
+ priority: Optional[int] = None,
198
+ data: Optional[dict[str, Any]] = None,
199
+ ) -> Message:
200
+ client = await self._relay._ensure_started()
201
+ result = await client.send_message(
202
+ to=to,
203
+ text=text,
204
+ from_=self._name,
205
+ thread_id=thread_id,
206
+ priority=priority,
207
+ data=data,
208
+ )
209
+
210
+ event_id = result.get("event_id", secrets.token_hex(8))
211
+ msg = Message(
212
+ event_id=event_id,
213
+ from_name=self._name,
214
+ to=to,
215
+ text=text,
216
+ thread_id=thread_id,
217
+ data=data,
218
+ )
219
+ # Don't fire hook for unsupported operations
220
+ if event_id != "unsupported_operation" and self._relay.on_message_sent:
221
+ self._relay.on_message_sent(msg)
222
+ return msg
223
+
224
+ def on_output(self, callback: Callable[[str], None]) -> Callable[[], None]:
225
+ listeners = self._relay._output_listeners.setdefault(self._name, [])
226
+ listeners.append(callback)
227
+
228
+ def unsubscribe() -> None:
229
+ try:
230
+ listeners.remove(callback)
231
+ except ValueError:
232
+ pass
233
+ if not listeners:
234
+ self._relay._output_listeners.pop(self._name, None)
235
+
236
+ return unsubscribe
237
+
238
+
239
+ # ── Human handle ──────────────────────────────────────────────────────────────
240
+
241
+
242
+ class HumanHandle:
243
+ """A messaging handle for human/system messages."""
244
+
245
+ def __init__(self, name: str, relay: AgentRelay):
246
+ self._name = name
247
+ self._relay = relay
248
+
249
+ @property
250
+ def name(self) -> str:
251
+ return self._name
252
+
253
+ async def send_message(
254
+ self,
255
+ *,
256
+ to: str,
257
+ text: str,
258
+ thread_id: Optional[str] = None,
259
+ priority: Optional[int] = None,
260
+ data: Optional[dict[str, Any]] = None,
261
+ ) -> Message:
262
+ client = await self._relay._ensure_started()
263
+ result = await client.send_message(
264
+ to=to,
265
+ text=text,
266
+ from_=self._name,
267
+ thread_id=thread_id,
268
+ priority=priority,
269
+ data=data,
270
+ )
271
+
272
+ event_id = result.get("event_id", secrets.token_hex(8))
273
+ msg = Message(
274
+ event_id=event_id,
275
+ from_name=self._name,
276
+ to=to,
277
+ text=text,
278
+ thread_id=thread_id,
279
+ data=data,
280
+ )
281
+ # Don't fire hook for unsupported operations
282
+ if event_id != "unsupported_operation" and self._relay.on_message_sent:
283
+ self._relay.on_message_sent(msg)
284
+ return msg
285
+
286
+
287
+ # ── Agent spawner ─────────────────────────────────────────────────────────────
288
+
289
+
290
+ class AgentSpawner:
291
+ """Shorthand spawner for a specific CLI (e.g., relay.claude.spawn(...))."""
292
+
293
+ def __init__(self, cli: str, default_name: str, relay: AgentRelay):
294
+ self._cli = cli
295
+ self._default_name = default_name
296
+ self._relay = relay
297
+
298
+ async def spawn(
299
+ self,
300
+ *,
301
+ name: Optional[str] = None,
302
+ args: Optional[list[str]] = None,
303
+ channels: Optional[list[str]] = None,
304
+ task: Optional[str] = None,
305
+ model: Optional[str] = None,
306
+ cwd: Optional[str] = None,
307
+ on_start: LifecycleHook = None,
308
+ on_success: LifecycleHook = None,
309
+ on_error: LifecycleHook = None,
310
+ ) -> Agent:
311
+ agent_name = name or self._default_name
312
+ agent_channels = channels or ["general"]
313
+ context = {
314
+ "name": agent_name,
315
+ "cli": self._cli,
316
+ "channels": agent_channels,
317
+ "task": task,
318
+ }
319
+ client = await self._relay._ensure_started()
320
+ await self._relay._invoke_lifecycle_hook(
321
+ on_start,
322
+ context,
323
+ f'spawn("{agent_name}") on_start',
324
+ )
325
+
326
+ try:
327
+ result = await client.spawn_pty(
328
+ name=agent_name,
329
+ cli=self._cli,
330
+ args=args or [],
331
+ channels=agent_channels,
332
+ task=task,
333
+ model=model,
334
+ cwd=cwd,
335
+ )
336
+ except Exception as error:
337
+ await self._relay._invoke_lifecycle_hook(
338
+ on_error,
339
+ {
340
+ **context,
341
+ "error": error,
342
+ },
343
+ f'spawn("{agent_name}") on_error',
344
+ )
345
+ raise
346
+
347
+ agent = Agent(
348
+ name=result.get("name", agent_name),
349
+ runtime=result.get("runtime", "pty"),
350
+ channels=agent_channels,
351
+ relay=self._relay,
352
+ )
353
+ self._relay._known_agents[agent.name] = agent
354
+ self._relay._reset_agent_lifecycle_state(agent.name)
355
+ await self._relay._invoke_lifecycle_hook(
356
+ on_success,
357
+ {
358
+ **context,
359
+ "name": agent.name,
360
+ "runtime": agent.runtime,
361
+ },
362
+ f'spawn("{agent_name}") on_success',
363
+ )
364
+ return agent
365
+
366
+
367
+ # ── AgentRelay facade ─────────────────────────────────────────────────────────
368
+
369
+
370
+ class AgentRelay:
371
+ """High-level facade for the Agent Relay SDK.
372
+
373
+ Example::
374
+
375
+ relay = AgentRelay(channels=["GTM"])
376
+ relay.on_message_received = lambda msg: print(f"[{msg.from_name}]: {msg.text}")
377
+
378
+ await relay.claude.spawn(name="Analyst", model="opus", channels=["GTM"], task="Analyze")
379
+ await relay.wait_for_agent_ready("Analyst")
380
+ await relay.shutdown()
381
+ """
382
+
383
+ def __init__(
384
+ self,
385
+ *,
386
+ binary_path: Optional[str] = None,
387
+ binary_args: Optional[list[str]] = None,
388
+ broker_name: Optional[str] = None,
389
+ channels: Optional[list[str]] = None,
390
+ cwd: Optional[str] = None,
391
+ env: Optional[dict[str, str]] = None,
392
+ request_timeout_ms: int = 10_000,
393
+ shutdown_timeout_ms: int = 3_000,
394
+ ):
395
+ # Event hooks — assign a callback or None to clear
396
+ self.on_message_received: EventHook = None
397
+ self.on_message_sent: EventHook = None
398
+ self.on_agent_spawned: EventHook = None
399
+ self.on_agent_released: EventHook = None
400
+ self.on_agent_exited: EventHook = None
401
+ self.on_agent_ready: EventHook = None
402
+ self.on_worker_output: EventHook = None
403
+ self.on_delivery_update: EventHook = None
404
+ self.on_agent_exit_requested: EventHook = None
405
+ self.on_agent_idle: EventHook = None
406
+
407
+ self._default_channels = channels or ["general"]
408
+ self._client_kwargs: dict[str, Any] = {
409
+ "binary_path": binary_path,
410
+ "binary_args": binary_args,
411
+ "broker_name": broker_name,
412
+ "channels": self._default_channels,
413
+ "cwd": cwd,
414
+ "env": env,
415
+ "request_timeout_ms": request_timeout_ms,
416
+ "shutdown_timeout_ms": shutdown_timeout_ms,
417
+ }
418
+
419
+ self._client: Optional[AgentRelayClient] = None
420
+ self._start_lock = asyncio.Lock()
421
+ self._unsubscribe_event: Optional[Callable[[], None]] = None
422
+
423
+ # Agent tracking
424
+ self._known_agents: dict[str, Agent] = {}
425
+ self._ready_agents: set[str] = set()
426
+ self._message_ready_agents: set[str] = set()
427
+ self._exited_agents: set[str] = set()
428
+ self._idle_agents: set[str] = set()
429
+ self._output_listeners: dict[str, list[Callable[[str], None]]] = {}
430
+ self._exit_resolvers: dict[str, list[asyncio.Future[str]]] = {}
431
+ self._idle_resolvers: dict[str, list[asyncio.Future[str]]] = {}
432
+
433
+ # Shorthand spawners
434
+ self.codex = AgentSpawner("codex", "Codex", self)
435
+ self.claude = AgentSpawner("claude", "Claude", self)
436
+ self.gemini = AgentSpawner("gemini", "Gemini", self)
437
+
438
+ @property
439
+ def workspace_key(self) -> Optional[str]:
440
+ return self._client.workspace_key if self._client else None
441
+
442
+ # ── Internal startup ──────────────────────────────────────────────────
443
+
444
+ async def _ensure_started(self) -> AgentRelayClient:
445
+ if self._client:
446
+ return self._client
447
+ async with self._start_lock:
448
+ if self._client:
449
+ return self._client
450
+
451
+ # Ensure env has RELAY_API_KEY if available
452
+ env = self._client_kwargs.get("env")
453
+ if env is None:
454
+ env_key = os.environ.get("RELAY_API_KEY")
455
+ if env_key:
456
+ self._client_kwargs["env"] = {**os.environ, "RELAY_API_KEY": env_key}
457
+ else:
458
+ self._client_kwargs["env"] = dict(os.environ)
459
+ else:
460
+ # Inject RELAY_API_KEY into custom env if not already present
461
+ env_key = os.environ.get("RELAY_API_KEY")
462
+ if env_key and "RELAY_API_KEY" not in env:
463
+ env["RELAY_API_KEY"] = env_key
464
+
465
+ # Remove None values to use defaults
466
+ kwargs = {k: v for k, v in self._client_kwargs.items() if v is not None}
467
+ client = AgentRelayClient(**kwargs)
468
+ await client.start_client()
469
+
470
+ self._client = client
471
+ if client.workspace_key:
472
+ pass # workspace_key is available via property
473
+
474
+ self._wire_events(client)
475
+ return client
476
+
477
+ # ── Spawning ──────────────────────────────────────────────────────────
478
+
479
+ async def spawn(
480
+ self,
481
+ name: str,
482
+ cli: str,
483
+ task: Optional[str] = None,
484
+ options: Optional[SpawnOptions] = None,
485
+ ) -> Agent:
486
+ client = await self._ensure_started()
487
+ opts = options or SpawnOptions()
488
+ channels = opts.channels or ["general"]
489
+ context = {
490
+ "name": name,
491
+ "cli": cli,
492
+ "channels": channels,
493
+ "task": task,
494
+ }
495
+ await self._invoke_lifecycle_hook(
496
+ opts.on_start,
497
+ context,
498
+ f'spawn("{name}") on_start',
499
+ )
500
+
501
+ try:
502
+ result = await client.spawn_pty(
503
+ name=name,
504
+ cli=cli,
505
+ task=task,
506
+ args=opts.args,
507
+ channels=channels,
508
+ model=opts.model,
509
+ cwd=opts.cwd,
510
+ team=opts.team,
511
+ shadow_of=opts.shadow_of,
512
+ shadow_mode=opts.shadow_mode,
513
+ idle_threshold_secs=opts.idle_threshold_secs,
514
+ restart_policy=opts.restart_policy,
515
+ )
516
+ except Exception as error:
517
+ await self._invoke_lifecycle_hook(
518
+ opts.on_error,
519
+ {
520
+ **context,
521
+ "error": error,
522
+ },
523
+ f'spawn("{name}") on_error',
524
+ )
525
+ raise
526
+
527
+ agent = Agent(
528
+ name=result.get("name", name),
529
+ runtime=result.get("runtime", "pty"),
530
+ channels=channels,
531
+ relay=self,
532
+ )
533
+ self._known_agents[agent.name] = agent
534
+ self._reset_agent_lifecycle_state(agent.name)
535
+ await self._invoke_lifecycle_hook(
536
+ opts.on_success,
537
+ {
538
+ **context,
539
+ "name": agent.name,
540
+ "runtime": agent.runtime,
541
+ },
542
+ f'spawn("{name}") on_success',
543
+ )
544
+ return agent
545
+
546
+ async def spawn_and_wait(
547
+ self,
548
+ name: str,
549
+ cli: str,
550
+ task: str,
551
+ options: Optional[SpawnOptions] = None,
552
+ timeout_ms: int = 60_000,
553
+ wait_for_message: bool = False,
554
+ ) -> Agent:
555
+ agent = await self.spawn(name, cli, task, options)
556
+ if wait_for_message:
557
+ return await self.wait_for_agent_message(agent.name, timeout_ms)
558
+ return await self.wait_for_agent_ready(agent.name, timeout_ms)
559
+
560
+ # ── Human/system messaging ────────────────────────────────────────────
561
+
562
+ def human(self, name: str) -> HumanHandle:
563
+ return HumanHandle(name, self)
564
+
565
+ def system(self) -> HumanHandle:
566
+ return HumanHandle("system", self)
567
+
568
+ async def broadcast(self, text: str, *, from_name: str = "human:orchestrator") -> Message:
569
+ return await self.human(from_name).send_message(to="*", text=text)
570
+
571
+ # ── Listing / status ──────────────────────────────────────────────────
572
+
573
+ async def list_agents(self) -> list[Agent]:
574
+ client = await self._ensure_started()
575
+ raw_list = await client.list_agents()
576
+ agents = []
577
+ for entry in raw_list:
578
+ name = entry.get("name", "")
579
+ existing = self._known_agents.get(name)
580
+ if existing:
581
+ agents.append(existing)
582
+ else:
583
+ agent = Agent(
584
+ name=name,
585
+ runtime=entry.get("runtime", "pty"),
586
+ channels=entry.get("channels", []),
587
+ relay=self,
588
+ )
589
+ self._known_agents[name] = agent
590
+ agents.append(agent)
591
+ return agents
592
+
593
+ async def preflight_agents(self, agents: list[dict[str, str]]) -> None:
594
+ client = await self._ensure_started()
595
+ await client.preflight_agents(agents)
596
+
597
+ async def get_status(self) -> dict[str, Any]:
598
+ client = await self._ensure_started()
599
+ return await client.get_status()
600
+
601
+ # ── Wait helpers ──────────────────────────────────────────────────────
602
+
603
+ async def wait_for_agent_ready(self, name: str, timeout_ms: int = 60_000) -> Agent:
604
+ client = await self._ensure_started()
605
+ existing = self._known_agents.get(name)
606
+ if existing and name in self._ready_agents:
607
+ return existing
608
+
609
+ future: asyncio.Future[Agent] = asyncio.get_running_loop().create_future()
610
+
611
+ def on_event(event: BrokerEvent) -> None:
612
+ if event.get("kind") != "worker_ready" or event.get("name") != name:
613
+ return
614
+ agent = self._ensure_agent_handle(name, event.get("runtime", "pty"))
615
+ self._ready_agents.add(name)
616
+ self._exited_agents.discard(name)
617
+ if not future.done():
618
+ future.set_result(agent)
619
+
620
+ unsub = client.on_event(on_event)
621
+ try:
622
+ # Check again after subscribing (race condition guard)
623
+ known = self._known_agents.get(name)
624
+ if known and name in self._ready_agents:
625
+ return known
626
+ return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
627
+ except asyncio.TimeoutError:
628
+ raise TimeoutError(
629
+ f"Timed out waiting for worker_ready for '{name}' after {timeout_ms}ms"
630
+ ) from None
631
+ finally:
632
+ unsub()
633
+
634
+ async def wait_for_agent_message(self, name: str, timeout_ms: int = 60_000) -> Agent:
635
+ client = await self._ensure_started()
636
+ existing = self._known_agents.get(name)
637
+ if existing and name in self._message_ready_agents:
638
+ return existing
639
+
640
+ future: asyncio.Future[Agent] = asyncio.get_running_loop().create_future()
641
+
642
+ def on_event(event: BrokerEvent) -> None:
643
+ if future.done():
644
+ return
645
+ if event.get("kind") == "relay_inbound" and event.get("from") == name:
646
+ self._message_ready_agents.add(name)
647
+ self._exited_agents.discard(name)
648
+ future.set_result(self._ensure_agent_handle(name))
649
+ elif event.get("kind") == "agent_exited" and event.get("name") == name:
650
+ future.set_exception(
651
+ RuntimeError(f"Agent '{name}' exited before sending its first relay message")
652
+ )
653
+ elif event.get("kind") == "agent_released" and event.get("name") == name:
654
+ future.set_exception(
655
+ RuntimeError(f"Agent '{name}' was released before sending its first relay message")
656
+ )
657
+
658
+ unsub = client.on_event(on_event)
659
+ try:
660
+ known = self._known_agents.get(name)
661
+ if known and name in self._message_ready_agents:
662
+ return known
663
+ return await asyncio.wait_for(future, timeout=timeout_ms / 1000)
664
+ except asyncio.TimeoutError:
665
+ raise TimeoutError(
666
+ f"Timed out waiting for first relay message from '{name}' after {timeout_ms}ms"
667
+ ) from None
668
+ finally:
669
+ unsub()
670
+
671
+ @staticmethod
672
+ async def wait_for_any(
673
+ agents: list[Agent], timeout_ms: Optional[int] = None
674
+ ) -> tuple[Agent, str]:
675
+ """Wait for any agent to exit. Returns (agent, result) tuple."""
676
+ if not agents:
677
+ raise ValueError("wait_for_any requires at least one agent")
678
+
679
+ async def _wait(agent: Agent) -> tuple[Agent, str]:
680
+ result = await agent.wait_for_exit(timeout_ms)
681
+ return (agent, result)
682
+
683
+ done, pending = await asyncio.wait(
684
+ [asyncio.create_task(_wait(a)) for a in agents],
685
+ return_when=asyncio.FIRST_COMPLETED,
686
+ )
687
+ for task in pending:
688
+ task.cancel()
689
+ return done.pop().result()
690
+
691
+ # ── Lifecycle ─────────────────────────────────────────────────────────
692
+
693
+ async def shutdown(self) -> None:
694
+ if self._unsubscribe_event:
695
+ self._unsubscribe_event()
696
+ self._unsubscribe_event = None
697
+ if self._client:
698
+ await self._client.shutdown()
699
+ self._client = None
700
+
701
+ self._known_agents.clear()
702
+ self._ready_agents.clear()
703
+ self._message_ready_agents.clear()
704
+ self._exited_agents.clear()
705
+ self._idle_agents.clear()
706
+ self._output_listeners.clear()
707
+
708
+ for futures in self._exit_resolvers.values():
709
+ for future in futures:
710
+ if not future.done():
711
+ future.set_result("released")
712
+ self._exit_resolvers.clear()
713
+ for futures in self._idle_resolvers.values():
714
+ for future in futures:
715
+ if not future.done():
716
+ future.set_result("exited")
717
+ self._idle_resolvers.clear()
718
+
719
+ # ── Private helpers ───────────────────────────────────────────────────
720
+
721
+ async def _invoke_lifecycle_hook(
722
+ self,
723
+ hook: LifecycleHook,
724
+ context: dict[str, Any],
725
+ label: str,
726
+ ) -> None:
727
+ if hook is None:
728
+ return
729
+ try:
730
+ result = hook(context)
731
+ if inspect.isawaitable(result):
732
+ await result
733
+ except Exception as error:
734
+ print(f"[AgentRelay] {label} hook threw: {error}")
735
+
736
+ def _reset_agent_lifecycle_state(self, name: str) -> None:
737
+ self._ready_agents.discard(name)
738
+ self._message_ready_agents.discard(name)
739
+ self._exited_agents.discard(name)
740
+ self._idle_agents.discard(name)
741
+
742
+ def _ensure_agent_handle(
743
+ self, name: str, runtime: AgentRuntime = "pty", channels: Optional[list[str]] = None,
744
+ ) -> Agent:
745
+ existing = self._known_agents.get(name)
746
+ if existing:
747
+ return existing
748
+ agent = Agent(name, runtime, channels or [], self)
749
+ self._known_agents[name] = agent
750
+ return agent
751
+
752
+ def _wire_events(self, client: AgentRelayClient) -> None:
753
+ def on_event(event: BrokerEvent) -> None:
754
+ kind = event.get("kind")
755
+ name = event.get("name", "")
756
+
757
+ if kind == "relay_inbound":
758
+ from_name = event.get("from", "")
759
+ if from_name in self._known_agents:
760
+ self._message_ready_agents.add(from_name)
761
+ self._exited_agents.discard(from_name)
762
+ msg = Message(
763
+ event_id=event.get("event_id", ""),
764
+ from_name=event.get("from", ""),
765
+ to=event.get("target", ""),
766
+ text=event.get("body", ""),
767
+ thread_id=event.get("thread_id"),
768
+ )
769
+ if self.on_message_received:
770
+ self.on_message_received(msg)
771
+
772
+ elif kind == "agent_spawned":
773
+ agent = self._ensure_agent_handle(name, event.get("runtime", "pty"))
774
+ self._ready_agents.discard(name)
775
+ self._message_ready_agents.discard(name)
776
+ self._exited_agents.discard(name)
777
+ self._idle_agents.discard(name)
778
+ if self.on_agent_spawned:
779
+ self.on_agent_spawned(agent)
780
+
781
+ elif kind == "agent_released":
782
+ agent = self._known_agents.get(name) or self._ensure_agent_handle(name)
783
+ self._exited_agents.add(name)
784
+ self._ready_agents.discard(name)
785
+ self._message_ready_agents.discard(name)
786
+ self._idle_agents.discard(name)
787
+ if self.on_agent_released:
788
+ self.on_agent_released(agent)
789
+ self._known_agents.pop(name, None)
790
+ self._output_listeners.pop(name, None)
791
+ for future in self._exit_resolvers.pop(name, []):
792
+ if not future.done():
793
+ future.set_result("released")
794
+ for future in self._idle_resolvers.pop(name, []):
795
+ if not future.done():
796
+ future.set_result("exited")
797
+
798
+ elif kind == "agent_exited":
799
+ agent = self._known_agents.get(name) or self._ensure_agent_handle(name)
800
+ self._exited_agents.add(name)
801
+ self._ready_agents.discard(name)
802
+ self._message_ready_agents.discard(name)
803
+ self._idle_agents.discard(name)
804
+ agent.exit_code = event.get("code")
805
+ agent.exit_signal = event.get("signal")
806
+ if self.on_agent_exited:
807
+ self.on_agent_exited(agent)
808
+ self._known_agents.pop(name, None)
809
+ self._output_listeners.pop(name, None)
810
+ for future in self._exit_resolvers.pop(name, []):
811
+ if not future.done():
812
+ future.set_result("exited")
813
+ for future in self._idle_resolvers.pop(name, []):
814
+ if not future.done():
815
+ future.set_result("exited")
816
+
817
+ elif kind == "agent_exit":
818
+ agent = self._known_agents.get(name) or self._ensure_agent_handle(name)
819
+ agent.exit_reason = event.get("reason", "")
820
+ if self.on_agent_exit_requested:
821
+ self.on_agent_exit_requested({"name": name, "reason": event.get("reason", "")})
822
+
823
+ elif kind == "worker_ready":
824
+ agent = self._ensure_agent_handle(name, event.get("runtime", "pty"))
825
+ self._ready_agents.add(name)
826
+ self._exited_agents.discard(name)
827
+ self._idle_agents.discard(name)
828
+ if self.on_agent_ready:
829
+ self.on_agent_ready(agent)
830
+
831
+ elif kind == "worker_stream":
832
+ self._idle_agents.discard(name)
833
+ if self.on_worker_output:
834
+ self.on_worker_output({
835
+ "name": name,
836
+ "stream": event.get("stream", ""),
837
+ "chunk": event.get("chunk", ""),
838
+ })
839
+ # Per-agent output listeners
840
+ listeners = self._output_listeners.get(name, [])
841
+ for listener in listeners:
842
+ listener(event.get("chunk", ""))
843
+
844
+ elif kind == "agent_idle":
845
+ self._idle_agents.add(name)
846
+ if self.on_agent_idle:
847
+ self.on_agent_idle({
848
+ "name": name,
849
+ "idle_secs": event.get("idle_secs", 0),
850
+ })
851
+ for future in self._idle_resolvers.pop(name, []):
852
+ if not future.done():
853
+ future.set_result("idle")
854
+
855
+ # Delivery events
856
+ if kind and kind.startswith("delivery_"):
857
+ if self.on_delivery_update:
858
+ self.on_delivery_update(event)
859
+
860
+ self._unsubscribe_event = client.on_event(on_event)