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,354 +1,335 @@
1
- import { once } from 'node:events';
2
- import { execSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
3
- import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
4
- import fs from 'node:fs';
5
- import os from 'node:os';
1
+ /**
2
+ * AgentRelayClient single client for communicating with an agent-relay broker
3
+ * over HTTP/WS. Works identically for local and remote brokers.
4
+ *
5
+ * Usage:
6
+ * // Remote broker (Daytona sandbox, cloud, etc.)
7
+ * const client = new AgentRelayClient({ baseUrl, apiKey });
8
+ *
9
+ * // Local broker (spawn and connect)
10
+ * const client = await AgentRelayClient.spawn({ cwd: '/my/project' });
11
+ */
12
+
13
+ import { spawn, type ChildProcess } from 'node:child_process';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { readFileSync, existsSync } from 'node:fs';
6
16
  import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- import { getProjectPaths } from '@agent-relay/config';
10
-
11
- import {
12
- PROTOCOL_VERSION,
13
- type AgentRuntime,
14
- type AgentSpec,
15
- type BrokerEvent,
16
- type BrokerStats,
17
- type BrokerStatus,
18
- type CrashInsightsResponse,
19
- type HeadlessProvider,
20
- type ProtocolEnvelope,
21
- type ProtocolError,
22
- type RestartPolicy,
23
- type MessageInjectionMode,
17
+ import { BrokerTransport, AgentRelayProtocolError } from './transport.js';
18
+ import { getBrokerBinaryPath } from './broker-path.js';
19
+ import type {
20
+ AgentRuntime,
21
+ BrokerEvent,
22
+ BrokerStats,
23
+ BrokerStatus,
24
+ CrashInsightsResponse,
25
+ HeadlessProvider,
24
26
  } from './protocol.js';
27
+ import type {
28
+ AgentTransport,
29
+ SpawnPtyInput,
30
+ SpawnProviderInput,
31
+ SendMessageInput,
32
+ ListAgent,
33
+ } from './types.js';
34
+
35
+ // ── Types ──────────────────────────────────────────────────────────────
25
36
 
26
37
  export interface AgentRelayClientOptions {
38
+ baseUrl: string;
39
+ apiKey?: string;
40
+ /** Timeout in ms for HTTP requests. Default: 30000. */
41
+ requestTimeoutMs?: number;
42
+ }
43
+
44
+ export interface AgentRelaySpawnOptions {
45
+ /** Path to the agent-relay-broker binary. Auto-resolved if omitted. */
27
46
  binaryPath?: string;
47
+ /** Extra args passed to `broker init` (e.g. ['--persist']). */
28
48
  binaryArgs?: string[];
49
+ /** Broker name. Defaults to cwd basename. */
29
50
  brokerName?: string;
51
+ /** Default channels for spawned agents. */
30
52
  channels?: string[];
53
+ /** Working directory for the broker process. */
31
54
  cwd?: string;
55
+ /** Environment variables for the broker process. */
32
56
  env?: NodeJS.ProcessEnv;
57
+ /** Forward broker stderr to this callback. */
58
+ onStderr?: (line: string) => void;
59
+ /** Timeout in ms to wait for broker to become ready. Default: 15000. */
60
+ startupTimeoutMs?: number;
61
+ /** Timeout in ms for HTTP requests to the broker. Default: 30000. */
33
62
  requestTimeoutMs?: number;
34
- shutdownTimeoutMs?: number;
35
- clientName?: string;
36
- clientVersion?: string;
37
- }
38
-
39
- export interface SpawnPtyInput {
40
- name: string;
41
- cli: string;
42
- args?: string[];
43
- channels?: string[];
44
- task?: string;
45
- model?: string;
46
- cwd?: string;
47
- team?: string;
48
- shadowOf?: string;
49
- shadowMode?: string;
50
- /** Silence duration in seconds before emitting agent_idle (0 = disabled, default: 30). */
51
- idleThresholdSecs?: number;
52
- /** Auto-restart policy for crashed agents. */
53
- restartPolicy?: RestartPolicy;
54
- /** Name of a previously released agent whose continuity context should be injected. */
55
- continueFrom?: string;
56
- /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent.
57
- * Useful for minor tasks where relay messaging is not needed, saving tokens. */
58
- skipRelayPrompt?: boolean;
59
63
  }
60
64
 
61
- export interface SpawnHeadlessInput {
62
- name: string;
63
- provider: HeadlessProvider;
64
- args?: string[];
65
- channels?: string[];
66
- task?: string;
67
- /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent.
68
- * Useful for minor tasks where relay messaging is not needed, saving tokens. */
69
- skipRelayPrompt?: boolean;
65
+ export interface SessionInfo {
66
+ broker_version: string;
67
+ protocol_version: number;
68
+ workspace_key?: string;
69
+ default_workspace_id?: string;
70
+ mode: string;
71
+ uptime_secs: number;
70
72
  }
71
73
 
72
- export type AgentTransport = 'pty' | 'headless';
73
-
74
- export interface SpawnProviderInput {
75
- name: string;
76
- provider: string;
77
- transport?: AgentTransport;
78
- args?: string[];
79
- channels?: string[];
80
- task?: string;
81
- model?: string;
82
- cwd?: string;
83
- team?: string;
84
- shadowOf?: string;
85
- shadowMode?: string;
86
- idleThresholdSecs?: number;
87
- restartPolicy?: RestartPolicy;
88
- continueFrom?: string;
89
- /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent.
90
- * Useful for minor tasks where relay messaging is not needed, saving tokens. */
91
- skipRelayPrompt?: boolean;
92
- }
93
-
94
- export interface SendMessageInput {
95
- to: string;
96
- text: string;
97
- from?: string;
98
- threadId?: string;
99
- workspaceId?: string;
100
- workspaceAlias?: string;
101
- priority?: number;
102
- data?: Record<string, unknown>;
103
- mode?: MessageInjectionMode;
104
- }
105
-
106
- export interface ListAgent {
107
- name: string;
108
- runtime: AgentRuntime;
109
- provider?: HeadlessProvider;
110
- cli?: string;
111
- model?: string;
112
- team?: string;
113
- channels: string[];
114
- parent?: string;
115
- pid?: number;
116
- }
117
-
118
- interface PendingRequest {
119
- expectedType: 'ok' | 'hello_ack';
120
- resolve: (value: ProtocolEnvelope<unknown>) => void;
121
- reject: (error: Error) => void;
122
- timeout: ReturnType<typeof setTimeout>;
74
+ function isHeadlessProvider(value: string): value is HeadlessProvider {
75
+ return value === 'claude' || value === 'opencode';
123
76
  }
124
77
 
125
- interface ParsedEnvelope {
126
- v: number;
127
- type: string;
128
- request_id?: string;
129
- payload: unknown;
78
+ function resolveSpawnTransport(input: SpawnProviderInput): AgentTransport {
79
+ return input.transport ?? (input.provider === 'opencode' ? 'headless' : 'pty');
130
80
  }
131
81
 
132
- export class AgentRelayProtocolError extends Error {
133
- code: string;
134
- retryable: boolean;
135
- data?: unknown;
136
-
137
- constructor(payload: ProtocolError) {
138
- super(payload.message);
139
- this.name = 'AgentRelayProtocolError';
140
- this.code = payload.code;
141
- this.retryable = payload.retryable;
142
- this.data = payload.data;
82
+ function isProcessRunning(pid: number): boolean {
83
+ if (!Number.isInteger(pid) || pid <= 0) {
84
+ return false;
143
85
  }
144
- }
145
86
 
146
- export class AgentRelayProcessError extends Error {
147
- constructor(message: string) {
148
- super(message);
149
- this.name = 'AgentRelayProcessError';
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ } catch (error) {
91
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
150
92
  }
151
93
  }
152
94
 
153
- function isHeadlessProvider(value: string): value is HeadlessProvider {
154
- return value === 'claude' || value === 'opencode';
155
- }
95
+ // ── Client ─────────────────────────────────────────────────────────────
156
96
 
157
97
  export class AgentRelayClient {
158
- private readonly options: Required<AgentRelayClientOptions>;
159
- private child?: ChildProcessWithoutNullStreams;
160
- private stdoutRl?: ReadlineInterface;
161
- private stderrRl?: ReadlineInterface;
162
- private lastStderrLine?: string;
163
- private requestSeq = 0;
164
- private pending = new Map<string, PendingRequest>();
165
- private startingPromise?: Promise<void>;
166
- private eventListeners = new Set<(event: BrokerEvent) => void>();
167
- private stderrListeners = new Set<(line: string) => void>();
168
- private eventBuffer: BrokerEvent[] = [];
169
- private maxBufferSize = 1000;
170
- private exitPromise?: Promise<void>;
171
- /** The workspace key returned by the broker in its hello_ack response. */
172
- workspaceKey?: string;
98
+ private readonly transport: BrokerTransport;
173
99
 
174
- constructor(options: AgentRelayClientOptions = {}) {
175
- this.options = {
176
- binaryPath: options.binaryPath ?? resolveDefaultBinaryPath(),
177
- binaryArgs: options.binaryArgs ?? [],
178
- brokerName: options.brokerName ?? (path.basename(options.cwd ?? process.cwd()) || 'project'),
179
- channels: options.channels ?? ['general'],
180
- cwd: options.cwd ?? process.cwd(),
181
- env: options.env ?? process.env,
182
- requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
183
- shutdownTimeoutMs: options.shutdownTimeoutMs ?? 3_000,
184
- clientName: options.clientName ?? '@agent-relay/sdk',
185
- clientVersion: options.clientVersion ?? '0.1.0',
186
- };
187
- }
100
+ /** Set after spawn() — the managed child process. */
101
+ private child: ChildProcess | null = null;
102
+ /** Lease renewal timer (only for spawned brokers). */
103
+ private leaseTimer: ReturnType<typeof setInterval> | null = null;
188
104
 
189
- static async start(options: AgentRelayClientOptions = {}): Promise<AgentRelayClient> {
190
- const client = new AgentRelayClient(options);
191
- await client.start();
192
- return client;
193
- }
105
+ workspaceKey?: string;
194
106
 
195
- onEvent(listener: (event: BrokerEvent) => void): () => void {
196
- this.eventListeners.add(listener);
197
- return () => {
198
- this.eventListeners.delete(listener);
199
- };
107
+ constructor(options: AgentRelayClientOptions) {
108
+ this.transport = new BrokerTransport({
109
+ baseUrl: options.baseUrl,
110
+ apiKey: options.apiKey,
111
+ requestTimeoutMs: options.requestTimeoutMs,
112
+ });
200
113
  }
201
114
 
202
- queryEvents(filter?: { kind?: string; name?: string; since?: number; limit?: number }): BrokerEvent[] {
203
- let events = [...this.eventBuffer];
204
- if (filter?.kind) {
205
- events = events.filter((event) => event.kind === filter.kind);
115
+ /**
116
+ * Connect to an already-running broker by reading its connection file.
117
+ *
118
+ * The broker writes `connection.json` to its data directory ({cwd}/.agent-relay/
119
+ * in persist mode). This method reads that file to get the URL and API key.
120
+ *
121
+ * @param cwd — project directory (default: process.cwd())
122
+ * @param connectionPath — explicit path to connection.json (overrides cwd)
123
+ */
124
+ static connect(options?: { cwd?: string; connectionPath?: string }): AgentRelayClient {
125
+ const cwd = options?.cwd ?? process.cwd();
126
+ const stateDir = process.env.AGENT_RELAY_STATE_DIR;
127
+ const connPath =
128
+ options?.connectionPath ?? path.join(stateDir ?? path.join(cwd, '.agent-relay'), 'connection.json');
129
+
130
+ if (!existsSync(connPath)) {
131
+ throw new Error(
132
+ `No running broker found (${connPath} does not exist). Start one with 'agent-relay up' or use AgentRelayClient.spawn().`
133
+ );
206
134
  }
207
- if (filter?.name) {
208
- events = events.filter((event) => 'name' in event && event.name === filter.name);
135
+
136
+ const raw = readFileSync(connPath, 'utf-8');
137
+ let conn: { url?: string; api_key?: string; port?: number; pid?: number };
138
+ try {
139
+ conn = JSON.parse(raw);
140
+ } catch {
141
+ throw new Error(`Corrupt broker connection file (${connPath}). Remove it and start the broker again.`);
209
142
  }
210
- const since = filter?.since;
211
- if (since !== undefined) {
212
- events = events.filter(
213
- (event) => 'timestamp' in event && typeof event.timestamp === 'number' && event.timestamp >= since
143
+
144
+ if (typeof conn.url !== 'string' || typeof conn.api_key !== 'string' || typeof conn.pid !== 'number') {
145
+ throw new Error(
146
+ `Invalid broker connection metadata in ${connPath}. Remove it and start the broker again.`
214
147
  );
215
148
  }
216
- const limit = filter?.limit;
217
- if (limit !== undefined) {
218
- events = events.slice(-limit);
219
- }
220
- return events;
221
- }
222
149
 
223
- getLastEvent(kind: string, name?: string): BrokerEvent | undefined {
224
- for (let i = this.eventBuffer.length - 1; i >= 0; i -= 1) {
225
- const event = this.eventBuffer[i];
226
- if (event.kind === kind && (!name || ('name' in event && event.name === name))) {
227
- return event;
228
- }
150
+ if (!isProcessRunning(conn.pid)) {
151
+ throw new Error(
152
+ `Stale broker connection file (${connPath}) points to dead pid ${conn.pid}. Start the broker with 'agent-relay up' or use AgentRelayClient.spawn().`
153
+ );
229
154
  }
230
- return undefined;
155
+
156
+ return new AgentRelayClient({ baseUrl: conn.url, apiKey: conn.api_key });
231
157
  }
232
158
 
233
- onBrokerStderr(listener: (line: string) => void): () => void {
234
- this.stderrListeners.add(listener);
235
- return () => {
236
- this.stderrListeners.delete(listener);
159
+ /**
160
+ * Spawn a local broker process and return a connected client.
161
+ *
162
+ * 1. Generates a random API key
163
+ * 2. Spawns the broker binary (attached)
164
+ * 3. Parses the API port from stdout
165
+ * 4. Connects HTTP/WS transport
166
+ * 5. Fetches session metadata
167
+ * 6. Starts event stream + lease renewal
168
+ */
169
+ static async spawn(options?: AgentRelaySpawnOptions): Promise<AgentRelayClient> {
170
+ const binaryPath = options?.binaryPath ?? getBrokerBinaryPath() ?? 'agent-relay-broker';
171
+ const cwd = options?.cwd ?? process.cwd();
172
+ const brokerName = options?.brokerName ?? (path.basename(cwd) || 'project');
173
+ const channels = options?.channels ?? ['general'];
174
+ const timeoutMs = options?.startupTimeoutMs ?? 15_000;
175
+ const userArgs = options?.binaryArgs ?? [];
176
+
177
+ const apiKey = `br_${randomBytes(16).toString('hex')}`;
178
+
179
+ const env = {
180
+ ...process.env,
181
+ ...options?.env,
182
+ RELAY_BROKER_API_KEY: apiKey,
237
183
  };
184
+
185
+ const args = ['init', '--name', brokerName, '--channels', channels.join(','), ...userArgs];
186
+
187
+ const child = spawn(binaryPath, args, {
188
+ cwd,
189
+ env,
190
+ stdio: ['ignore', 'pipe', options?.onStderr ? 'pipe' : 'ignore'],
191
+ });
192
+
193
+ // Forward stderr if requested
194
+ if (options?.onStderr && child.stderr) {
195
+ const { createInterface } = await import('node:readline');
196
+ const rl = createInterface({ input: child.stderr });
197
+ rl.on('line', (line) => options.onStderr!(line));
198
+ }
199
+
200
+ // Parse the API URL from stdout (the broker prints it after binding)
201
+ const baseUrl = await waitForApiUrl(child, timeoutMs);
202
+
203
+ const client = new AgentRelayClient({
204
+ baseUrl,
205
+ apiKey,
206
+ requestTimeoutMs: options?.requestTimeoutMs,
207
+ });
208
+ client.child = child;
209
+
210
+ await client.getSession();
211
+ client.connectEvents();
212
+
213
+ // Renew the owner lease so the broker doesn't auto-shutdown
214
+ client.leaseTimer = setInterval(() => {
215
+ client.renewLease().catch(() => {});
216
+ }, 60_000);
217
+
218
+ child.on('exit', () => {
219
+ client.disconnectEvents();
220
+ if (client.leaseTimer) {
221
+ clearInterval(client.leaseTimer);
222
+ client.leaseTimer = null;
223
+ }
224
+ });
225
+
226
+ return client;
238
227
  }
239
228
 
229
+ /** PID of the managed broker process, if spawned locally. */
240
230
  get brokerPid(): number | undefined {
241
231
  return this.child?.pid;
242
232
  }
243
233
 
244
- async start(): Promise<void> {
245
- if (this.child) {
246
- return;
247
- }
248
- if (this.startingPromise) {
249
- return this.startingPromise;
250
- }
234
+ // ── Session ────────────────────────────────────────────────────────
251
235
 
252
- this.startingPromise = this.startInternal();
253
- try {
254
- await this.startingPromise;
255
- } finally {
256
- this.startingPromise = undefined;
257
- }
236
+ async getSession(): Promise<SessionInfo> {
237
+ const session = await this.transport.request<SessionInfo>('/api/session');
238
+ this.workspaceKey = session.workspace_key;
239
+ return session;
258
240
  }
259
241
 
260
- /**
261
- * Pre-register a batch of agents with Relaycast before their steps execute.
262
- * The broker warms its token cache in parallel; subsequent spawn_agent calls
263
- * hit the cache rather than waiting on individual HTTP registrations.
264
- * Fire-and-forget from the caller's perspective — broker responds immediately
265
- * and registers in the background.
266
- */
267
- async preflightAgents(agents: Array<{ name: string; cli: string | AgentRuntime }>): Promise<void> {
268
- if (agents.length === 0) return;
269
- await this.start();
270
- await this.requestOk<void>('preflight_agents', { agents });
242
+ async healthCheck(): Promise<{ service: string }> {
243
+ return this.transport.request<{ service: string }>('/health');
271
244
  }
272
245
 
273
- async spawnPty(input: SpawnPtyInput): Promise<{ name: string; runtime: AgentRuntime }> {
274
- await this.start();
275
- const args = buildPtyArgsWithModel(input.cli, input.args ?? [], input.model);
276
- const agent: AgentSpec = {
277
- name: input.name,
278
- runtime: 'pty',
279
- cli: input.cli,
280
- args,
281
- channels: input.channels ?? [],
282
- model: input.model,
283
- cwd: input.cwd ?? this.options.cwd,
284
- team: input.team,
285
- shadow_of: input.shadowOf,
286
- shadow_mode: input.shadowMode,
287
- restart_policy: input.restartPolicy,
288
- };
289
- const result = await this.requestOk<{ name: string; runtime: AgentRuntime }>('spawn_agent', {
290
- agent,
291
- ...(input.task != null ? { initial_task: input.task } : {}),
292
- ...(input.idleThresholdSecs != null ? { idle_threshold_secs: input.idleThresholdSecs } : {}),
293
- ...(input.continueFrom != null ? { continue_from: input.continueFrom } : {}),
294
- ...(input.skipRelayPrompt != null ? { skip_relay_prompt: input.skipRelayPrompt } : {}),
295
- });
296
- return result;
246
+ // ── Events ─────────────────────────────────────────────────────────
247
+
248
+ connectEvents(sinceSeq?: number): void {
249
+ this.transport.connect(sinceSeq);
297
250
  }
298
251
 
299
- async spawnHeadless(input: SpawnHeadlessInput): Promise<{ name: string; runtime: AgentRuntime }> {
300
- await this.start();
301
- const agent: AgentSpec = {
302
- name: input.name,
303
- runtime: 'headless',
304
- provider: input.provider,
305
- args: input.args ?? [],
306
- channels: input.channels ?? [],
307
- };
308
- const result = await this.requestOk<{ name: string; runtime: AgentRuntime }>('spawn_agent', {
309
- agent,
310
- ...(input.task != null ? { initial_task: input.task } : {}),
311
- ...(input.skipRelayPrompt != null ? { skip_relay_prompt: input.skipRelayPrompt } : {}),
312
- });
313
- return result;
252
+ disconnectEvents(): void {
253
+ this.transport.disconnect();
314
254
  }
315
255
 
316
- async spawnProvider(input: SpawnProviderInput): Promise<{ name: string; runtime: AgentRuntime }> {
317
- const transport = input.transport ?? (input.provider === 'opencode' ? 'headless' : 'pty');
318
- if (transport === 'headless') {
319
- if (!isHeadlessProvider(input.provider)) {
320
- throw new AgentRelayProcessError(
321
- `provider '${input.provider}' does not support headless transport (supported: claude, opencode)`
322
- );
323
- }
324
- return this.spawnHeadless({
256
+ onEvent(listener: (event: BrokerEvent) => void): () => void {
257
+ return this.transport.onEvent(listener);
258
+ }
259
+
260
+ queryEvents(filter?: { kind?: string; name?: string; since?: number; limit?: number }): BrokerEvent[] {
261
+ return this.transport.queryEvents(filter);
262
+ }
263
+
264
+ getLastEvent(kind: string, name?: string): BrokerEvent | undefined {
265
+ return this.transport.getLastEvent(kind, name);
266
+ }
267
+
268
+ // ── Agent lifecycle ────────────────────────────────────────────────
269
+
270
+ async spawnPty(input: SpawnPtyInput): Promise<{ name: string; runtime: AgentRuntime }> {
271
+ return this.transport.request('/api/spawn', {
272
+ method: 'POST',
273
+ body: JSON.stringify({
325
274
  name: input.name,
326
- provider: input.provider,
327
- args: input.args,
328
- channels: input.channels,
275
+ cli: input.cli,
276
+ model: input.model,
277
+ args: input.args ?? [],
329
278
  task: input.task,
279
+ channels: input.channels ?? [],
280
+ cwd: input.cwd,
281
+ team: input.team,
282
+ shadowOf: input.shadowOf,
283
+ shadowMode: input.shadowMode,
284
+ continueFrom: input.continueFrom,
285
+ idleThresholdSecs: input.idleThresholdSecs,
286
+ restartPolicy: input.restartPolicy,
330
287
  skipRelayPrompt: input.skipRelayPrompt,
331
- });
288
+ }),
289
+ });
290
+ }
291
+
292
+ async spawnProvider(input: SpawnProviderInput): Promise<{ name: string; runtime: AgentRuntime }> {
293
+ const transport = resolveSpawnTransport(input);
294
+ if (transport === 'headless' && !isHeadlessProvider(input.provider)) {
295
+ throw new Error(
296
+ `provider '${input.provider}' does not support headless transport (supported: claude, opencode)`
297
+ );
332
298
  }
333
299
 
334
- return this.spawnPty({
335
- name: input.name,
336
- cli: input.provider,
337
- args: input.args,
338
- channels: input.channels,
339
- task: input.task,
340
- model: input.model,
341
- cwd: input.cwd,
342
- team: input.team,
343
- shadowOf: input.shadowOf,
344
- shadowMode: input.shadowMode,
345
- idleThresholdSecs: input.idleThresholdSecs,
346
- restartPolicy: input.restartPolicy,
347
- continueFrom: input.continueFrom,
348
- skipRelayPrompt: input.skipRelayPrompt,
300
+ return this.transport.request('/api/spawn', {
301
+ method: 'POST',
302
+ body: JSON.stringify({
303
+ name: input.name,
304
+ cli: input.provider,
305
+ model: input.model,
306
+ args: input.args ?? [],
307
+ task: input.task,
308
+ channels: input.channels ?? [],
309
+ cwd: input.cwd,
310
+ team: input.team,
311
+ shadowOf: input.shadowOf,
312
+ shadowMode: input.shadowMode,
313
+ continueFrom: input.continueFrom,
314
+ idleThresholdSecs: input.idleThresholdSecs,
315
+ restartPolicy: input.restartPolicy,
316
+ skipRelayPrompt: input.skipRelayPrompt,
317
+ transport,
318
+ }),
349
319
  });
350
320
  }
351
321
 
322
+ async spawnHeadless(input: {
323
+ name: string;
324
+ provider: HeadlessProvider;
325
+ args?: string[];
326
+ channels?: string[];
327
+ task?: string;
328
+ skipRelayPrompt?: boolean;
329
+ }): Promise<{ name: string; runtime: AgentRuntime }> {
330
+ return this.spawnProvider({ ...input, transport: 'headless' });
331
+ }
332
+
352
333
  async spawnClaude(
353
334
  input: Omit<SpawnProviderInput, 'provider'>
354
335
  ): Promise<{ name: string; runtime: AgentRuntime }> {
@@ -362,23 +343,24 @@ export class AgentRelayClient {
362
343
  }
363
344
 
364
345
  async release(name: string, reason?: string): Promise<{ name: string }> {
365
- await this.start();
366
- return this.requestOk<{ name: string }>('release_agent', { name, reason });
346
+ return this.transport.request(`/api/spawned/${encodeURIComponent(name)}`, {
347
+ method: 'DELETE',
348
+ ...(reason ? { body: JSON.stringify({ reason }) } : {}),
349
+ });
367
350
  }
368
351
 
369
- async sendInput(name: string, data: string): Promise<{ name: string; bytes_written: number }> {
370
- await this.start();
371
- return this.requestOk<{ name: string; bytes_written: number }>('send_input', { name, data });
352
+ async listAgents(): Promise<ListAgent[]> {
353
+ const result = await this.transport.request<{ agents: ListAgent[] }>('/api/spawned');
354
+ return result.agents;
372
355
  }
373
356
 
374
- async subscribeChannels(name: string, channels: string[]): Promise<void> {
375
- await this.start();
376
- await this.requestOk<void>('subscribe_channels', { name, channels });
377
- }
357
+ // ── PTY control ────────────────────────────────────────────────────
378
358
 
379
- async unsubscribeChannels(name: string, channels: string[]): Promise<void> {
380
- await this.start();
381
- await this.requestOk<void>('unsubscribe_channels', { name, channels });
359
+ async sendInput(name: string, data: string): Promise<{ name: string; bytes_written: number }> {
360
+ return this.transport.request(`/api/input/${encodeURIComponent(name)}`, {
361
+ method: 'POST',
362
+ body: JSON.stringify({ data }),
363
+ });
382
364
  }
383
365
 
384
366
  async resizePty(
@@ -386,56 +368,29 @@ export class AgentRelayClient {
386
368
  rows: number,
387
369
  cols: number
388
370
  ): Promise<{ name: string; rows: number; cols: number }> {
389
- await this.start();
390
- return this.requestOk<{ name: string; rows: number; cols: number }>('resize_pty', {
391
- name,
392
- rows,
393
- cols,
371
+ return this.transport.request(`/api/resize/${encodeURIComponent(name)}`, {
372
+ method: 'POST',
373
+ body: JSON.stringify({ rows, cols }),
394
374
  });
395
375
  }
396
376
 
397
- async setModel(
398
- name: string,
399
- model: string,
400
- opts?: { timeoutMs?: number }
401
- ): Promise<{ name: string; model: string; success: boolean }> {
402
- await this.start();
403
- return this.requestOk<{ name: string; model: string; success: boolean }>('set_model', {
404
- name,
405
- model,
406
- timeout_ms: opts?.timeoutMs,
407
- });
408
- }
409
-
410
- async getMetrics(agent?: string): Promise<{
411
- agents: Array<{ name: string; pid: number; memory_bytes: number; uptime_secs: number }>;
412
- broker?: BrokerStats;
413
- }> {
414
- await this.start();
415
- return this.requestOk<{
416
- agents: Array<{ name: string; pid: number; memory_bytes: number; uptime_secs: number }>;
417
- broker?: BrokerStats;
418
- }>('get_metrics', { agent });
419
- }
420
-
421
- async getCrashInsights(): Promise<CrashInsightsResponse> {
422
- await this.start();
423
- return this.requestOk<CrashInsightsResponse>('get_crash_insights', {});
424
- }
377
+ // ── Messaging ──────────────────────────────────────────────────────
425
378
 
426
379
  async sendMessage(input: SendMessageInput): Promise<{ event_id: string; targets: string[] }> {
427
- await this.start();
428
380
  try {
429
- return await this.requestOk<{ event_id: string; targets: string[] }>('send_message', {
430
- to: input.to,
431
- text: input.text,
432
- from: input.from,
433
- thread_id: input.threadId,
434
- workspace_id: input.workspaceId,
435
- workspace_alias: input.workspaceAlias,
436
- priority: input.priority,
437
- data: input.data,
438
- mode: input.mode,
381
+ return await this.transport.request('/api/send', {
382
+ method: 'POST',
383
+ body: JSON.stringify({
384
+ to: input.to,
385
+ text: input.text,
386
+ from: input.from,
387
+ threadId: input.threadId,
388
+ workspaceId: input.workspaceId,
389
+ workspaceAlias: input.workspaceAlias,
390
+ priority: input.priority,
391
+ data: input.data,
392
+ mode: input.mode,
393
+ }),
439
394
  });
440
395
  } catch (error) {
441
396
  if (error instanceof AgentRelayProtocolError && error.code === 'unsupported_operation') {
@@ -445,786 +400,181 @@ export class AgentRelayClient {
445
400
  }
446
401
  }
447
402
 
448
- async listAgents(): Promise<ListAgent[]> {
449
- await this.start();
450
- const result = await this.requestOk<{ agents: ListAgent[] }>('list_agents', {});
451
- return result.agents;
452
- }
453
-
454
- async getStatus(): Promise<BrokerStatus> {
455
- await this.start();
456
- return this.requestOk<BrokerStatus>('get_status', {});
457
- }
403
+ // ── Model control ──────────────────────────────────────────────────
458
404
 
459
- async shutdown(): Promise<void> {
460
- if (!this.child) {
461
- return;
462
- }
463
-
464
- void this.requestOk('shutdown', {}).catch(() => {
465
- // Continue shutdown path if broker is already unhealthy or exits before replying.
405
+ async setModel(
406
+ name: string,
407
+ model: string,
408
+ opts?: { timeoutMs?: number }
409
+ ): Promise<{ name: string; model: string; success: boolean }> {
410
+ return this.transport.request(`/api/spawned/${encodeURIComponent(name)}/model`, {
411
+ method: 'POST',
412
+ body: JSON.stringify({ model, timeout_ms: opts?.timeoutMs }),
466
413
  });
467
-
468
- const child = this.child;
469
- const wait = this.exitPromise ?? Promise.resolve();
470
- const waitForExit = async (timeoutMs: number): Promise<boolean> => {
471
- let timer: ReturnType<typeof setTimeout> | undefined;
472
- const result = await Promise.race([
473
- wait.then(() => true),
474
- new Promise<boolean>((resolve) => {
475
- timer = setTimeout(() => resolve(false), timeoutMs);
476
- }),
477
- ]);
478
- if (timer !== undefined) clearTimeout(timer);
479
- return result;
480
- };
481
-
482
- if (await waitForExit(this.options.shutdownTimeoutMs)) {
483
- return;
484
- }
485
-
486
- if (child.exitCode === null && child.signalCode === null) {
487
- child.kill('SIGTERM');
488
- }
489
- if (await waitForExit(1_000)) {
490
- return;
491
- }
492
-
493
- if (child.exitCode === null && child.signalCode === null) {
494
- child.kill('SIGKILL');
495
- }
496
- await waitForExit(1_000);
497
- }
498
-
499
- async waitForExit(): Promise<void> {
500
- if (!this.child) {
501
- return;
502
- }
503
- await this.exitPromise;
504
414
  }
505
415
 
506
- private async startInternal(): Promise<void> {
507
- const resolvedBinary = expandTilde(this.options.binaryPath);
508
- if (isExplicitPath(this.options.binaryPath) && !fs.existsSync(resolvedBinary)) {
509
- throw new AgentRelayProcessError(`broker binary not found: ${this.options.binaryPath}`);
510
- }
511
- this.lastStderrLine = undefined;
512
-
513
- const args = [
514
- 'init',
515
- '--name',
516
- this.options.brokerName,
517
- ...(this.options.channels.length > 0 ? ['--channels', this.options.channels.join(',')] : []),
518
- ...this.options.binaryArgs,
519
- ];
520
-
521
- // Ensure the SDK bin directory (containing agent-relay-broker) is on
522
- // PATH so spawned workers can find it without any user setup.
523
- const env = { ...this.options.env };
524
- if (isExplicitPath(this.options.binaryPath)) {
525
- const binDir = path.dirname(path.resolve(resolvedBinary));
526
- const currentPath = env.PATH ?? env.Path ?? '';
527
- if (!currentPath.split(path.delimiter).includes(binDir)) {
528
- env.PATH = `${binDir}${path.delimiter}${currentPath}`;
529
- }
530
- }
531
-
532
- console.error(`[broker] Starting: ${resolvedBinary} ${args.join(' ')}`);
533
- const child = spawn(resolvedBinary, args, {
534
- cwd: this.options.cwd,
535
- env,
536
- stdio: 'pipe',
537
- });
538
-
539
- this.child = child;
540
- this.stdoutRl = createInterface({ input: child.stdout, crlfDelay: Infinity });
541
- this.stderrRl = createInterface({ input: child.stderr, crlfDelay: Infinity });
416
+ // ── Channels ───────────────────────────────────────────────────────
542
417
 
543
- this.stdoutRl.on('line', (line) => {
544
- this.handleStdoutLine(line);
545
- });
546
-
547
- this.stderrRl.on('line', (line) => {
548
- const trimmed = line.trim();
549
- if (trimmed) {
550
- this.lastStderrLine = trimmed;
551
- }
552
- for (const listener of this.stderrListeners) {
553
- listener(line);
554
- }
555
- });
556
-
557
- this.exitPromise = new Promise<void>((resolve) => {
558
- // Use 'close' instead of 'exit' so that all buffered stderr/stdout
559
- // data has been consumed before we build the error message. The
560
- // 'exit' event fires when the process terminates, but stdio streams
561
- // may still have unread data; 'close' fires after both the process
562
- // exits AND all stdio streams have ended.
563
- child.once('close', (code, signal) => {
564
- const detail = this.lastStderrLine ? `: ${this.lastStderrLine}` : '';
565
- const error = new AgentRelayProcessError(
566
- `broker exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})${detail}`
567
- );
568
- this.failAllPending(error);
569
- this.disposeProcessHandles();
570
- resolve();
571
- });
572
- child.once('error', (error) => {
573
- this.failAllPending(error);
574
- this.disposeProcessHandles();
575
- resolve();
576
- });
418
+ async subscribeChannels(name: string, channels: string[]): Promise<void> {
419
+ await this.transport.request(`/api/spawned/${encodeURIComponent(name)}/subscribe`, {
420
+ method: 'POST',
421
+ body: JSON.stringify({ channels }),
577
422
  });
578
-
579
- const helloAck = await this.requestHello();
580
- console.error('[broker] Broker ready (hello handshake complete)');
581
- if (helloAck.workspace_key) {
582
- this.workspaceKey = helloAck.workspace_key;
583
- }
584
- }
585
-
586
- private disposeProcessHandles(): void {
587
- this.stdoutRl?.close();
588
- this.stderrRl?.close();
589
- this.stdoutRl = undefined;
590
- this.stderrRl = undefined;
591
- this.lastStderrLine = undefined;
592
- this.child = undefined;
593
- this.exitPromise = undefined;
594
423
  }
595
424
 
596
- private failAllPending(error: Error): void {
597
- for (const pending of this.pending.values()) {
598
- clearTimeout(pending.timeout);
599
- pending.reject(error);
600
- }
601
- this.pending.clear();
425
+ async unsubscribeChannels(name: string, channels: string[]): Promise<void> {
426
+ await this.transport.request(`/api/spawned/${encodeURIComponent(name)}/unsubscribe`, {
427
+ method: 'POST',
428
+ body: JSON.stringify({ channels }),
429
+ });
602
430
  }
603
431
 
604
- private handleStdoutLine(line: string): void {
605
- let parsed: ParsedEnvelope;
606
- try {
607
- parsed = JSON.parse(line) as ParsedEnvelope;
608
- } catch {
609
- // Non-protocol output should not crash the SDK.
610
- return;
611
- }
612
-
613
- if (!parsed || typeof parsed !== 'object') {
614
- return;
615
- }
616
- if (parsed.v !== PROTOCOL_VERSION || typeof parsed.type !== 'string') {
617
- return;
618
- }
619
-
620
- const envelope: ProtocolEnvelope<unknown> = {
621
- v: parsed.v,
622
- type: parsed.type,
623
- request_id: parsed.request_id,
624
- payload: parsed.payload,
625
- };
626
-
627
- if (envelope.type === 'event') {
628
- const payload = envelope.payload as BrokerEvent;
629
- this.eventBuffer.push(payload);
630
- if (this.eventBuffer.length > this.maxBufferSize) {
631
- this.eventBuffer.shift();
632
- }
633
- for (const listener of this.eventListeners) {
634
- listener(payload);
635
- }
636
- return;
637
- }
638
-
639
- if (!envelope.request_id) {
640
- return;
641
- }
642
-
643
- const pending = this.pending.get(envelope.request_id);
644
- if (!pending) {
645
- return;
646
- }
647
-
648
- if (envelope.type === 'error') {
649
- clearTimeout(pending.timeout);
650
- this.pending.delete(envelope.request_id);
651
- pending.reject(new AgentRelayProtocolError(envelope.payload as ProtocolError));
652
- return;
653
- }
432
+ // ── Observability ──────────────────────────────────────────────────
654
433
 
655
- if (envelope.type !== pending.expectedType) {
656
- clearTimeout(pending.timeout);
657
- this.pending.delete(envelope.request_id);
658
- pending.reject(
659
- new AgentRelayProcessError(
660
- `unexpected response type '${envelope.type}' for request '${envelope.request_id}' (expected '${pending.expectedType}')`
661
- )
662
- );
663
- return;
664
- }
665
-
666
- clearTimeout(pending.timeout);
667
- this.pending.delete(envelope.request_id);
668
- pending.resolve(envelope);
669
- }
670
-
671
- private async requestHello(): Promise<{
672
- broker_version: string;
673
- protocol_version: number;
674
- workspace_key?: string;
434
+ async getMetrics(agent?: string): Promise<{
435
+ agents: Array<{ name: string; pid: number; memory_bytes: number; uptime_secs: number }>;
436
+ broker?: BrokerStats;
675
437
  }> {
676
- const payload = {
677
- client_name: this.options.clientName,
678
- client_version: this.options.clientVersion,
679
- };
680
- const frame = await this.sendRequest('hello', payload, 'hello_ack');
681
- return frame.payload as { broker_version: string; protocol_version: number; workspace_key?: string };
438
+ const query = agent ? `?agent=${encodeURIComponent(agent)}` : '';
439
+ return this.transport.request(`/api/metrics${query}`);
682
440
  }
683
441
 
684
- private async requestOk<T = unknown>(type: string, payload: unknown): Promise<T> {
685
- const frame = await this.sendRequest(type, payload, 'ok');
686
- const result = frame.payload as { result: T };
687
- return result.result;
442
+ async getStatus(): Promise<BrokerStatus> {
443
+ return this.transport.request<BrokerStatus>('/api/status');
688
444
  }
689
445
 
690
- private async sendRequest(
691
- type: string,
692
- payload: unknown,
693
- expectedType: 'ok' | 'hello_ack'
694
- ): Promise<ProtocolEnvelope<unknown>> {
695
- if (!this.child) {
696
- throw new AgentRelayProcessError('broker is not running');
697
- }
446
+ async getCrashInsights(): Promise<CrashInsightsResponse> {
447
+ return this.transport.request('/api/crash-insights');
448
+ }
698
449
 
699
- const requestId = `req_${++this.requestSeq}`;
700
- const message: ProtocolEnvelope<unknown> = {
701
- v: PROTOCOL_VERSION,
702
- type,
703
- request_id: requestId,
704
- payload,
705
- };
450
+ // ── Lifecycle ──────────────────────────────────────────────────────
706
451
 
707
- const responsePromise = new Promise<ProtocolEnvelope<unknown>>((resolve, reject) => {
708
- const timeout = setTimeout(() => {
709
- this.pending.delete(requestId);
710
- reject(
711
- new AgentRelayProcessError(
712
- `request timed out after ${this.options.requestTimeoutMs}ms (type='${type}', request_id='${requestId}')`
713
- )
714
- );
715
- }, this.options.requestTimeoutMs);
716
-
717
- this.pending.set(requestId, {
718
- expectedType,
719
- resolve,
720
- reject,
721
- timeout,
722
- });
452
+ async preflight(agents: Array<{ name: string; cli: string }>): Promise<{ queued: number }> {
453
+ return this.transport.request('/api/preflight', {
454
+ method: 'POST',
455
+ body: JSON.stringify({ agents }),
723
456
  });
724
-
725
- const line = `${JSON.stringify(message)}\n`;
726
- if (!this.child.stdin.write(line)) {
727
- await once(this.child.stdin, 'drain');
728
- }
729
-
730
- return responsePromise;
731
457
  }
732
- }
733
-
734
- const CLI_MODEL_FLAG_CLIS = new Set(['claude', 'codex', 'gemini', 'goose', 'aider']);
735
458
 
736
- const CLI_DEFAULT_ARGS: Record<string, string[]> = {
737
- codex: ['-c', 'check_for_update_on_startup=false'],
738
- };
739
-
740
- function buildPtyArgsWithModel(cli: string, args: string[], model?: string): string[] {
741
- const cliName = cli.split(':')[0].trim().toLowerCase();
742
- const defaultArgs = CLI_DEFAULT_ARGS[cliName] ?? [];
743
- const baseArgs = [...defaultArgs, ...args];
744
- if (!model) {
745
- return baseArgs;
746
- }
747
- if (!CLI_MODEL_FLAG_CLIS.has(cliName)) {
748
- return baseArgs;
749
- }
750
- if (hasModelArg(baseArgs)) {
751
- return baseArgs;
459
+ async renewLease(): Promise<{ renewed: boolean; expires_in_secs: number }> {
460
+ return this.transport.request('/api/session/renew', { method: 'POST' });
752
461
  }
753
- return ['--model', model, ...baseArgs];
754
- }
755
462
 
756
- function hasModelArg(args: string[]): boolean {
757
- for (let i = 0; i < args.length; i += 1) {
758
- const arg = args[i];
759
- if (arg === '--model') {
760
- return true;
761
- }
762
- if (arg.startsWith('--model=')) {
763
- return true;
463
+ /**
464
+ * Shut down and clean up.
465
+ * - For spawned brokers (via .spawn()): sends POST /api/shutdown to kill the broker, waits for exit.
466
+ * - For connected brokers (via .connect() or constructor): just disconnects the transport.
467
+ * Does NOT kill the broker — the caller doesn't own it.
468
+ */
469
+ async shutdown(): Promise<void> {
470
+ if (this.leaseTimer) {
471
+ clearInterval(this.leaseTimer);
472
+ this.leaseTimer = null;
764
473
  }
765
- }
766
- return false;
767
- }
768
-
769
- function expandTilde(p: string): string {
770
- if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
771
- const home = os.homedir();
772
- return path.join(home, p.slice(2));
773
- }
774
- return p;
775
- }
776
-
777
- function isExplicitPath(binaryPath: string): boolean {
778
- return (
779
- binaryPath.includes('/') ||
780
- binaryPath.includes('\\') ||
781
- binaryPath.startsWith('.') ||
782
- binaryPath.startsWith('~')
783
- );
784
- }
785
-
786
- function detectPlatformSuffix(): string | null {
787
- const platformMap: Record<string, Record<string, string>> = {
788
- darwin: { arm64: 'darwin-arm64', x64: 'darwin-x64' },
789
- linux: { arm64: 'linux-arm64', x64: 'linux-x64' },
790
- win32: { x64: 'win32-x64' },
791
- };
792
- return platformMap[process.platform]?.[process.arch] ?? null;
793
- }
794
-
795
- function getLatestVersionSync(): string | null {
796
- try {
797
- const result = execSync('curl -fsSL https://api.github.com/repos/AgentWorkforce/relay/releases/latest', {
798
- timeout: 15_000,
799
- stdio: ['pipe', 'pipe', 'pipe'],
800
- }).toString();
801
- const match = result.match(/"tag_name"\s*:\s*"([^"]+)"/);
802
- if (!match?.[1]) return null;
803
- // Strip tag prefixes: "openclaw-v3.1.18" -> "3.1.18", "v3.1.18" -> "3.1.18"
804
- return match[1].replace(/^openclaw-/, '').replace(/^v/, '');
805
- } catch {
806
- return null;
807
- }
808
- }
809
-
810
- function installBrokerBinary(): string {
811
- const suffix = detectPlatformSuffix();
812
- if (!suffix) {
813
- throw new AgentRelayProcessError(`Unsupported platform: ${process.platform}-${process.arch}`);
814
- }
815
474
 
816
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
817
- const installDir = path.join(homeDir, '.agent-relay', 'bin');
818
- const brokerExe = process.platform === 'win32' ? 'agent-relay-broker.exe' : 'agent-relay-broker';
819
- const targetPath = path.join(installDir, brokerExe);
820
-
821
- console.log(`[agent-relay] Broker binary not found, installing for ${suffix}...`);
822
-
823
- const version = getLatestVersionSync();
824
- if (!version) {
825
- throw new AgentRelayProcessError(
826
- 'Failed to fetch latest agent-relay version from GitHub.\n' +
827
- 'Install manually: curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'
828
- );
829
- }
830
-
831
- const binaryName = `agent-relay-broker-${suffix}`;
832
- const downloadUrl = `https://github.com/AgentWorkforce/relay/releases/download/v${version}/${binaryName}`;
833
-
834
- console.log(`[agent-relay] Downloading v${version} from ${downloadUrl}`);
835
-
836
- try {
837
- fs.mkdirSync(installDir, { recursive: true });
838
- execSync(`curl -fsSL "${downloadUrl}" -o "${targetPath}"`, {
839
- timeout: 60_000,
840
- stdio: ['pipe', 'pipe', 'pipe'],
841
- });
842
- fs.chmodSync(targetPath, 0o755);
843
-
844
- // macOS: strip quarantine attribute and re-sign to avoid Gatekeeper issues
845
- if (process.platform === 'darwin') {
846
- try {
847
- execSync(`xattr -d com.apple.quarantine "${targetPath}" 2>/dev/null || true`, {
848
- timeout: 10_000,
849
- stdio: ['pipe', 'pipe', 'pipe'],
850
- });
851
- } catch {
852
- // Non-fatal
853
- }
475
+ // Only send the shutdown command if we own the broker process
476
+ if (this.child) {
854
477
  try {
855
- execSync(`codesign --force --sign - "${targetPath}"`, {
856
- timeout: 10_000,
857
- stdio: ['pipe', 'pipe', 'pipe'],
858
- });
478
+ await this.transport.request('/api/shutdown', { method: 'POST' });
859
479
  } catch {
860
- // Non-fatal
480
+ // Broker may already be dead
861
481
  }
862
482
  }
863
483
 
864
- // Verify
865
- execSync(`"${targetPath}" --help`, { timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] });
866
- } catch (err) {
867
- try {
868
- fs.unlinkSync(targetPath);
869
- } catch {
870
- /* ignore */
871
- }
872
- const message = err instanceof Error ? err.message : String(err);
873
- throw new AgentRelayProcessError(
874
- `Failed to install broker binary: ${message}\n` +
875
- 'Install manually: curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash'
876
- );
877
- }
878
-
879
- console.log(`[agent-relay] Broker installed to ${targetPath}`);
880
- return targetPath;
881
- }
882
-
883
- function resolveDefaultBinaryPath(): string {
884
- const brokerExe = process.platform === 'win32' ? 'agent-relay-broker.exe' : 'agent-relay-broker';
885
- const moduleDir = path.dirname(fileURLToPath(import.meta.url));
484
+ this.transport.disconnect();
886
485
 
887
- // 1. In a source checkout, prefer Cargo's release binary to avoid stale bundled
888
- // copies when local dev rebuilds happen while broker processes are running.
889
- const workspaceRelease = path.resolve(moduleDir, '..', '..', '..', 'target', 'release', brokerExe);
890
- if (fs.existsSync(workspaceRelease)) {
891
- return workspaceRelease;
486
+ if (this.child) {
487
+ await waitForExit(this.child, 5000);
488
+ this.child = null;
489
+ }
892
490
  }
893
491
 
894
- // 2. Check for bundled platform-specific broker binary in SDK package (npm install).
895
- // Only use binaries that match the current platform to avoid running
896
- // e.g. a macOS binary on Linux (or vice-versa).
897
- const binDir = path.resolve(moduleDir, '..', 'bin');
898
- const suffix = detectPlatformSuffix();
899
- if (suffix) {
900
- const ext = process.platform === 'win32' ? '.exe' : '';
901
- const platformBinary = path.join(binDir, `agent-relay-broker-${suffix}${ext}`);
902
- if (fs.existsSync(platformBinary)) {
903
- return platformBinary;
492
+ /** Disconnect without shutting down the broker. Alias for cases where the intent is clear. */
493
+ disconnect(): void {
494
+ if (this.leaseTimer) {
495
+ clearInterval(this.leaseTimer);
496
+ this.leaseTimer = null;
904
497
  }
498
+ this.transport.disconnect();
905
499
  }
906
500
 
907
- // 3. Check for standalone broker binary in ~/.agent-relay/bin/ (install.sh)
908
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
909
- const standaloneBroker = path.join(homeDir, '.agent-relay', 'bin', brokerExe);
910
- if (fs.existsSync(standaloneBroker)) {
911
- return standaloneBroker;
501
+ async getConfig(): Promise<{ workspaceKey?: string }> {
502
+ return this.transport.request('/api/config');
912
503
  }
913
-
914
- // 4. Auto-install from GitHub releases
915
- return installBrokerBinary();
916
504
  }
917
505
 
918
- // ---------------------------------------------------------------------------
919
- // HTTP transport client — connects to an already-running broker's HTTP API
920
- // ---------------------------------------------------------------------------
506
+ // ── Helpers ──────────────────────────────────────────────────────────────
921
507
 
922
- export interface HttpAgentRelayClientOptions {
923
- port: number;
924
- apiKey?: string;
925
- }
508
+ /**
509
+ * Parse the API URL from the broker's stdout. The broker prints:
510
+ * [agent-relay] API listening on http://{bind}:{port}
511
+ * Returns the full URL (e.g. "http://127.0.0.1:3889").
512
+ */
513
+ async function waitForApiUrl(child: ChildProcess, timeoutMs: number): Promise<string> {
514
+ const { createInterface } = await import('node:readline');
926
515
 
927
- export interface DiscoverAndConnectOptions {
928
- cwd?: string;
929
- apiKey?: string;
930
- /** Auto-start the broker if not running (default: false). */
931
- autoStart?: boolean;
932
- /**
933
- * Path to the broker binary for auto-start.
934
- * If not provided, the SDK resolves it automatically via standard install locations
935
- * (~/.agent-relay/bin, bundled platform binary, or Cargo release build).
936
- * Only used when `autoStart: true`.
937
- */
938
- brokerBinaryPath?: string;
939
- }
940
-
941
- const DEFAULT_DASHBOARD_PORT = (() => {
942
- const envPort = typeof process !== 'undefined' ? process.env.AGENT_RELAY_DASHBOARD_PORT : undefined;
943
- if (envPort) {
944
- const parsed = Number.parseInt(envPort, 10);
945
- if (Number.isFinite(parsed) && parsed > 0) return parsed;
946
- }
947
- return 3888;
948
- })();
949
- const HTTP_MAX_PORT_SCAN = 25;
950
- const HTTP_AUTOSTART_TIMEOUT_MS = 10_000;
951
- const HTTP_AUTOSTART_POLL_MS = 250;
952
-
953
- function sanitizeBrokerName(name: string): string {
954
- return name.replace(/[^\p{L}\p{N}-]/gu, '-');
955
- }
956
-
957
- function brokerPidFilename(projectRoot: string): string {
958
- const brokerName = path.basename(projectRoot) || 'project';
959
- return `broker-${sanitizeBrokerName(brokerName)}.pid`;
960
- }
961
-
962
- export class HttpAgentRelayClient {
963
- private readonly port: number;
964
- private readonly apiKey?: string;
965
-
966
- constructor(options: HttpAgentRelayClientOptions) {
967
- this.port = options.port;
968
- this.apiKey = options.apiKey;
969
- }
970
-
971
- /**
972
- * Connect to an already-running broker on the given port.
973
- */
974
- static async connectHttp(
975
- port: number,
976
- options?: { apiKey?: string }
977
- ): Promise<HttpAgentRelayClient> {
978
- const client = new HttpAgentRelayClient({ port, apiKey: options?.apiKey });
979
- // Verify connectivity
980
- await client.healthCheck();
981
- return client;
982
- }
983
-
984
- /**
985
- * Discover a running broker for the current project and connect to it.
986
- * Reads the broker PID file, verifies the process is alive, scans ports
987
- * for the HTTP API, and returns a connected client.
988
- */
989
- static async discoverAndConnect(
990
- options?: DiscoverAndConnectOptions
991
- ): Promise<HttpAgentRelayClient> {
992
- const cwd = options?.cwd ?? process.cwd();
993
- const apiKey = options?.apiKey ?? process.env.RELAY_BROKER_API_KEY?.trim();
994
- const autoStart = options?.autoStart ?? false;
995
- const paths = getProjectPaths(cwd);
996
- const preferredApiPort = DEFAULT_DASHBOARD_PORT + 1;
997
-
998
- // Try to find a running broker via PID file
999
- const pidFilePath = path.join(paths.dataDir, brokerPidFilename(paths.projectRoot));
1000
- const legacyPidPath = path.join(paths.dataDir, 'broker.pid');
1001
- let brokerRunning = false;
1002
-
1003
- for (const pidPath of [pidFilePath, legacyPidPath]) {
1004
- if (fs.existsSync(pidPath)) {
1005
- const pidStr = fs.readFileSync(pidPath, 'utf-8').trim();
1006
- const pid = Number.parseInt(pidStr, 10);
1007
- if (Number.isFinite(pid) && pid > 0) {
1008
- try {
1009
- process.kill(pid, 0);
1010
- brokerRunning = true;
1011
- break;
1012
- } catch {
1013
- // Process not running
1014
- }
1015
- }
1016
- }
516
+ return new Promise<string>((resolve, reject) => {
517
+ if (!child.stdout) {
518
+ reject(new Error('Broker stdout not available'));
519
+ return;
1017
520
  }
1018
521
 
1019
- if (brokerRunning) {
1020
- const port = await HttpAgentRelayClient.scanForBrokerPort(preferredApiPort);
1021
- if (port !== null) {
1022
- return new HttpAgentRelayClient({ port, apiKey });
1023
- }
1024
- throw new AgentRelayProcessError(
1025
- 'broker is running for this project, but its local API is unavailable'
1026
- );
1027
- }
522
+ let resolved = false;
523
+ const rl = createInterface({ input: child.stdout });
1028
524
 
1029
- if (!autoStart) {
1030
- throw new AgentRelayProcessError('broker is not running for this project');
1031
- }
1032
-
1033
- // Auto-start the broker using the resolved binary path (not process.argv[1],
1034
- // which only works from CLI context — breaks when SDK is imported by user apps).
1035
- // The broker binary requires the `init` subcommand with `--api-port` and
1036
- // `--persist` so it writes PID files for subsequent discovery.
1037
- const brokerBinary = options?.brokerBinaryPath ?? resolveDefaultBinaryPath();
1038
-
1039
- const child = spawn(
1040
- brokerBinary,
1041
- ['init', '--persist', '--api-port', String(preferredApiPort)],
1042
- {
1043
- cwd: paths.projectRoot,
1044
- env: process.env,
1045
- detached: true,
1046
- stdio: 'ignore',
525
+ const timer = setTimeout(() => {
526
+ if (!resolved) {
527
+ resolved = true;
528
+ rl.close();
529
+ reject(new Error(`Broker did not report API port within ${timeoutMs}ms`));
1047
530
  }
1048
- );
1049
- child.unref();
1050
-
1051
- const startedAt = Date.now();
1052
- while (Date.now() - startedAt < HTTP_AUTOSTART_TIMEOUT_MS) {
1053
- const port = await HttpAgentRelayClient.scanForBrokerPort(preferredApiPort);
1054
- if (port !== null) {
1055
- return new HttpAgentRelayClient({ port, apiKey });
531
+ }, timeoutMs);
532
+
533
+ child.on('exit', (code) => {
534
+ if (!resolved) {
535
+ resolved = true;
536
+ clearTimeout(timer);
537
+ rl.close();
538
+ reject(new Error(`Broker process exited with code ${code} before becoming ready`));
1056
539
  }
1057
- await new Promise((resolve) => setTimeout(resolve, HTTP_AUTOSTART_POLL_MS));
1058
- }
1059
-
1060
- throw new AgentRelayProcessError(
1061
- `broker did not become ready within ${HTTP_AUTOSTART_TIMEOUT_MS}ms`
1062
- );
1063
- }
540
+ });
1064
541
 
1065
- private static async scanForBrokerPort(startPort: number): Promise<number | null> {
1066
- for (let i = 0; i < HTTP_MAX_PORT_SCAN; i++) {
1067
- const port = startPort + i;
1068
- try {
1069
- const res = await fetch(`http://127.0.0.1:${port}/health`);
1070
- if (!res.ok) continue;
1071
- const payload = (await res.json().catch(() => null)) as { service?: string } | null;
1072
- if (payload?.service === 'agent-relay-listen') {
1073
- return port;
1074
- }
1075
- } catch {
1076
- // Keep scanning
542
+ child.on('error', (err) => {
543
+ if (!resolved) {
544
+ resolved = true;
545
+ clearTimeout(timer);
546
+ rl.close();
547
+ reject(new Error(`Failed to start broker: ${err.message}`));
1077
548
  }
1078
- }
1079
- return null;
1080
- }
1081
-
1082
- private async request<T = unknown>(pathname: string, init?: RequestInit): Promise<T> {
1083
- const headers = new Headers(init?.headers);
1084
- if (this.apiKey && !headers.has('x-api-key') && !headers.has('authorization')) {
1085
- headers.set('x-api-key', this.apiKey);
1086
- }
1087
-
1088
- const response = await fetch(`http://127.0.0.1:${this.port}${pathname}`, {
1089
- ...init,
1090
- headers,
1091
549
  });
1092
550
 
1093
- const text = await response.text();
1094
- let payload: unknown;
1095
- try {
1096
- payload = text ? JSON.parse(text) : undefined;
1097
- } catch {
1098
- payload = text;
1099
- }
1100
-
1101
- if (!response.ok) {
1102
- const msg = HttpAgentRelayClient.extractErrorMessage(response, payload);
1103
- throw new AgentRelayProcessError(msg);
1104
- }
1105
-
1106
- return payload as T;
1107
- }
1108
-
1109
- private static extractErrorMessage(response: Response, payload: unknown): string {
1110
- if (typeof payload === 'string' && payload.trim()) return payload.trim();
1111
- const p = payload as Record<string, unknown> | undefined;
1112
- if (typeof p?.error === 'string') return p.error;
1113
- if (typeof (p?.error as Record<string, unknown>)?.message === 'string')
1114
- return (p!.error as Record<string, unknown>).message as string;
1115
- if (typeof p?.message === 'string' && (p.message as string).trim())
1116
- return (p.message as string).trim();
1117
- return `${response.status} ${response.statusText}`.trim();
1118
- }
1119
-
1120
- async healthCheck(): Promise<{ service: string }> {
1121
- return this.request<{ service: string }>('/health');
1122
- }
1123
-
1124
- /** No-op — broker is already running. */
1125
- async start(): Promise<void> {}
1126
-
1127
- /** No-op — don't kill an externally-managed broker. */
1128
- async shutdown(): Promise<void> {}
551
+ rl.on('line', (line) => {
552
+ if (resolved) return;
1129
553
 
1130
- async spawnPty(input: SpawnPtyInput): Promise<{ name: string; runtime: AgentRuntime }> {
1131
- const payload = await this.request<{ name?: string }>('/api/spawn', {
1132
- method: 'POST',
1133
- headers: { 'content-type': 'application/json' },
1134
- body: JSON.stringify({
1135
- name: input.name,
1136
- cli: input.cli,
1137
- model: input.model,
1138
- args: input.args ?? [],
1139
- task: input.task,
1140
- channels: input.channels ?? [],
1141
- cwd: input.cwd,
1142
- team: input.team,
1143
- shadowOf: input.shadowOf,
1144
- shadowMode: input.shadowMode,
1145
- continueFrom: input.continueFrom,
1146
- idleThresholdSecs: input.idleThresholdSecs,
1147
- restartPolicy: input.restartPolicy,
1148
- skipRelayPrompt: input.skipRelayPrompt,
1149
- }),
554
+ const match = line.match(/API listening on (https?:\/\/[^\s]+)/);
555
+ if (match) {
556
+ resolved = true;
557
+ clearTimeout(timer);
558
+ rl.close();
559
+ resolve(match[1]);
560
+ }
1150
561
  });
1151
- return {
1152
- name: typeof payload?.name === 'string' ? payload.name : input.name,
1153
- runtime: 'pty',
1154
- };
1155
- }
562
+ });
563
+ }
1156
564
 
1157
- async sendMessage(input: SendMessageInput): Promise<{ event_id: string; targets: string[] }> {
1158
- return this.request<{ event_id: string; targets: string[] }>('/api/send', {
1159
- method: 'POST',
1160
- headers: { 'content-type': 'application/json' },
1161
- body: JSON.stringify({
1162
- to: input.to,
1163
- text: input.text,
1164
- from: input.from,
1165
- threadId: input.threadId,
1166
- workspaceId: input.workspaceId,
1167
- workspaceAlias: input.workspaceAlias,
1168
- priority: input.priority,
1169
- data: input.data,
1170
- mode: input.mode,
1171
- }),
565
+ function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
566
+ return new Promise((resolve) => {
567
+ if (child.exitCode !== null) {
568
+ resolve();
569
+ return;
570
+ }
571
+ const timer = setTimeout(() => {
572
+ child.kill('SIGKILL');
573
+ resolve();
574
+ }, timeoutMs);
575
+ child.on('exit', () => {
576
+ clearTimeout(timer);
577
+ resolve();
1172
578
  });
1173
- }
1174
-
1175
- async listAgents(): Promise<ListAgent[]> {
1176
- const payload = await this.request<{ agents?: ListAgent[] }>('/api/spawned', { method: 'GET' });
1177
- return Array.isArray(payload?.agents) ? payload.agents : [];
1178
- }
1179
-
1180
- async release(name: string, reason?: string): Promise<{ name: string }> {
1181
- const payload = await this.request<{ name?: string }>(
1182
- `/api/spawned/${encodeURIComponent(name)}`,
1183
- {
1184
- method: 'DELETE',
1185
- ...(reason
1186
- ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason }) }
1187
- : {}),
1188
- }
1189
- );
1190
- return { name: typeof payload?.name === 'string' ? payload.name : name };
1191
- }
1192
-
1193
- async subscribeChannels(_name: string, _channels: string[]): Promise<void> {
1194
- throw new Error(
1195
- 'subscribeChannels is only available via the broker protocol (BrokerAgentRelayClient). ' +
1196
- 'The HTTP API does not support dynamic channel subscription.'
1197
- );
1198
- }
1199
-
1200
- async unsubscribeChannels(_name: string, _channels: string[]): Promise<void> {
1201
- throw new Error(
1202
- 'unsubscribeChannels is only available via the broker protocol (BrokerAgentRelayClient). ' +
1203
- 'The HTTP API does not support dynamic channel unsubscription.'
1204
- );
1205
- }
1206
-
1207
- async setModel(
1208
- name: string,
1209
- model: string,
1210
- opts?: { timeoutMs?: number }
1211
- ): Promise<{ name: string; model: string; success: boolean }> {
1212
- const payload = await this.request<{ success?: boolean; model?: string }>(
1213
- `/api/spawned/${encodeURIComponent(name)}/model`,
1214
- {
1215
- method: 'POST',
1216
- headers: { 'content-type': 'application/json' },
1217
- body: JSON.stringify({ model, timeoutMs: opts?.timeoutMs }),
1218
- }
1219
- );
1220
- return {
1221
- name,
1222
- model: typeof payload?.model === 'string' ? payload.model : model,
1223
- success: payload?.success !== false,
1224
- };
1225
- }
1226
-
1227
- async getConfig(): Promise<{ workspace_key?: string }> {
1228
- return this.request<{ workspace_key?: string }>('/api/config');
1229
- }
579
+ });
1230
580
  }