agent-relay 3.1.0 → 3.1.2

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 (159) hide show
  1. package/bin/agent-relay-broker-linux-x64 +0 -0
  2. package/package.json +9 -9
  3. package/packages/acp-bridge/package.json +2 -2
  4. package/packages/config/package.json +1 -1
  5. package/packages/hooks/package.json +4 -4
  6. package/packages/memory/package.json +2 -2
  7. package/packages/openclaw/README.md +78 -0
  8. package/packages/openclaw/bin/relay-openclaw.mjs +2 -0
  9. package/packages/openclaw/bridge/bridge.mjs +305 -0
  10. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts +2 -0
  11. package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts.map +1 -0
  12. package/packages/openclaw/dist/__tests__/gateway-control.test.js +250 -0
  13. package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -0
  14. package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts +2 -0
  15. package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts.map +1 -0
  16. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +937 -0
  17. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -0
  18. package/packages/openclaw/dist/__tests__/naming.test.d.ts +2 -0
  19. package/packages/openclaw/dist/__tests__/naming.test.d.ts.map +1 -0
  20. package/packages/openclaw/dist/__tests__/naming.test.js +21 -0
  21. package/packages/openclaw/dist/__tests__/naming.test.js.map +1 -0
  22. package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts +2 -0
  23. package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts.map +1 -0
  24. package/packages/openclaw/dist/__tests__/spawn-manager.test.js +155 -0
  25. package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -0
  26. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts +2 -0
  27. package/packages/openclaw/dist/__tests__/ws-client.test.d.ts.map +1 -0
  28. package/packages/openclaw/dist/__tests__/ws-client.test.js +324 -0
  29. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -0
  30. package/packages/openclaw/dist/auth/converter.d.ts +28 -0
  31. package/packages/openclaw/dist/auth/converter.d.ts.map +1 -0
  32. package/packages/openclaw/dist/auth/converter.js +64 -0
  33. package/packages/openclaw/dist/auth/converter.js.map +1 -0
  34. package/packages/openclaw/dist/cli.d.ts +2 -0
  35. package/packages/openclaw/dist/cli.d.ts.map +1 -0
  36. package/packages/openclaw/dist/cli.js +230 -0
  37. package/packages/openclaw/dist/cli.js.map +1 -0
  38. package/packages/openclaw/dist/config.d.ts +27 -0
  39. package/packages/openclaw/dist/config.d.ts.map +1 -0
  40. package/packages/openclaw/dist/config.js +97 -0
  41. package/packages/openclaw/dist/config.js.map +1 -0
  42. package/packages/openclaw/dist/control.d.ts +22 -0
  43. package/packages/openclaw/dist/control.d.ts.map +1 -0
  44. package/packages/openclaw/dist/control.js +58 -0
  45. package/packages/openclaw/dist/control.js.map +1 -0
  46. package/packages/openclaw/dist/gateway.d.ts +97 -0
  47. package/packages/openclaw/dist/gateway.d.ts.map +1 -0
  48. package/packages/openclaw/dist/gateway.js +836 -0
  49. package/packages/openclaw/dist/gateway.js.map +1 -0
  50. package/packages/openclaw/dist/identity/contract.d.ts +11 -0
  51. package/packages/openclaw/dist/identity/contract.d.ts.map +1 -0
  52. package/packages/openclaw/dist/identity/contract.js +40 -0
  53. package/packages/openclaw/dist/identity/contract.js.map +1 -0
  54. package/packages/openclaw/dist/identity/files.d.ts +33 -0
  55. package/packages/openclaw/dist/identity/files.d.ts.map +1 -0
  56. package/packages/openclaw/dist/identity/files.js +145 -0
  57. package/packages/openclaw/dist/identity/files.js.map +1 -0
  58. package/packages/openclaw/dist/identity/model.d.ts +11 -0
  59. package/packages/openclaw/dist/identity/model.d.ts.map +1 -0
  60. package/packages/openclaw/dist/identity/model.js +28 -0
  61. package/packages/openclaw/dist/identity/model.js.map +1 -0
  62. package/packages/openclaw/dist/identity/naming.d.ts +5 -0
  63. package/packages/openclaw/dist/identity/naming.d.ts.map +1 -0
  64. package/packages/openclaw/dist/identity/naming.js +7 -0
  65. package/packages/openclaw/dist/identity/naming.js.map +1 -0
  66. package/packages/openclaw/dist/index.d.ts +20 -0
  67. package/packages/openclaw/dist/index.d.ts.map +1 -0
  68. package/packages/openclaw/dist/index.js +27 -0
  69. package/packages/openclaw/dist/index.js.map +1 -0
  70. package/packages/openclaw/dist/inject.d.ts +14 -0
  71. package/packages/openclaw/dist/inject.d.ts.map +1 -0
  72. package/packages/openclaw/dist/inject.js +66 -0
  73. package/packages/openclaw/dist/inject.js.map +1 -0
  74. package/packages/openclaw/dist/mcp/server.d.ts +8 -0
  75. package/packages/openclaw/dist/mcp/server.d.ts.map +1 -0
  76. package/packages/openclaw/dist/mcp/server.js +105 -0
  77. package/packages/openclaw/dist/mcp/server.js.map +1 -0
  78. package/packages/openclaw/dist/mcp/tools.d.ts +17 -0
  79. package/packages/openclaw/dist/mcp/tools.d.ts.map +1 -0
  80. package/packages/openclaw/dist/mcp/tools.js +145 -0
  81. package/packages/openclaw/dist/mcp/tools.js.map +1 -0
  82. package/packages/openclaw/dist/runtime/openclaw-config.d.ts +20 -0
  83. package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -0
  84. package/packages/openclaw/dist/runtime/openclaw-config.js +50 -0
  85. package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -0
  86. package/packages/openclaw/dist/runtime/patch.d.ts +24 -0
  87. package/packages/openclaw/dist/runtime/patch.d.ts.map +1 -0
  88. package/packages/openclaw/dist/runtime/patch.js +92 -0
  89. package/packages/openclaw/dist/runtime/patch.js.map +1 -0
  90. package/packages/openclaw/dist/runtime/setup.d.ts +26 -0
  91. package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -0
  92. package/packages/openclaw/dist/runtime/setup.js +58 -0
  93. package/packages/openclaw/dist/runtime/setup.js.map +1 -0
  94. package/packages/openclaw/dist/setup.d.ts +29 -0
  95. package/packages/openclaw/dist/setup.d.ts.map +1 -0
  96. package/packages/openclaw/dist/setup.js +300 -0
  97. package/packages/openclaw/dist/setup.js.map +1 -0
  98. package/packages/openclaw/dist/spawn/docker.d.ts +58 -0
  99. package/packages/openclaw/dist/spawn/docker.d.ts.map +1 -0
  100. package/packages/openclaw/dist/spawn/docker.js +222 -0
  101. package/packages/openclaw/dist/spawn/docker.js.map +1 -0
  102. package/packages/openclaw/dist/spawn/manager.d.ts +45 -0
  103. package/packages/openclaw/dist/spawn/manager.d.ts.map +1 -0
  104. package/packages/openclaw/dist/spawn/manager.js +140 -0
  105. package/packages/openclaw/dist/spawn/manager.js.map +1 -0
  106. package/packages/openclaw/dist/spawn/process.d.ts +16 -0
  107. package/packages/openclaw/dist/spawn/process.d.ts.map +1 -0
  108. package/packages/openclaw/dist/spawn/process.js +241 -0
  109. package/packages/openclaw/dist/spawn/process.js.map +1 -0
  110. package/packages/openclaw/dist/spawn/types.d.ts +42 -0
  111. package/packages/openclaw/dist/spawn/types.d.ts.map +1 -0
  112. package/packages/openclaw/dist/spawn/types.js +2 -0
  113. package/packages/openclaw/dist/spawn/types.js.map +1 -0
  114. package/packages/openclaw/dist/types.d.ts +41 -0
  115. package/packages/openclaw/dist/types.d.ts.map +1 -0
  116. package/packages/openclaw/dist/types.js +2 -0
  117. package/packages/openclaw/dist/types.js.map +1 -0
  118. package/packages/openclaw/package.json +63 -0
  119. package/packages/openclaw/skill/SKILL.md +216 -0
  120. package/packages/openclaw/src/__tests__/SPEC-ws-client-testing.md +192 -0
  121. package/packages/openclaw/src/__tests__/gateway-control.test.ts +288 -0
  122. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +1130 -0
  123. package/packages/openclaw/src/__tests__/naming.test.ts +24 -0
  124. package/packages/openclaw/src/__tests__/spawn-manager.test.ts +189 -0
  125. package/packages/openclaw/src/__tests__/ws-client.test.ts +395 -0
  126. package/packages/openclaw/src/auth/converter.ts +90 -0
  127. package/packages/openclaw/src/cli.ts +269 -0
  128. package/packages/openclaw/src/config.ts +124 -0
  129. package/packages/openclaw/src/control.ts +100 -0
  130. package/packages/openclaw/src/gateway.ts +1014 -0
  131. package/packages/openclaw/src/identity/contract.ts +44 -0
  132. package/packages/openclaw/src/identity/files.ts +198 -0
  133. package/packages/openclaw/src/identity/model.ts +27 -0
  134. package/packages/openclaw/src/identity/naming.ts +6 -0
  135. package/packages/openclaw/src/index.ts +59 -0
  136. package/packages/openclaw/src/inject.ts +77 -0
  137. package/packages/openclaw/src/mcp/server.ts +121 -0
  138. package/packages/openclaw/src/mcp/tools.ts +174 -0
  139. package/packages/openclaw/src/runtime/openclaw-config.ts +64 -0
  140. package/packages/openclaw/src/runtime/patch.ts +103 -0
  141. package/packages/openclaw/src/runtime/setup.ts +89 -0
  142. package/packages/openclaw/src/setup.ts +336 -0
  143. package/packages/openclaw/src/spawn/docker.ts +261 -0
  144. package/packages/openclaw/src/spawn/manager.ts +181 -0
  145. package/packages/openclaw/src/spawn/process.ts +272 -0
  146. package/packages/openclaw/src/spawn/types.ts +43 -0
  147. package/packages/openclaw/src/types.ts +42 -0
  148. package/packages/openclaw/templates/SOUL.md.template +34 -0
  149. package/packages/openclaw/tsconfig.json +12 -0
  150. package/packages/policy/package.json +2 -2
  151. package/packages/sdk/package.json +2 -2
  152. package/packages/sdk-py/pyproject.toml +1 -1
  153. package/packages/telemetry/package.json +1 -1
  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/bin/agent-relay-broker-darwin-arm64 +0 -0
  158. package/bin/agent-relay-broker-darwin-x64 +0 -0
  159. package/bin/agent-relay-broker-linux-arm64 +0 -0
@@ -0,0 +1,1014 @@
1
+ import { createHash, generateKeyPairSync, sign, type KeyObject } from 'node:crypto';
2
+ import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
3
+
4
+ import type { SendMessageInput } from '@agent-relay/sdk';
5
+ import { RelayCast, type AgentClient } from '@relaycast/sdk';
6
+ import type {
7
+ MessageCreatedEvent,
8
+ ThreadReplyEvent,
9
+ DmReceivedEvent,
10
+ GroupDmReceivedEvent,
11
+ CommandInvokedEvent,
12
+ ReactionAddedEvent,
13
+ ReactionRemovedEvent,
14
+ } from '@relaycast/sdk';
15
+ import WebSocket from 'ws';
16
+
17
+ import type { GatewayConfig, InboundMessage, DeliveryResult } from './types.js';
18
+ import { SpawnManager } from './spawn/manager.js';
19
+ import type { SpawnOptions } from './spawn/types.js';
20
+
21
+ /**
22
+ * A minimal interface for sending messages via Agent Relay.
23
+ * Accepts either AgentRelayClient or AgentRelay — any object with a
24
+ * compatible sendMessage() method.
25
+ */
26
+ export interface RelaySender {
27
+ sendMessage(input: SendMessageInput): Promise<{ event_id: string; targets?: string[] }>;
28
+ }
29
+
30
+ export interface GatewayOptions {
31
+ /** Gateway configuration. */
32
+ config: GatewayConfig;
33
+ /**
34
+ * Pre-existing relay sender for message delivery.
35
+ * Pass the API server's AgentRelay instance so all gateways share a single
36
+ * broker process instead of each spawning their own.
37
+ */
38
+ relaySender?: RelaySender;
39
+ }
40
+
41
+ function normalizeChannelName(channel: string): string {
42
+ return channel.startsWith('#') ? channel.slice(1) : channel;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Ed25519 device identity for OpenClaw gateway WebSocket auth
47
+ // ---------------------------------------------------------------------------
48
+
49
+ interface DeviceIdentity {
50
+ publicKeyB64: string; // base64url-encoded raw Ed25519 public key
51
+ privateKeyObj: KeyObject; // Node.js KeyObject for signing
52
+ deviceId: string; // SHA-256 hex of the raw public key
53
+ }
54
+
55
+ function generateDeviceIdentity(): DeviceIdentity {
56
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
57
+
58
+ // Extract raw 32-byte public key from SPKI DER (12-byte header for Ed25519)
59
+ const rawPublicBytes = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
60
+
61
+ const deviceId = createHash('sha256').update(rawPublicBytes).digest('hex');
62
+ const publicKeyB64 = Buffer.from(rawPublicBytes).toString('base64url');
63
+
64
+ return {
65
+ publicKeyB64,
66
+ privateKeyObj: privateKey,
67
+ deviceId,
68
+ };
69
+ }
70
+
71
+ function signConnectPayload(
72
+ device: DeviceIdentity,
73
+ params: {
74
+ clientId: string;
75
+ clientMode: string;
76
+ platform: string;
77
+ deviceFamily: string;
78
+ role: string;
79
+ scopes: string[];
80
+ signedAt: number;
81
+ token: string;
82
+ nonce: string;
83
+ },
84
+ ): string {
85
+ // v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
86
+ const payload = [
87
+ 'v3',
88
+ device.deviceId,
89
+ params.clientId,
90
+ params.clientMode,
91
+ params.role,
92
+ params.scopes.join(','),
93
+ String(params.signedAt),
94
+ params.token || '',
95
+ params.nonce,
96
+ params.platform,
97
+ params.deviceFamily,
98
+ ].join('|');
99
+
100
+ const payloadBytes = Buffer.from(payload, 'utf-8');
101
+
102
+ // Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519
103
+ const signature = sign(null, payloadBytes, device.privateKeyObj);
104
+ return Buffer.from(signature).toString('base64url');
105
+ }
106
+
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Persistent OpenClaw Gateway WebSocket client
110
+ // ---------------------------------------------------------------------------
111
+
112
+ interface PendingRpc {
113
+ resolve: (value: boolean) => void;
114
+ timer: ReturnType<typeof setTimeout>;
115
+ }
116
+
117
+ /** @internal */
118
+ export class OpenClawGatewayClient {
119
+ private ws: WebSocket | null = null;
120
+ private authenticated = false;
121
+ private device: DeviceIdentity;
122
+ private token: string;
123
+ private port: number;
124
+ private pendingRpcs = new Map<string, PendingRpc>();
125
+ private rpcIdCounter = 0;
126
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
127
+ private stopped = false;
128
+ private connectPromise: Promise<void> | null = null;
129
+ private connectResolve: (() => void) | null = null;
130
+ private connectReject: ((error: Error) => void) | null = null;
131
+ private connectTimeout: ReturnType<typeof setTimeout> | null = null;
132
+
133
+ /** Default timeout for initial connection (30 seconds). */
134
+ private static readonly CONNECT_TIMEOUT_MS = 30_000;
135
+
136
+ constructor(token: string, port: number) {
137
+ this.token = token;
138
+ this.port = port;
139
+ this.device = generateDeviceIdentity();
140
+ }
141
+
142
+ /** Connect and authenticate. Resolves when chat.send is ready, rejects on timeout or error. */
143
+ async connect(): Promise<void> {
144
+ if (this.authenticated && this.ws?.readyState === WebSocket.OPEN) return;
145
+
146
+ // Cancel any pending reconnect timer to prevent orphaned WebSocket connections
147
+ if (this.reconnectTimer) {
148
+ clearTimeout(this.reconnectTimer);
149
+ this.reconnectTimer = null;
150
+ }
151
+
152
+ this.connectPromise = new Promise<void>((resolve, reject) => {
153
+ this.connectResolve = resolve;
154
+ this.connectReject = reject;
155
+
156
+ // Set up timeout to prevent indefinite hanging
157
+ this.connectTimeout = setTimeout(() => {
158
+ this.connectTimeout = null;
159
+ if (!this.authenticated) {
160
+ const err = new Error(`Connection to OpenClaw gateway timed out after ${OpenClawGatewayClient.CONNECT_TIMEOUT_MS}ms`);
161
+ this.connectReject?.(err);
162
+ this.connectReject = null;
163
+ this.connectResolve = null;
164
+ }
165
+ }, OpenClawGatewayClient.CONNECT_TIMEOUT_MS);
166
+ });
167
+
168
+ this.doConnect();
169
+ return this.connectPromise;
170
+ }
171
+
172
+ private clearConnectTimeout(): void {
173
+ if (this.connectTimeout) {
174
+ clearTimeout(this.connectTimeout);
175
+ this.connectTimeout = null;
176
+ }
177
+ }
178
+
179
+ private doConnect(): void {
180
+ if (this.stopped) return;
181
+
182
+ try {
183
+ this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
184
+ } catch (err) {
185
+ console.warn(`[openclaw-ws] Connection failed: ${err instanceof Error ? err.message : String(err)}`);
186
+ this.scheduleReconnect();
187
+ return;
188
+ }
189
+
190
+ this.ws.on('open', () => {
191
+ console.log('[openclaw-ws] Connected to OpenClaw gateway');
192
+ });
193
+
194
+ this.ws.on('message', (data) => {
195
+ this.handleMessage(data.toString());
196
+ });
197
+
198
+ this.ws.on('close', (code, reason) => {
199
+ console.warn(`[openclaw-ws] Disconnected: ${code} ${reason.toString()}`);
200
+ const wasAuthenticated = this.authenticated;
201
+ this.authenticated = false;
202
+ // Reject all pending RPCs
203
+ for (const [id, pending] of this.pendingRpcs) {
204
+ clearTimeout(pending.timer);
205
+ pending.resolve(false);
206
+ this.pendingRpcs.delete(id);
207
+ }
208
+ // If we weren't authenticated yet, reject the connect promise
209
+ if (!wasAuthenticated && this.connectReject) {
210
+ this.clearConnectTimeout();
211
+ const err = new Error(`WebSocket closed before authentication (code=${code})`);
212
+ this.connectReject(err);
213
+ this.connectReject = null;
214
+ this.connectResolve = null;
215
+ }
216
+ if (!this.stopped) {
217
+ this.scheduleReconnect();
218
+ }
219
+ });
220
+
221
+ this.ws.on('error', (err) => {
222
+ console.warn(`[openclaw-ws] Error: ${err.message}`);
223
+ // If we weren't authenticated yet, reject the connect promise
224
+ if (!this.authenticated && this.connectReject) {
225
+ this.clearConnectTimeout();
226
+ this.connectReject(err);
227
+ this.connectReject = null;
228
+ this.connectResolve = null;
229
+ }
230
+ });
231
+ }
232
+
233
+ private handleMessage(raw: string): void {
234
+ let msg: Record<string, unknown>;
235
+ try {
236
+ msg = JSON.parse(raw);
237
+ } catch {
238
+ return;
239
+ }
240
+
241
+ // Handle connect.challenge — sign and respond
242
+ if (msg.type === 'event' && msg.event === 'connect.challenge') {
243
+ const payload = msg.payload as { nonce: string; ts: number };
244
+ console.log('[openclaw-ws] Received connect.challenge, signing...');
245
+
246
+ const signedAt = Date.now();
247
+ const clientId = 'cli';
248
+ const clientMode = 'cli';
249
+ const platform = process.platform === 'darwin' ? 'macos' : 'linux';
250
+ const deviceFamily = 'cli';
251
+ const role = 'operator';
252
+ const scopes = ['operator.read', 'operator.write'];
253
+
254
+ const signature = signConnectPayload(this.device, {
255
+ clientId,
256
+ clientMode,
257
+ platform,
258
+ deviceFamily,
259
+ role,
260
+ scopes,
261
+ signedAt,
262
+ token: this.token,
263
+ nonce: payload.nonce,
264
+ });
265
+
266
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
267
+ console.warn('[openclaw-ws] WebSocket not open when trying to send connect');
268
+ return;
269
+ }
270
+ this.ws.send(JSON.stringify({
271
+ type: 'req',
272
+ id: 'connect-1',
273
+ method: 'connect',
274
+ params: {
275
+ minProtocol: 3,
276
+ maxProtocol: 3,
277
+ client: {
278
+ id: clientId,
279
+ version: '1.0.0',
280
+ platform,
281
+ mode: clientMode,
282
+ deviceFamily,
283
+ },
284
+ role,
285
+ scopes,
286
+ caps: [],
287
+ commands: [],
288
+ permissions: {},
289
+ auth: { token: this.token },
290
+ locale: 'en-US',
291
+ userAgent: 'relaycast-gateway/1.0.0',
292
+ device: {
293
+ id: this.device.deviceId,
294
+ publicKey: this.device.publicKeyB64,
295
+ signature,
296
+ signedAt,
297
+ nonce: payload.nonce,
298
+ },
299
+ },
300
+ }));
301
+ return;
302
+ }
303
+
304
+ // Handle connect response
305
+ if (msg.type === 'res' && msg.id === 'connect-1') {
306
+ this.clearConnectTimeout();
307
+ if (msg.ok) {
308
+ console.log('[openclaw-ws] Authenticated successfully');
309
+ this.authenticated = true;
310
+ this.connectResolve?.();
311
+ this.connectResolve = null;
312
+ this.connectReject = null;
313
+ } else {
314
+ console.warn(`[openclaw-ws] Auth rejected: ${JSON.stringify(msg.error ?? msg)}`);
315
+ // Reject the connect promise on auth failure
316
+ const errMsg = msg.error ? JSON.stringify(msg.error) : 'Authentication rejected';
317
+ this.connectReject?.(new Error(`OpenClaw gateway auth failed: ${errMsg}`));
318
+ this.connectReject = null;
319
+ this.connectResolve = null;
320
+ }
321
+ return;
322
+ }
323
+
324
+ // Handle RPC responses
325
+ const id = msg.id as string | undefined;
326
+ if (id && this.pendingRpcs.has(id)) {
327
+ const pending = this.pendingRpcs.get(id)!;
328
+ clearTimeout(pending.timer);
329
+ this.pendingRpcs.delete(id);
330
+
331
+ if (msg.ok === false || msg.error) {
332
+ console.warn(`[openclaw-ws] RPC ${id} error: ${JSON.stringify(msg.error ?? msg)}`);
333
+ pending.resolve(false);
334
+ } else {
335
+ const result = msg.payload as Record<string, unknown> | undefined;
336
+ console.log(`[openclaw-ws] RPC ${id} ok: runId=${result?.runId ?? 'n/a'} status=${result?.status ?? 'n/a'}`);
337
+ pending.resolve(true);
338
+ }
339
+ return;
340
+ }
341
+
342
+ // Log other events at debug level
343
+ if (msg.type === 'event') {
344
+ // chat events, tick events, etc. — ignore silently
345
+ }
346
+ }
347
+
348
+ /** Send a chat.send RPC. Returns true if accepted. */
349
+ async sendChatMessage(text: string, idempotencyKey?: string): Promise<boolean> {
350
+ if (!this.authenticated || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
351
+ // Try to reconnect
352
+ try {
353
+ await this.connect();
354
+ } catch {
355
+ return false;
356
+ }
357
+ if (!this.authenticated) return false;
358
+ }
359
+
360
+ const id = `chat-${++this.rpcIdCounter}-${Date.now()}`;
361
+
362
+ return new Promise<boolean>((resolve) => {
363
+ const timer = setTimeout(() => {
364
+ console.warn(`[openclaw-ws] chat.send ${id} timed out`);
365
+ this.pendingRpcs.delete(id);
366
+ resolve(false);
367
+ }, 15_000);
368
+
369
+ this.pendingRpcs.set(id, { resolve, timer });
370
+
371
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
372
+ clearTimeout(timer);
373
+ this.pendingRpcs.delete(id);
374
+ resolve(false);
375
+ return;
376
+ }
377
+ this.ws.send(JSON.stringify({
378
+ type: 'req',
379
+ id,
380
+ method: 'chat.send',
381
+ params: {
382
+ sessionKey: 'agent:main:main',
383
+ message: text,
384
+ ...(idempotencyKey ? { idempotencyKey } : {}),
385
+ },
386
+ }));
387
+ });
388
+ }
389
+
390
+ private scheduleReconnect(): void {
391
+ if (this.stopped || this.reconnectTimer) return;
392
+ console.log('[openclaw-ws] Reconnecting in 3s...');
393
+ this.reconnectTimer = setTimeout(() => {
394
+ this.reconnectTimer = null;
395
+ this.doConnect();
396
+ }, 3_000);
397
+ }
398
+
399
+ async disconnect(): Promise<void> {
400
+ this.stopped = true;
401
+ this.clearConnectTimeout();
402
+ if (this.reconnectTimer) {
403
+ clearTimeout(this.reconnectTimer);
404
+ this.reconnectTimer = null;
405
+ }
406
+ for (const [id, pending] of this.pendingRpcs) {
407
+ clearTimeout(pending.timer);
408
+ pending.resolve(false);
409
+ this.pendingRpcs.delete(id);
410
+ }
411
+ if (this.ws) {
412
+ try { this.ws.close(); } catch {}
413
+ this.ws = null;
414
+ }
415
+ this.authenticated = false;
416
+ // Clear any pending connect promise
417
+ this.connectReject = null;
418
+ this.connectResolve = null;
419
+ }
420
+ }
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // InboundGateway
424
+ // ---------------------------------------------------------------------------
425
+
426
+ export class InboundGateway {
427
+ private readonly relaySender: RelaySender | null;
428
+ private relayAgentClient: AgentClient | null = null;
429
+ private readonly relaycast: RelayCast;
430
+ private readonly config: GatewayConfig;
431
+ private readonly dedupeTtlMs: number;
432
+
433
+ private running = false;
434
+ private unsubscribeHandlers: Array<() => void> = [];
435
+ private seenMessageIds = new Map<string, number>();
436
+ private processingMessageIds = new Set<string>();
437
+
438
+ /** Persistent WebSocket client for the local OpenClaw gateway. */
439
+ private openclawClient: OpenClawGatewayClient | null = null;
440
+
441
+ /** Spawn manager — lives in the gateway so spawned processes survive MCP server restarts. */
442
+ private spawnManager: SpawnManager;
443
+ /** HTTP control server for spawn/list/release commands. */
444
+ private controlServer: HttpServer | null = null;
445
+ /** Port the control server listens on. */
446
+ controlPort = 0;
447
+
448
+ /** Default control port for the gateway's spawn API. */
449
+ static readonly DEFAULT_CONTROL_PORT = 18790;
450
+
451
+ constructor(options: GatewayOptions) {
452
+ this.config = {
453
+ ...options.config,
454
+ channels: options.config.channels.map(normalizeChannelName),
455
+ };
456
+ this.relaySender = options.relaySender ?? null;
457
+ this.relaycast = new RelayCast({
458
+ apiKey: this.config.apiKey,
459
+ baseUrl: this.config.baseUrl,
460
+ });
461
+
462
+ const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000);
463
+ this.dedupeTtlMs = Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000
464
+ ? Math.floor(dedupeTtlMs)
465
+ : 15 * 60 * 1000;
466
+
467
+ const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0);
468
+ this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 });
469
+ }
470
+
471
+ /** Start the gateway — register agent and subscribe for realtime events. */
472
+ async start(): Promise<void> {
473
+ if (this.running) return;
474
+ this.running = true;
475
+
476
+ // Connect to the local OpenClaw gateway WebSocket (persistent connection)
477
+ const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
478
+ const port = this.config.openclawGatewayPort ?? 18789;
479
+
480
+ if (token) {
481
+ this.openclawClient = new OpenClawGatewayClient(token, port);
482
+ try {
483
+ await this.openclawClient.connect();
484
+ console.log('[gateway] OpenClaw gateway WebSocket client ready');
485
+ } catch (err) {
486
+ console.warn(`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`);
487
+ }
488
+ } else {
489
+ console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
490
+ }
491
+
492
+ // Register with a viewer- prefixed name so we don't collide with the
493
+ // container broker's agent registration (which uses the bare clawName).
494
+ const viewerName = `viewer-${this.config.clawName}`;
495
+ const registered = await this.relaycast.agents.registerOrGet({
496
+ name: viewerName,
497
+ type: 'agent',
498
+ persona: 'Relaycast inbound gateway for OpenClaw',
499
+ });
500
+
501
+ this.relayAgentClient = this.relaycast.as(registered.token);
502
+
503
+ // Connect first, then register handlers. The SDK requires connect()
504
+ // before subscribe() can be called.
505
+ this.relayAgentClient.connect();
506
+
507
+ this.unsubscribeHandlers.push(
508
+ this.relayAgentClient.on.connected(() => {
509
+ console.log(`[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}`);
510
+ this.relayAgentClient?.subscribe(this.config.channels);
511
+ }),
512
+ );
513
+ this.unsubscribeHandlers.push(
514
+ this.relayAgentClient.on.messageCreated((event: MessageCreatedEvent) => {
515
+ console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`);
516
+ void this.handleRealtimeMessage(event);
517
+ }),
518
+ );
519
+ this.unsubscribeHandlers.push(
520
+ this.relayAgentClient.on.threadReply((event: ThreadReplyEvent) => {
521
+ console.log(`[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})`);
522
+ void this.handleRealtimeThreadReply(event);
523
+ }),
524
+ );
525
+ this.unsubscribeHandlers.push(
526
+ this.relayAgentClient.on.dmReceived((event: DmReceivedEvent) => {
527
+ console.log(`[gateway] DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
528
+ void this.handleRealtimeDm(event);
529
+ }),
530
+ );
531
+ this.unsubscribeHandlers.push(
532
+ this.relayAgentClient.on.groupDmReceived((event: GroupDmReceivedEvent) => {
533
+ console.log(`[gateway] Group DM from @${event.message?.agentName} (conv: ${event.conversationId})`);
534
+ void this.handleRealtimeGroupDm(event);
535
+ }),
536
+ );
537
+ this.unsubscribeHandlers.push(
538
+ this.relayAgentClient.on.commandInvoked((event: CommandInvokedEvent) => {
539
+ console.log(`[gateway] Command /${event.command} invoked by @${event.invokedBy} in #${event.channel}`);
540
+ void this.handleRealtimeCommand(event);
541
+ }),
542
+ );
543
+ this.unsubscribeHandlers.push(
544
+ this.relayAgentClient.on.reactionAdded((event: ReactionAddedEvent) => {
545
+ console.log(`[gateway] Reaction :${event.emoji}: added by @${event.agentName} on ${event.messageId}`);
546
+ void this.handleRealtimeReaction(event, 'added');
547
+ }),
548
+ );
549
+ this.unsubscribeHandlers.push(
550
+ this.relayAgentClient.on.reactionRemoved((event: ReactionRemovedEvent) => {
551
+ console.log(`[gateway] Reaction :${event.emoji}: removed by @${event.agentName} from ${event.messageId}`);
552
+ void this.handleRealtimeReaction(event, 'removed');
553
+ }),
554
+ );
555
+ this.unsubscribeHandlers.push(
556
+ this.relayAgentClient.on.reconnecting((attempt: number) => {
557
+ console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`);
558
+ }),
559
+ );
560
+ this.unsubscribeHandlers.push(
561
+ this.relayAgentClient.on.disconnected(() => {
562
+ console.warn(`[gateway] Relaycast disconnected`);
563
+ }),
564
+ );
565
+ this.unsubscribeHandlers.push(
566
+ this.relayAgentClient.on.error(() => {
567
+ console.warn(`[gateway] Relaycast socket error`);
568
+ }),
569
+ );
570
+
571
+ await this.ensureChannelMembership();
572
+
573
+ // Also subscribe explicitly in case the `connected` event already fired
574
+ // before we registered the handler above.
575
+ try {
576
+ this.relayAgentClient.subscribe(this.config.channels);
577
+ } catch {
578
+ // Will subscribe on next connected event
579
+ }
580
+
581
+ console.log(
582
+ `[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`,
583
+ );
584
+
585
+ // Start spawn control HTTP server
586
+ await this.startControlServer();
587
+ }
588
+
589
+ /** Stop the gateway — clean up websocket and relay clients. */
590
+ async stop(): Promise<void> {
591
+ this.running = false;
592
+
593
+ for (const unsubscribe of this.unsubscribeHandlers) {
594
+ try {
595
+ unsubscribe();
596
+ } catch {
597
+ // Best effort
598
+ }
599
+ }
600
+ this.unsubscribeHandlers = [];
601
+
602
+ if (this.relayAgentClient) {
603
+ try {
604
+ await this.relayAgentClient.disconnect();
605
+ } catch {
606
+ // Best effort
607
+ }
608
+ this.relayAgentClient = null;
609
+ }
610
+
611
+ if (this.openclawClient) {
612
+ await this.openclawClient.disconnect();
613
+ this.openclawClient = null;
614
+ }
615
+
616
+ // Stop control server and release all spawns
617
+ if (this.controlServer) {
618
+ this.controlServer.close();
619
+ this.controlServer = null;
620
+ }
621
+ await this.spawnManager.releaseAll();
622
+
623
+ this.processingMessageIds.clear();
624
+ this.seenMessageIds.clear();
625
+ }
626
+
627
+ private cleanupSeenMap(nowMs: number): void {
628
+ for (const [id, seenAt] of this.seenMessageIds.entries()) {
629
+ if (nowMs - seenAt > this.dedupeTtlMs) {
630
+ this.seenMessageIds.delete(id);
631
+ }
632
+ }
633
+ }
634
+
635
+ private isSeen(messageId: string): boolean {
636
+ const nowMs = Date.now();
637
+ this.cleanupSeenMap(nowMs);
638
+ return this.seenMessageIds.has(messageId);
639
+ }
640
+
641
+ private markSeen(messageId: string): void {
642
+ const nowMs = Date.now();
643
+ this.cleanupSeenMap(nowMs);
644
+ this.seenMessageIds.set(messageId, nowMs);
645
+ }
646
+
647
+ private async ensureChannelMembership(): Promise<void> {
648
+ if (!this.relayAgentClient) return;
649
+
650
+ for (const channel of this.config.channels) {
651
+ try {
652
+ await this.relayAgentClient.channels.join(channel);
653
+ } catch {
654
+ try {
655
+ await this.relayAgentClient.channels.create({ name: channel });
656
+ await this.relayAgentClient.channels.join(channel);
657
+ } catch {
658
+ // Non-fatal
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ private async handleRealtimeMessage(event: MessageCreatedEvent): Promise<void> {
665
+ const channel = normalizeChannelName(event.channel);
666
+ if (!this.config.channels.includes(channel)) return;
667
+
668
+ const messageId = event.message?.id;
669
+ if (!messageId) return;
670
+
671
+ const inbound: InboundMessage = {
672
+ id: messageId,
673
+ channel,
674
+ from: event.message.agentName,
675
+ text: event.message.text,
676
+ timestamp: new Date().toISOString(),
677
+ };
678
+
679
+ await this.handleInbound(inbound);
680
+ }
681
+
682
+ private async handleRealtimeThreadReply(event: ThreadReplyEvent): Promise<void> {
683
+ const channel = normalizeChannelName(event.channel);
684
+ if (!this.config.channels.includes(channel)) return;
685
+
686
+ const messageId = event.message?.id;
687
+ if (!messageId) return;
688
+
689
+ const inbound: InboundMessage = {
690
+ id: messageId,
691
+ channel,
692
+ from: event.message.agentName,
693
+ text: event.message.text,
694
+ timestamp: new Date().toISOString(),
695
+ threadParentId: event.parentId,
696
+ };
697
+
698
+ await this.handleInbound(inbound);
699
+ }
700
+
701
+ private async handleRealtimeDm(event: DmReceivedEvent): Promise<void> {
702
+ const messageId = event.message?.id;
703
+ if (!messageId) return;
704
+
705
+ const inbound: InboundMessage = {
706
+ id: messageId,
707
+ channel: 'dm',
708
+ from: event.message.agentName,
709
+ text: event.message.text,
710
+ timestamp: new Date().toISOString(),
711
+ conversationId: event.conversationId,
712
+ kind: 'dm',
713
+ };
714
+
715
+ await this.handleInbound(inbound);
716
+ }
717
+
718
+ private async handleRealtimeGroupDm(event: GroupDmReceivedEvent): Promise<void> {
719
+ const messageId = event.message?.id;
720
+ if (!messageId) return;
721
+
722
+ const inbound: InboundMessage = {
723
+ id: messageId,
724
+ channel: `groupdm:${event.conversationId}`,
725
+ from: event.message.agentName,
726
+ text: event.message.text,
727
+ timestamp: new Date().toISOString(),
728
+ conversationId: event.conversationId,
729
+ kind: 'groupdm',
730
+ };
731
+
732
+ await this.handleInbound(inbound);
733
+ }
734
+
735
+ private async handleRealtimeCommand(event: CommandInvokedEvent): Promise<void> {
736
+ const channel = normalizeChannelName(event.channel);
737
+ if (!this.config.channels.includes(channel)) return;
738
+
739
+ // Commands lack a server-assigned event ID, so we synthesize one.
740
+ // We include args + timestamp to avoid silently dropping legitimate
741
+ // repeat invocations (e.g. /deploy twice in 15 min). This means SDK
742
+ // reconnection replays may deliver a duplicate, but that's less
743
+ // harmful than silently swallowing a real command.
744
+ const argsSlug = event.args ? `_${event.args}` : '';
745
+ const syntheticId = `cmd_${event.command}_${channel}_${event.invokedBy}${argsSlug}_${Date.now()}`;
746
+ const argsText = event.args ? ` ${event.args}` : '';
747
+
748
+ const inbound: InboundMessage = {
749
+ id: syntheticId,
750
+ channel,
751
+ from: event.invokedBy,
752
+ text: `[relaycast:command:${channel}] @${event.invokedBy} /${event.command}${argsText}`,
753
+ timestamp: new Date().toISOString(),
754
+ kind: 'command',
755
+ };
756
+
757
+ await this.handleInbound(inbound);
758
+ }
759
+
760
+ private async handleRealtimeReaction(
761
+ event: ReactionAddedEvent | ReactionRemovedEvent,
762
+ action: 'added' | 'removed',
763
+ ): Promise<void> {
764
+ // Include timestamp so add→remove→re-add of the same emoji isn't
765
+ // silently dropped within the 15-min dedup window. Reactions are soft
766
+ // notifications, so a rare duplicate on SDK reconnect is acceptable.
767
+ const syntheticId = `reaction_${event.messageId}_${event.emoji}_${event.agentName}_${action}_${Date.now()}`;
768
+ const text = action === 'added'
769
+ ? `[relaycast:reaction] @${event.agentName} reacted ${event.emoji} to message ${event.messageId} (soft notification, no action required)`
770
+ : `[relaycast:reaction] @${event.agentName} removed ${event.emoji} from message ${event.messageId} (soft notification, no action required)`;
771
+
772
+ const inbound: InboundMessage = {
773
+ id: syntheticId,
774
+ channel: 'reaction',
775
+ from: event.agentName,
776
+ text,
777
+ timestamp: new Date().toISOString(),
778
+ kind: 'reaction',
779
+ };
780
+
781
+ await this.handleInbound(inbound);
782
+ }
783
+
784
+ private async handleInbound(message: InboundMessage): Promise<void> {
785
+ if (!this.running) return;
786
+ if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) return;
787
+
788
+ // Avoid echo loops — skip messages from this claw or its viewer identity.
789
+ const viewerName = `viewer-${this.config.clawName}`;
790
+ if (message.from === this.config.clawName || message.from === viewerName) {
791
+ // Only update cursor for real channels with real (non-synthetic) message IDs.
792
+ this.markSeen(message.id);
793
+ return;
794
+ }
795
+
796
+ // Mark as seen immediately to prevent duplicate delivery from concurrent
797
+ // realtime events processing the same message.
798
+ this.markSeen(message.id);
799
+ this.processingMessageIds.add(message.id);
800
+
801
+ console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`);
802
+ try {
803
+ const result = await this.onMessage(message);
804
+ console.log(`[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`);
805
+ } finally {
806
+ this.processingMessageIds.delete(message.id);
807
+ }
808
+ }
809
+
810
+ /** Format delivery text with channel, sender, and optional thread prefix. */
811
+ private formatDeliveryText(message: InboundMessage): string {
812
+ // Pre-formatted kinds (command, reaction) already have the full text.
813
+ if (message.kind === 'command' || message.kind === 'reaction') {
814
+ return message.text;
815
+ }
816
+ if (message.kind === 'dm') {
817
+ return `[relaycast:dm] @${message.from}: ${message.text}`;
818
+ }
819
+ if (message.kind === 'groupdm') {
820
+ return `[relaycast:groupdm] @${message.from}: ${message.text}`;
821
+ }
822
+ const threadPrefix = message.threadParentId ? '[thread] ' : '';
823
+ return `${threadPrefix}[relaycast:${message.channel}] @${message.from}: ${message.text}`;
824
+ }
825
+
826
+ /** Handle an inbound Relaycast message. */
827
+ private async onMessage(message: InboundMessage): Promise<DeliveryResult> {
828
+ // Try primary delivery via the shared relay sender (no extra broker spawned).
829
+ if (this.relaySender) {
830
+ const ok = await this.deliverViaRelaySender(message);
831
+ if (ok) {
832
+ return { ok: true, method: 'relay_sdk' };
833
+ }
834
+ }
835
+
836
+ // Deliver via persistent OpenClaw gateway WebSocket connection
837
+ if (this.openclawClient) {
838
+ const text = this.formatDeliveryText(message);
839
+ const ok = await this.openclawClient.sendChatMessage(text, message.id);
840
+ if (ok) {
841
+ return { ok: true, method: 'gateway_ws' };
842
+ }
843
+ }
844
+
845
+ console.warn(
846
+ `[gateway] Failed to deliver message ${message.id} from @${message.from}`,
847
+ );
848
+ return { ok: false, method: 'failed', error: 'All delivery methods failed' };
849
+ }
850
+
851
+ /** Deliver via the caller-provided relay sender (shared broker). */
852
+ private async deliverViaRelaySender(message: InboundMessage): Promise<boolean> {
853
+ if (!this.relaySender) return false;
854
+
855
+ const input: SendMessageInput = {
856
+ to: this.config.clawName,
857
+ text: this.formatDeliveryText(message),
858
+ from: message.from,
859
+ data: {
860
+ source: 'relaycast',
861
+ channel: message.channel,
862
+ messageId: message.id,
863
+ },
864
+ };
865
+
866
+ try {
867
+ const result = await this.relaySender.sendMessage(input);
868
+ return Boolean(result.event_id) && result.event_id !== 'unsupported_operation';
869
+ } catch {
870
+ return false;
871
+ }
872
+ }
873
+
874
+ // -------------------------------------------------------------------------
875
+ // Spawn control HTTP server
876
+ // -------------------------------------------------------------------------
877
+
878
+ private async startControlServer(): Promise<void> {
879
+ const port = Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT;
880
+
881
+ this.controlServer = createServer((req, res) => {
882
+ void this.handleControlRequest(req, res);
883
+ });
884
+
885
+ return new Promise((resolve) => {
886
+ this.controlServer!.listen(port, '127.0.0.1', () => {
887
+ this.controlPort = port;
888
+ console.log(`[gateway] Spawn control API listening on http://127.0.0.1:${port}`);
889
+ resolve();
890
+ });
891
+ this.controlServer!.on('error', (err) => {
892
+ console.warn(`[gateway] Control server failed to start on port ${port}: ${err.message}`);
893
+ this.controlServer = null;
894
+ resolve(); // Non-fatal
895
+ });
896
+ });
897
+ }
898
+
899
+ private async handleControlRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
900
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
901
+ const path = url.pathname;
902
+
903
+ // CORS for local callers
904
+ res.setHeader('Content-Type', 'application/json');
905
+
906
+ if (req.method === 'GET' && path === '/health') {
907
+ res.writeHead(200);
908
+ res.end(JSON.stringify({
909
+ ok: true,
910
+ status: 'running',
911
+ active: this.spawnManager.size,
912
+ uptime: process.uptime(),
913
+ }));
914
+ return;
915
+ }
916
+
917
+ if (req.method === 'POST' && path === '/spawn') {
918
+ const body = await readBody(req);
919
+ try {
920
+ const args = JSON.parse(body) as Record<string, unknown>;
921
+ const name = args.name as string;
922
+ if (!name) {
923
+ res.writeHead(400);
924
+ res.end(JSON.stringify({ ok: false, error: '"name" is required' }));
925
+ return;
926
+ }
927
+
928
+ const relayApiKey = this.config.apiKey;
929
+ const spawnOpts: SpawnOptions = {
930
+ name,
931
+ relayApiKey,
932
+ role: (args.role as string) || undefined,
933
+ model: (args.model as string) || undefined,
934
+ channels: (args.channels as string[]) || undefined,
935
+ systemPrompt: (args.system_prompt as string) || undefined,
936
+ relayBaseUrl: this.config.baseUrl,
937
+ workspaceId: (args.workspace_id as string) || process.env.OPENCLAW_WORKSPACE_ID,
938
+ };
939
+
940
+ const handle = await this.spawnManager.spawn(spawnOpts);
941
+ res.writeHead(200);
942
+ res.end(JSON.stringify({
943
+ ok: true,
944
+ name: handle.displayName,
945
+ agentName: handle.agentName,
946
+ id: handle.id,
947
+ gatewayPort: handle.gatewayPort,
948
+ active: this.spawnManager.size,
949
+ }));
950
+ } catch (err) {
951
+ res.writeHead(500);
952
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
953
+ }
954
+ return;
955
+ }
956
+
957
+ if (req.method === 'GET' && path === '/list') {
958
+ const handles = this.spawnManager.list();
959
+ res.writeHead(200);
960
+ res.end(JSON.stringify({
961
+ ok: true,
962
+ active: handles.length,
963
+ claws: handles.map(h => ({
964
+ name: h.displayName,
965
+ agentName: h.agentName,
966
+ id: h.id,
967
+ gatewayPort: h.gatewayPort,
968
+ })),
969
+ }));
970
+ return;
971
+ }
972
+
973
+ if (req.method === 'POST' && path === '/release') {
974
+ const body = await readBody(req);
975
+ try {
976
+ const args = JSON.parse(body) as Record<string, unknown>;
977
+ const name = args.name as string | undefined;
978
+ const id = args.id as string | undefined;
979
+
980
+ if (!name && !id) {
981
+ res.writeHead(400);
982
+ res.end(JSON.stringify({ ok: false, error: 'Provide "name" or "id"' }));
983
+ return;
984
+ }
985
+
986
+ let released = false;
987
+ if (id) {
988
+ released = await this.spawnManager.release(id);
989
+ } else if (name) {
990
+ released = await this.spawnManager.releaseByName(name);
991
+ }
992
+
993
+ res.writeHead(200);
994
+ res.end(JSON.stringify({ ok: released, active: this.spawnManager.size }));
995
+ } catch (err) {
996
+ res.writeHead(500);
997
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
998
+ }
999
+ return;
1000
+ }
1001
+
1002
+ res.writeHead(404);
1003
+ res.end(JSON.stringify({ error: 'Not found' }));
1004
+ }
1005
+ }
1006
+
1007
+ function readBody(req: IncomingMessage): Promise<string> {
1008
+ return new Promise((resolve, reject) => {
1009
+ const chunks: Buffer[] = [];
1010
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
1011
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
1012
+ req.on('error', (err) => reject(err));
1013
+ });
1014
+ }