@agent-relay/sdk 3.2.22 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/broker-path.d.ts +3 -2
- package/dist/broker-path.d.ts.map +1 -1
- package/dist/broker-path.js +119 -32
- package/dist/broker-path.js.map +1 -1
- package/dist/client.d.ts +119 -197
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +354 -823
- package/dist/client.js.map +1 -1
- package/dist/examples/example.js +2 -5
- package/dist/examples/example.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/relay-adapter.d.ts +9 -26
- package/dist/relay-adapter.d.ts.map +1 -1
- package/dist/relay-adapter.js +75 -47
- package/dist/relay-adapter.js.map +1 -1
- package/dist/relay.d.ts +26 -6
- package/dist/relay.d.ts.map +1 -1
- package/dist/relay.js +213 -43
- package/dist/relay.js.map +1 -1
- package/dist/transport.d.ts +58 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +184 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workflows/__tests__/channel-messenger.test.d.ts +2 -0
- package/dist/workflows/__tests__/channel-messenger.test.d.ts.map +1 -0
- package/dist/workflows/__tests__/channel-messenger.test.js +117 -0
- package/dist/workflows/__tests__/channel-messenger.test.js.map +1 -0
- package/dist/workflows/__tests__/run-summary-table.test.js +4 -3
- package/dist/workflows/__tests__/run-summary-table.test.js.map +1 -1
- package/dist/workflows/__tests__/step-executor.test.d.ts +2 -0
- package/dist/workflows/__tests__/step-executor.test.d.ts.map +1 -0
- package/dist/workflows/__tests__/step-executor.test.js +378 -0
- package/dist/workflows/__tests__/step-executor.test.js.map +1 -0
- package/dist/workflows/__tests__/template-resolver.test.d.ts +2 -0
- package/dist/workflows/__tests__/template-resolver.test.d.ts.map +1 -0
- package/dist/workflows/__tests__/template-resolver.test.js +145 -0
- package/dist/workflows/__tests__/template-resolver.test.js.map +1 -0
- package/dist/workflows/__tests__/verification.test.d.ts +2 -0
- package/dist/workflows/__tests__/verification.test.d.ts.map +1 -0
- package/dist/workflows/__tests__/verification.test.js +170 -0
- package/dist/workflows/__tests__/verification.test.js.map +1 -0
- package/dist/workflows/builder.d.ts +3 -2
- package/dist/workflows/builder.d.ts.map +1 -1
- package/dist/workflows/builder.js +1 -3
- package/dist/workflows/builder.js.map +1 -1
- package/dist/workflows/channel-messenger.d.ts +28 -0
- package/dist/workflows/channel-messenger.d.ts.map +1 -0
- package/dist/workflows/channel-messenger.js +255 -0
- package/dist/workflows/channel-messenger.js.map +1 -0
- package/dist/workflows/index.d.ts +7 -0
- package/dist/workflows/index.d.ts.map +1 -1
- package/dist/workflows/index.js +7 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/process-spawner.d.ts +35 -0
- package/dist/workflows/process-spawner.d.ts.map +1 -0
- package/dist/workflows/process-spawner.js +141 -0
- package/dist/workflows/process-spawner.js.map +1 -0
- package/dist/workflows/run.d.ts +2 -1
- package/dist/workflows/run.d.ts.map +1 -1
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/runner.d.ts +6 -6
- package/dist/workflows/runner.d.ts.map +1 -1
- package/dist/workflows/runner.js +443 -719
- package/dist/workflows/runner.js.map +1 -1
- package/dist/workflows/step-executor.d.ts +95 -0
- package/dist/workflows/step-executor.d.ts.map +1 -0
- package/dist/workflows/step-executor.js +393 -0
- package/dist/workflows/step-executor.js.map +1 -0
- package/dist/workflows/template-resolver.d.ts +33 -0
- package/dist/workflows/template-resolver.d.ts.map +1 -0
- package/dist/workflows/template-resolver.js +144 -0
- package/dist/workflows/template-resolver.js.map +1 -0
- package/dist/workflows/validator.d.ts.map +1 -1
- package/dist/workflows/validator.js +17 -2
- package/dist/workflows/validator.js.map +1 -1
- package/dist/workflows/verification.d.ts +33 -0
- package/dist/workflows/verification.d.ts.map +1 -0
- package/dist/workflows/verification.js +122 -0
- package/dist/workflows/verification.js.map +1 -0
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,211 +1,242 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
15
|
import path from 'node:path';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
code;
|
|
12
|
-
retryable;
|
|
13
|
-
data;
|
|
14
|
-
constructor(payload) {
|
|
15
|
-
super(payload.message);
|
|
16
|
-
this.name = 'AgentRelayProtocolError';
|
|
17
|
-
this.code = payload.code;
|
|
18
|
-
this.retryable = payload.retryable;
|
|
19
|
-
this.data = payload.data;
|
|
20
|
-
}
|
|
16
|
+
import { BrokerTransport, AgentRelayProtocolError } from './transport.js';
|
|
17
|
+
import { getBrokerBinaryPath } from './broker-path.js';
|
|
18
|
+
function isHeadlessProvider(value) {
|
|
19
|
+
return value === 'claude' || value === 'opencode';
|
|
21
20
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
function resolveSpawnTransport(input) {
|
|
22
|
+
return input.transport ?? (input.provider === 'opencode' ? 'headless' : 'pty');
|
|
23
|
+
}
|
|
24
|
+
function isProcessRunning(pid) {
|
|
25
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return error.code === 'EPERM';
|
|
26
34
|
}
|
|
27
35
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
36
|
+
function buildBrokerInitArgs(args) {
|
|
37
|
+
if (!args) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const cliArgs = [];
|
|
41
|
+
if (args.persist) {
|
|
42
|
+
cliArgs.push('--persist');
|
|
43
|
+
}
|
|
44
|
+
if (args.apiPort !== undefined) {
|
|
45
|
+
cliArgs.push('--api-port', String(args.apiPort));
|
|
46
|
+
}
|
|
47
|
+
if (args.apiBind !== undefined) {
|
|
48
|
+
cliArgs.push('--api-bind', args.apiBind);
|
|
49
|
+
}
|
|
50
|
+
if (args.stateDir !== undefined) {
|
|
51
|
+
cliArgs.push('--state-dir', args.stateDir);
|
|
52
|
+
}
|
|
53
|
+
return cliArgs;
|
|
30
54
|
}
|
|
55
|
+
// ── Client ─────────────────────────────────────────────────────────────
|
|
31
56
|
export class AgentRelayClient {
|
|
32
|
-
|
|
33
|
-
child
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
requestSeq = 0;
|
|
38
|
-
pending = new Map();
|
|
39
|
-
startingPromise;
|
|
40
|
-
eventListeners = new Set();
|
|
41
|
-
stderrListeners = new Set();
|
|
42
|
-
eventBuffer = [];
|
|
43
|
-
maxBufferSize = 1000;
|
|
44
|
-
exitPromise;
|
|
45
|
-
/** The workspace key returned by the broker in its hello_ack response. */
|
|
57
|
+
transport;
|
|
58
|
+
/** Set after spawn() — the managed child process. */
|
|
59
|
+
child = null;
|
|
60
|
+
/** Lease renewal timer (only for spawned brokers). */
|
|
61
|
+
leaseTimer = null;
|
|
46
62
|
workspaceKey;
|
|
47
|
-
constructor(options
|
|
48
|
-
this.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
cwd: options.cwd ?? process.cwd(),
|
|
54
|
-
env: options.env ?? process.env,
|
|
55
|
-
requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
|
|
56
|
-
shutdownTimeoutMs: options.shutdownTimeoutMs ?? 3_000,
|
|
57
|
-
clientName: options.clientName ?? '@agent-relay/sdk',
|
|
58
|
-
clientVersion: options.clientVersion ?? '0.1.0',
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
static async start(options = {}) {
|
|
62
|
-
const client = new AgentRelayClient(options);
|
|
63
|
-
await client.start();
|
|
64
|
-
return client;
|
|
65
|
-
}
|
|
66
|
-
onEvent(listener) {
|
|
67
|
-
this.eventListeners.add(listener);
|
|
68
|
-
return () => {
|
|
69
|
-
this.eventListeners.delete(listener);
|
|
70
|
-
};
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.transport = new BrokerTransport({
|
|
65
|
+
baseUrl: options.baseUrl,
|
|
66
|
+
apiKey: options.apiKey,
|
|
67
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
68
|
+
});
|
|
71
69
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Connect to an already-running broker by reading its connection file.
|
|
72
|
+
*
|
|
73
|
+
* The broker writes `connection.json` to its data directory ({cwd}/.agent-relay/
|
|
74
|
+
* in persist mode). This method reads that file to get the URL and API key.
|
|
75
|
+
*
|
|
76
|
+
* @param cwd — project directory (default: process.cwd())
|
|
77
|
+
* @param connectionPath — explicit path to connection.json (overrides cwd)
|
|
78
|
+
*/
|
|
79
|
+
static connect(options) {
|
|
80
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
81
|
+
const stateDir = process.env.AGENT_RELAY_STATE_DIR;
|
|
82
|
+
const connPath = options?.connectionPath ?? path.join(stateDir ?? path.join(cwd, '.agent-relay'), 'connection.json');
|
|
83
|
+
if (!existsSync(connPath)) {
|
|
84
|
+
throw new Error(`No running broker found (${connPath} does not exist). Start one with 'agent-relay up' or use AgentRelayClient.spawn().`);
|
|
76
85
|
}
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
const raw = readFileSync(connPath, 'utf-8');
|
|
87
|
+
let conn;
|
|
88
|
+
try {
|
|
89
|
+
conn = JSON.parse(raw);
|
|
79
90
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
events = events.filter((event) => 'timestamp' in event && typeof event.timestamp === 'number' && event.timestamp >= since);
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error(`Corrupt broker connection file (${connPath}). Remove it and start the broker again.`);
|
|
83
93
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
events = events.slice(-limit);
|
|
94
|
+
if (typeof conn.url !== 'string' || typeof conn.api_key !== 'string' || typeof conn.pid !== 'number') {
|
|
95
|
+
throw new Error(`Invalid broker connection metadata in ${connPath}. Remove it and start the broker again.`);
|
|
87
96
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
getLastEvent(kind, name) {
|
|
91
|
-
for (let i = this.eventBuffer.length - 1; i >= 0; i -= 1) {
|
|
92
|
-
const event = this.eventBuffer[i];
|
|
93
|
-
if (event.kind === kind && (!name || ('name' in event && event.name === name))) {
|
|
94
|
-
return event;
|
|
95
|
-
}
|
|
97
|
+
if (!isProcessRunning(conn.pid)) {
|
|
98
|
+
throw new Error(`Stale broker connection file (${connPath}) points to dead pid ${conn.pid}. Start the broker with 'agent-relay up' or use AgentRelayClient.spawn().`);
|
|
96
99
|
}
|
|
97
|
-
return
|
|
100
|
+
return new AgentRelayClient({ baseUrl: conn.url, apiKey: conn.api_key });
|
|
98
101
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Spawn a local broker process and return a connected client.
|
|
104
|
+
*
|
|
105
|
+
* 1. Generates a random API key
|
|
106
|
+
* 2. Spawns the broker binary (attached)
|
|
107
|
+
* 3. Parses the API port from stdout
|
|
108
|
+
* 4. Connects HTTP/WS transport
|
|
109
|
+
* 5. Fetches session metadata
|
|
110
|
+
* 6. Starts event stream + lease renewal
|
|
111
|
+
*/
|
|
112
|
+
static async spawn(options) {
|
|
113
|
+
const binaryPath = options?.binaryPath ?? getBrokerBinaryPath() ?? 'agent-relay-broker';
|
|
114
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
115
|
+
const brokerName = options?.brokerName ?? (path.basename(cwd) || 'project');
|
|
116
|
+
const channels = options?.channels ?? ['general'];
|
|
117
|
+
const timeoutMs = options?.startupTimeoutMs ?? 15_000;
|
|
118
|
+
const userArgs = buildBrokerInitArgs(options?.binaryArgs);
|
|
119
|
+
const apiKey = `br_${randomBytes(16).toString('hex')}`;
|
|
120
|
+
const env = {
|
|
121
|
+
...process.env,
|
|
122
|
+
...options?.env,
|
|
123
|
+
RELAY_BROKER_API_KEY: apiKey,
|
|
103
124
|
};
|
|
125
|
+
const args = ['init', '--name', brokerName, '--channels', channels.join(','), ...userArgs];
|
|
126
|
+
const child = spawn(binaryPath, args, {
|
|
127
|
+
cwd,
|
|
128
|
+
env,
|
|
129
|
+
stdio: ['ignore', 'pipe', options?.onStderr ? 'pipe' : 'ignore'],
|
|
130
|
+
});
|
|
131
|
+
// Forward stderr if requested
|
|
132
|
+
if (options?.onStderr && child.stderr) {
|
|
133
|
+
const { createInterface } = await import('node:readline');
|
|
134
|
+
const rl = createInterface({ input: child.stderr });
|
|
135
|
+
rl.on('line', (line) => options.onStderr(line));
|
|
136
|
+
}
|
|
137
|
+
// Parse the API URL from stdout (the broker prints it after binding)
|
|
138
|
+
const baseUrl = await waitForApiUrl(child, timeoutMs);
|
|
139
|
+
const client = new AgentRelayClient({
|
|
140
|
+
baseUrl,
|
|
141
|
+
apiKey,
|
|
142
|
+
requestTimeoutMs: options?.requestTimeoutMs,
|
|
143
|
+
});
|
|
144
|
+
client.child = child;
|
|
145
|
+
await client.getSession();
|
|
146
|
+
client.connectEvents();
|
|
147
|
+
// Renew the owner lease so the broker doesn't auto-shutdown
|
|
148
|
+
client.leaseTimer = setInterval(() => {
|
|
149
|
+
client.renewLease().catch(() => { });
|
|
150
|
+
}, 60_000);
|
|
151
|
+
child.on('exit', () => {
|
|
152
|
+
client.disconnectEvents();
|
|
153
|
+
if (client.leaseTimer) {
|
|
154
|
+
clearInterval(client.leaseTimer);
|
|
155
|
+
client.leaseTimer = null;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return client;
|
|
104
159
|
}
|
|
160
|
+
/** PID of the managed broker process, if spawned locally. */
|
|
105
161
|
get brokerPid() {
|
|
106
162
|
return this.child?.pid;
|
|
107
163
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return this.startingPromise;
|
|
114
|
-
}
|
|
115
|
-
this.startingPromise = this.startInternal();
|
|
116
|
-
try {
|
|
117
|
-
await this.startingPromise;
|
|
118
|
-
}
|
|
119
|
-
finally {
|
|
120
|
-
this.startingPromise = undefined;
|
|
121
|
-
}
|
|
164
|
+
// ── Session ────────────────────────────────────────────────────────
|
|
165
|
+
async getSession() {
|
|
166
|
+
const session = await this.transport.request('/api/session');
|
|
167
|
+
this.workspaceKey = session.workspace_key;
|
|
168
|
+
return session;
|
|
122
169
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
* The broker warms its token cache in parallel; subsequent spawn_agent calls
|
|
126
|
-
* hit the cache rather than waiting on individual HTTP registrations.
|
|
127
|
-
* Fire-and-forget from the caller's perspective — broker responds immediately
|
|
128
|
-
* and registers in the background.
|
|
129
|
-
*/
|
|
130
|
-
async preflightAgents(agents) {
|
|
131
|
-
if (agents.length === 0)
|
|
132
|
-
return;
|
|
133
|
-
await this.start();
|
|
134
|
-
await this.requestOk('preflight_agents', { agents });
|
|
170
|
+
async healthCheck() {
|
|
171
|
+
return this.transport.request('/health');
|
|
135
172
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const agent = {
|
|
140
|
-
name: input.name,
|
|
141
|
-
runtime: 'pty',
|
|
142
|
-
cli: input.cli,
|
|
143
|
-
args,
|
|
144
|
-
channels: input.channels ?? [],
|
|
145
|
-
model: input.model,
|
|
146
|
-
cwd: input.cwd ?? this.options.cwd,
|
|
147
|
-
team: input.team,
|
|
148
|
-
shadow_of: input.shadowOf,
|
|
149
|
-
shadow_mode: input.shadowMode,
|
|
150
|
-
restart_policy: input.restartPolicy,
|
|
151
|
-
};
|
|
152
|
-
const result = await this.requestOk('spawn_agent', {
|
|
153
|
-
agent,
|
|
154
|
-
...(input.task != null ? { initial_task: input.task } : {}),
|
|
155
|
-
...(input.idleThresholdSecs != null ? { idle_threshold_secs: input.idleThresholdSecs } : {}),
|
|
156
|
-
...(input.continueFrom != null ? { continue_from: input.continueFrom } : {}),
|
|
157
|
-
...(input.skipRelayPrompt != null ? { skip_relay_prompt: input.skipRelayPrompt } : {}),
|
|
158
|
-
});
|
|
159
|
-
return result;
|
|
173
|
+
// ── Events ─────────────────────────────────────────────────────────
|
|
174
|
+
connectEvents(sinceSeq) {
|
|
175
|
+
this.transport.connect(sinceSeq);
|
|
160
176
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
disconnectEvents() {
|
|
178
|
+
this.transport.disconnect();
|
|
179
|
+
}
|
|
180
|
+
onEvent(listener) {
|
|
181
|
+
return this.transport.onEvent(listener);
|
|
182
|
+
}
|
|
183
|
+
queryEvents(filter) {
|
|
184
|
+
return this.transport.queryEvents(filter);
|
|
185
|
+
}
|
|
186
|
+
getLastEvent(kind, name) {
|
|
187
|
+
return this.transport.getLastEvent(kind, name);
|
|
188
|
+
}
|
|
189
|
+
// ── Agent lifecycle ────────────────────────────────────────────────
|
|
190
|
+
async spawnPty(input) {
|
|
191
|
+
return this.transport.request('/api/spawn', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
name: input.name,
|
|
195
|
+
cli: input.cli,
|
|
196
|
+
model: input.model,
|
|
197
|
+
args: input.args ?? [],
|
|
198
|
+
task: input.task,
|
|
199
|
+
channels: input.channels ?? [],
|
|
200
|
+
cwd: input.cwd,
|
|
201
|
+
team: input.team,
|
|
202
|
+
shadowOf: input.shadowOf,
|
|
203
|
+
shadowMode: input.shadowMode,
|
|
204
|
+
continueFrom: input.continueFrom,
|
|
205
|
+
idleThresholdSecs: input.idleThresholdSecs,
|
|
206
|
+
restartPolicy: input.restartPolicy,
|
|
207
|
+
skipRelayPrompt: input.skipRelayPrompt,
|
|
208
|
+
}),
|
|
174
209
|
});
|
|
175
|
-
return result;
|
|
176
210
|
}
|
|
177
211
|
async spawnProvider(input) {
|
|
178
|
-
const transport =
|
|
179
|
-
if (transport === 'headless') {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
212
|
+
const transport = resolveSpawnTransport(input);
|
|
213
|
+
if (transport === 'headless' && !isHeadlessProvider(input.provider)) {
|
|
214
|
+
throw new Error(`provider '${input.provider}' does not support headless transport (supported: claude, opencode)`);
|
|
215
|
+
}
|
|
216
|
+
return this.transport.request('/api/spawn', {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
body: JSON.stringify({
|
|
184
219
|
name: input.name,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
220
|
+
cli: input.provider,
|
|
221
|
+
model: input.model,
|
|
222
|
+
args: input.args ?? [],
|
|
188
223
|
task: input.task,
|
|
224
|
+
channels: input.channels ?? [],
|
|
225
|
+
cwd: input.cwd,
|
|
226
|
+
team: input.team,
|
|
227
|
+
shadowOf: input.shadowOf,
|
|
228
|
+
shadowMode: input.shadowMode,
|
|
229
|
+
continueFrom: input.continueFrom,
|
|
230
|
+
idleThresholdSecs: input.idleThresholdSecs,
|
|
231
|
+
restartPolicy: input.restartPolicy,
|
|
189
232
|
skipRelayPrompt: input.skipRelayPrompt,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return this.spawnPty({
|
|
193
|
-
name: input.name,
|
|
194
|
-
cli: input.provider,
|
|
195
|
-
args: input.args,
|
|
196
|
-
channels: input.channels,
|
|
197
|
-
task: input.task,
|
|
198
|
-
model: input.model,
|
|
199
|
-
cwd: input.cwd,
|
|
200
|
-
team: input.team,
|
|
201
|
-
shadowOf: input.shadowOf,
|
|
202
|
-
shadowMode: input.shadowMode,
|
|
203
|
-
idleThresholdSecs: input.idleThresholdSecs,
|
|
204
|
-
restartPolicy: input.restartPolicy,
|
|
205
|
-
continueFrom: input.continueFrom,
|
|
206
|
-
skipRelayPrompt: input.skipRelayPrompt,
|
|
233
|
+
transport,
|
|
234
|
+
}),
|
|
207
235
|
});
|
|
208
236
|
}
|
|
237
|
+
async spawnHeadless(input) {
|
|
238
|
+
return this.spawnProvider({ ...input, transport: 'headless' });
|
|
239
|
+
}
|
|
209
240
|
async spawnClaude(input) {
|
|
210
241
|
return this.spawnProvider({ ...input, provider: 'claude' });
|
|
211
242
|
}
|
|
@@ -213,58 +244,44 @@ export class AgentRelayClient {
|
|
|
213
244
|
return this.spawnProvider({ ...input, provider: 'opencode' });
|
|
214
245
|
}
|
|
215
246
|
async release(name, reason) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await this.start();
|
|
221
|
-
return this.requestOk('send_input', { name, data });
|
|
222
|
-
}
|
|
223
|
-
async subscribeChannels(name, channels) {
|
|
224
|
-
await this.start();
|
|
225
|
-
await this.requestOk('subscribe_channels', { name, channels });
|
|
247
|
+
return this.transport.request(`/api/spawned/${encodeURIComponent(name)}`, {
|
|
248
|
+
method: 'DELETE',
|
|
249
|
+
...(reason ? { body: JSON.stringify({ reason }) } : {}),
|
|
250
|
+
});
|
|
226
251
|
}
|
|
227
|
-
async
|
|
228
|
-
await this.
|
|
229
|
-
|
|
252
|
+
async listAgents() {
|
|
253
|
+
const result = await this.transport.request('/api/spawned');
|
|
254
|
+
return result.agents;
|
|
230
255
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return this.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
cols,
|
|
256
|
+
// ── PTY control ────────────────────────────────────────────────────
|
|
257
|
+
async sendInput(name, data) {
|
|
258
|
+
return this.transport.request(`/api/input/${encodeURIComponent(name)}`, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
body: JSON.stringify({ data }),
|
|
237
261
|
});
|
|
238
262
|
}
|
|
239
|
-
async
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
model,
|
|
244
|
-
timeout_ms: opts?.timeoutMs,
|
|
263
|
+
async resizePty(name, rows, cols) {
|
|
264
|
+
return this.transport.request(`/api/resize/${encodeURIComponent(name)}`, {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
body: JSON.stringify({ rows, cols }),
|
|
245
267
|
});
|
|
246
268
|
}
|
|
247
|
-
|
|
248
|
-
await this.start();
|
|
249
|
-
return this.requestOk('get_metrics', { agent });
|
|
250
|
-
}
|
|
251
|
-
async getCrashInsights() {
|
|
252
|
-
await this.start();
|
|
253
|
-
return this.requestOk('get_crash_insights', {});
|
|
254
|
-
}
|
|
269
|
+
// ── Messaging ──────────────────────────────────────────────────────
|
|
255
270
|
async sendMessage(input) {
|
|
256
|
-
await this.start();
|
|
257
271
|
try {
|
|
258
|
-
return await this.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
return await this.transport.request('/api/send', {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
to: input.to,
|
|
276
|
+
text: input.text,
|
|
277
|
+
from: input.from,
|
|
278
|
+
threadId: input.threadId,
|
|
279
|
+
workspaceId: input.workspaceId,
|
|
280
|
+
workspaceAlias: input.workspaceAlias,
|
|
281
|
+
priority: input.priority,
|
|
282
|
+
data: input.data,
|
|
283
|
+
mode: input.mode,
|
|
284
|
+
}),
|
|
268
285
|
});
|
|
269
286
|
}
|
|
270
287
|
catch (error) {
|
|
@@ -274,636 +291,150 @@ export class AgentRelayClient {
|
|
|
274
291
|
throw error;
|
|
275
292
|
}
|
|
276
293
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
async getStatus() {
|
|
283
|
-
await this.start();
|
|
284
|
-
return this.requestOk('get_status', {});
|
|
285
|
-
}
|
|
286
|
-
async shutdown() {
|
|
287
|
-
if (!this.child) {
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
void this.requestOk('shutdown', {}).catch(() => {
|
|
291
|
-
// Continue shutdown path if broker is already unhealthy or exits before replying.
|
|
294
|
+
// ── Model control ──────────────────────────────────────────────────
|
|
295
|
+
async setModel(name, model, opts) {
|
|
296
|
+
return this.transport.request(`/api/spawned/${encodeURIComponent(name)}/model`, {
|
|
297
|
+
method: 'POST',
|
|
298
|
+
body: JSON.stringify({ model, timeout_ms: opts?.timeoutMs }),
|
|
292
299
|
});
|
|
293
|
-
const child = this.child;
|
|
294
|
-
const wait = this.exitPromise ?? Promise.resolve();
|
|
295
|
-
const waitForExit = async (timeoutMs) => {
|
|
296
|
-
let timer;
|
|
297
|
-
const result = await Promise.race([
|
|
298
|
-
wait.then(() => true),
|
|
299
|
-
new Promise((resolve) => {
|
|
300
|
-
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
301
|
-
}),
|
|
302
|
-
]);
|
|
303
|
-
if (timer !== undefined)
|
|
304
|
-
clearTimeout(timer);
|
|
305
|
-
return result;
|
|
306
|
-
};
|
|
307
|
-
if (await waitForExit(this.options.shutdownTimeoutMs)) {
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
if (child.exitCode === null && child.signalCode === null) {
|
|
311
|
-
child.kill('SIGTERM');
|
|
312
|
-
}
|
|
313
|
-
if (await waitForExit(1_000)) {
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
if (child.exitCode === null && child.signalCode === null) {
|
|
317
|
-
child.kill('SIGKILL');
|
|
318
|
-
}
|
|
319
|
-
await waitForExit(1_000);
|
|
320
|
-
}
|
|
321
|
-
async waitForExit() {
|
|
322
|
-
if (!this.child) {
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
await this.exitPromise;
|
|
326
300
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
this.lastStderrLine = undefined;
|
|
333
|
-
const args = [
|
|
334
|
-
'init',
|
|
335
|
-
'--name',
|
|
336
|
-
this.options.brokerName,
|
|
337
|
-
...(this.options.channels.length > 0 ? ['--channels', this.options.channels.join(',')] : []),
|
|
338
|
-
...this.options.binaryArgs,
|
|
339
|
-
];
|
|
340
|
-
// Ensure the SDK bin directory (containing agent-relay-broker) is on
|
|
341
|
-
// PATH so spawned workers can find it without any user setup.
|
|
342
|
-
const env = { ...this.options.env };
|
|
343
|
-
if (isExplicitPath(this.options.binaryPath)) {
|
|
344
|
-
const binDir = path.dirname(path.resolve(resolvedBinary));
|
|
345
|
-
const currentPath = env.PATH ?? env.Path ?? '';
|
|
346
|
-
if (!currentPath.split(path.delimiter).includes(binDir)) {
|
|
347
|
-
env.PATH = `${binDir}${path.delimiter}${currentPath}`;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
console.error(`[broker] Starting: ${resolvedBinary} ${args.join(' ')}`);
|
|
351
|
-
const child = spawn(resolvedBinary, args, {
|
|
352
|
-
cwd: this.options.cwd,
|
|
353
|
-
env,
|
|
354
|
-
stdio: 'pipe',
|
|
355
|
-
});
|
|
356
|
-
this.child = child;
|
|
357
|
-
this.stdoutRl = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
358
|
-
this.stderrRl = createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
359
|
-
this.stdoutRl.on('line', (line) => {
|
|
360
|
-
this.handleStdoutLine(line);
|
|
361
|
-
});
|
|
362
|
-
this.stderrRl.on('line', (line) => {
|
|
363
|
-
const trimmed = line.trim();
|
|
364
|
-
if (trimmed) {
|
|
365
|
-
this.lastStderrLine = trimmed;
|
|
366
|
-
}
|
|
367
|
-
for (const listener of this.stderrListeners) {
|
|
368
|
-
listener(line);
|
|
369
|
-
}
|
|
301
|
+
// ── Channels ───────────────────────────────────────────────────────
|
|
302
|
+
async subscribeChannels(name, channels) {
|
|
303
|
+
await this.transport.request(`/api/spawned/${encodeURIComponent(name)}/subscribe`, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
body: JSON.stringify({ channels }),
|
|
370
306
|
});
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// exits AND all stdio streams have ended.
|
|
377
|
-
child.once('close', (code, signal) => {
|
|
378
|
-
const detail = this.lastStderrLine ? `: ${this.lastStderrLine}` : '';
|
|
379
|
-
const error = new AgentRelayProcessError(`broker exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})${detail}`);
|
|
380
|
-
this.failAllPending(error);
|
|
381
|
-
this.disposeProcessHandles();
|
|
382
|
-
resolve();
|
|
383
|
-
});
|
|
384
|
-
child.once('error', (error) => {
|
|
385
|
-
this.failAllPending(error);
|
|
386
|
-
this.disposeProcessHandles();
|
|
387
|
-
resolve();
|
|
388
|
-
});
|
|
307
|
+
}
|
|
308
|
+
async unsubscribeChannels(name, channels) {
|
|
309
|
+
await this.transport.request(`/api/spawned/${encodeURIComponent(name)}/unsubscribe`, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
body: JSON.stringify({ channels }),
|
|
389
312
|
});
|
|
390
|
-
const helloAck = await this.requestHello();
|
|
391
|
-
console.error('[broker] Broker ready (hello handshake complete)');
|
|
392
|
-
if (helloAck.workspace_key) {
|
|
393
|
-
this.workspaceKey = helloAck.workspace_key;
|
|
394
|
-
}
|
|
395
313
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
this.
|
|
400
|
-
this.stderrRl = undefined;
|
|
401
|
-
this.lastStderrLine = undefined;
|
|
402
|
-
this.child = undefined;
|
|
403
|
-
this.exitPromise = undefined;
|
|
404
|
-
}
|
|
405
|
-
failAllPending(error) {
|
|
406
|
-
for (const pending of this.pending.values()) {
|
|
407
|
-
clearTimeout(pending.timeout);
|
|
408
|
-
pending.reject(error);
|
|
409
|
-
}
|
|
410
|
-
this.pending.clear();
|
|
314
|
+
// ── Observability ──────────────────────────────────────────────────
|
|
315
|
+
async getMetrics(agent) {
|
|
316
|
+
const query = agent ? `?agent=${encodeURIComponent(agent)}` : '';
|
|
317
|
+
return this.transport.request(`/api/metrics${query}`);
|
|
411
318
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
try {
|
|
415
|
-
parsed = JSON.parse(line);
|
|
416
|
-
}
|
|
417
|
-
catch {
|
|
418
|
-
// Non-protocol output should not crash the SDK.
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
if (parsed.v !== PROTOCOL_VERSION || typeof parsed.type !== 'string') {
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
const envelope = {
|
|
428
|
-
v: parsed.v,
|
|
429
|
-
type: parsed.type,
|
|
430
|
-
request_id: parsed.request_id,
|
|
431
|
-
payload: parsed.payload,
|
|
432
|
-
};
|
|
433
|
-
if (envelope.type === 'event') {
|
|
434
|
-
const payload = envelope.payload;
|
|
435
|
-
this.eventBuffer.push(payload);
|
|
436
|
-
if (this.eventBuffer.length > this.maxBufferSize) {
|
|
437
|
-
this.eventBuffer.shift();
|
|
438
|
-
}
|
|
439
|
-
for (const listener of this.eventListeners) {
|
|
440
|
-
listener(payload);
|
|
441
|
-
}
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
if (!envelope.request_id) {
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
const pending = this.pending.get(envelope.request_id);
|
|
448
|
-
if (!pending) {
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
if (envelope.type === 'error') {
|
|
452
|
-
clearTimeout(pending.timeout);
|
|
453
|
-
this.pending.delete(envelope.request_id);
|
|
454
|
-
pending.reject(new AgentRelayProtocolError(envelope.payload));
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
if (envelope.type !== pending.expectedType) {
|
|
458
|
-
clearTimeout(pending.timeout);
|
|
459
|
-
this.pending.delete(envelope.request_id);
|
|
460
|
-
pending.reject(new AgentRelayProcessError(`unexpected response type '${envelope.type}' for request '${envelope.request_id}' (expected '${pending.expectedType}')`));
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
clearTimeout(pending.timeout);
|
|
464
|
-
this.pending.delete(envelope.request_id);
|
|
465
|
-
pending.resolve(envelope);
|
|
466
|
-
}
|
|
467
|
-
async requestHello() {
|
|
468
|
-
const payload = {
|
|
469
|
-
client_name: this.options.clientName,
|
|
470
|
-
client_version: this.options.clientVersion,
|
|
471
|
-
};
|
|
472
|
-
const frame = await this.sendRequest('hello', payload, 'hello_ack');
|
|
473
|
-
return frame.payload;
|
|
319
|
+
async getStatus() {
|
|
320
|
+
return this.transport.request('/api/status');
|
|
474
321
|
}
|
|
475
|
-
async
|
|
476
|
-
|
|
477
|
-
const result = frame.payload;
|
|
478
|
-
return result.result;
|
|
322
|
+
async getCrashInsights() {
|
|
323
|
+
return this.transport.request('/api/crash-insights');
|
|
479
324
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const message = {
|
|
486
|
-
v: PROTOCOL_VERSION,
|
|
487
|
-
type,
|
|
488
|
-
request_id: requestId,
|
|
489
|
-
payload,
|
|
490
|
-
};
|
|
491
|
-
const responsePromise = new Promise((resolve, reject) => {
|
|
492
|
-
const timeout = setTimeout(() => {
|
|
493
|
-
this.pending.delete(requestId);
|
|
494
|
-
reject(new AgentRelayProcessError(`request timed out after ${this.options.requestTimeoutMs}ms (type='${type}', request_id='${requestId}')`));
|
|
495
|
-
}, this.options.requestTimeoutMs);
|
|
496
|
-
this.pending.set(requestId, {
|
|
497
|
-
expectedType,
|
|
498
|
-
resolve,
|
|
499
|
-
reject,
|
|
500
|
-
timeout,
|
|
501
|
-
});
|
|
325
|
+
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
326
|
+
async preflight(agents) {
|
|
327
|
+
return this.transport.request('/api/preflight', {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
body: JSON.stringify({ agents }),
|
|
502
330
|
});
|
|
503
|
-
const line = `${JSON.stringify(message)}\n`;
|
|
504
|
-
if (!this.child.stdin.write(line)) {
|
|
505
|
-
await once(this.child.stdin, 'drain');
|
|
506
|
-
}
|
|
507
|
-
return responsePromise;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
const CLI_MODEL_FLAG_CLIS = new Set(['claude', 'codex', 'gemini', 'goose', 'aider']);
|
|
511
|
-
const CLI_DEFAULT_ARGS = {
|
|
512
|
-
codex: ['-c', 'check_for_update_on_startup=false'],
|
|
513
|
-
};
|
|
514
|
-
function buildPtyArgsWithModel(cli, args, model) {
|
|
515
|
-
const cliName = cli.split(':')[0].trim().toLowerCase();
|
|
516
|
-
const defaultArgs = CLI_DEFAULT_ARGS[cliName] ?? [];
|
|
517
|
-
const baseArgs = [...defaultArgs, ...args];
|
|
518
|
-
if (!model) {
|
|
519
|
-
return baseArgs;
|
|
520
|
-
}
|
|
521
|
-
if (!CLI_MODEL_FLAG_CLIS.has(cliName)) {
|
|
522
|
-
return baseArgs;
|
|
523
|
-
}
|
|
524
|
-
if (hasModelArg(baseArgs)) {
|
|
525
|
-
return baseArgs;
|
|
526
|
-
}
|
|
527
|
-
return ['--model', model, ...baseArgs];
|
|
528
|
-
}
|
|
529
|
-
function hasModelArg(args) {
|
|
530
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
531
|
-
const arg = args[i];
|
|
532
|
-
if (arg === '--model') {
|
|
533
|
-
return true;
|
|
534
|
-
}
|
|
535
|
-
if (arg.startsWith('--model=')) {
|
|
536
|
-
return true;
|
|
537
|
-
}
|
|
538
331
|
}
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
function expandTilde(p) {
|
|
542
|
-
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
|
|
543
|
-
const home = os.homedir();
|
|
544
|
-
return path.join(home, p.slice(2));
|
|
332
|
+
async renewLease() {
|
|
333
|
+
return this.transport.request('/api/session/renew', { method: 'POST' });
|
|
545
334
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
};
|
|
560
|
-
return platformMap[process.platform]?.[process.arch] ?? null;
|
|
561
|
-
}
|
|
562
|
-
function getLatestVersionSync() {
|
|
563
|
-
try {
|
|
564
|
-
const result = execSync('curl -fsSL https://api.github.com/repos/AgentWorkforce/relay/releases/latest', {
|
|
565
|
-
timeout: 15_000,
|
|
566
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
567
|
-
}).toString();
|
|
568
|
-
const match = result.match(/"tag_name"\s*:\s*"([^"]+)"/);
|
|
569
|
-
if (!match?.[1])
|
|
570
|
-
return null;
|
|
571
|
-
// Strip tag prefixes: "openclaw-v3.1.18" -> "3.1.18", "v3.1.18" -> "3.1.18"
|
|
572
|
-
return match[1].replace(/^openclaw-/, '').replace(/^v/, '');
|
|
573
|
-
}
|
|
574
|
-
catch {
|
|
575
|
-
return null;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
function installBrokerBinary() {
|
|
579
|
-
const suffix = detectPlatformSuffix();
|
|
580
|
-
if (!suffix) {
|
|
581
|
-
throw new AgentRelayProcessError(`Unsupported platform: ${process.platform}-${process.arch}`);
|
|
582
|
-
}
|
|
583
|
-
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
584
|
-
const installDir = path.join(homeDir, '.agent-relay', 'bin');
|
|
585
|
-
const brokerExe = process.platform === 'win32' ? 'agent-relay-broker.exe' : 'agent-relay-broker';
|
|
586
|
-
const targetPath = path.join(installDir, brokerExe);
|
|
587
|
-
console.log(`[agent-relay] Broker binary not found, installing for ${suffix}...`);
|
|
588
|
-
const version = getLatestVersionSync();
|
|
589
|
-
if (!version) {
|
|
590
|
-
throw new AgentRelayProcessError('Failed to fetch latest agent-relay version from GitHub.\n' +
|
|
591
|
-
'Install manually: curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash');
|
|
592
|
-
}
|
|
593
|
-
const binaryName = `agent-relay-broker-${suffix}`;
|
|
594
|
-
const downloadUrl = `https://github.com/AgentWorkforce/relay/releases/download/v${version}/${binaryName}`;
|
|
595
|
-
console.log(`[agent-relay] Downloading v${version} from ${downloadUrl}`);
|
|
596
|
-
try {
|
|
597
|
-
fs.mkdirSync(installDir, { recursive: true });
|
|
598
|
-
execSync(`curl -fsSL "${downloadUrl}" -o "${targetPath}"`, {
|
|
599
|
-
timeout: 60_000,
|
|
600
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
601
|
-
});
|
|
602
|
-
fs.chmodSync(targetPath, 0o755);
|
|
603
|
-
// macOS: strip quarantine attribute and re-sign to avoid Gatekeeper issues
|
|
604
|
-
if (process.platform === 'darwin') {
|
|
605
|
-
try {
|
|
606
|
-
execSync(`xattr -d com.apple.quarantine "${targetPath}" 2>/dev/null || true`, {
|
|
607
|
-
timeout: 10_000,
|
|
608
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
catch {
|
|
612
|
-
// Non-fatal
|
|
613
|
-
}
|
|
335
|
+
/**
|
|
336
|
+
* Shut down and clean up.
|
|
337
|
+
* - For spawned brokers (via .spawn()): sends POST /api/shutdown to kill the broker, waits for exit.
|
|
338
|
+
* - For connected brokers (via .connect() or constructor): just disconnects the transport.
|
|
339
|
+
* Does NOT kill the broker — the caller doesn't own it.
|
|
340
|
+
*/
|
|
341
|
+
async shutdown() {
|
|
342
|
+
if (this.leaseTimer) {
|
|
343
|
+
clearInterval(this.leaseTimer);
|
|
344
|
+
this.leaseTimer = null;
|
|
345
|
+
}
|
|
346
|
+
// Only send the shutdown command if we own the broker process
|
|
347
|
+
if (this.child) {
|
|
614
348
|
try {
|
|
615
|
-
|
|
616
|
-
timeout: 10_000,
|
|
617
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
618
|
-
});
|
|
349
|
+
await this.transport.request('/api/shutdown', { method: 'POST' });
|
|
619
350
|
}
|
|
620
351
|
catch {
|
|
621
|
-
//
|
|
352
|
+
// Broker may already be dead
|
|
622
353
|
}
|
|
623
354
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
try {
|
|
629
|
-
fs.unlinkSync(targetPath);
|
|
630
|
-
}
|
|
631
|
-
catch {
|
|
632
|
-
/* ignore */
|
|
355
|
+
this.transport.disconnect();
|
|
356
|
+
if (this.child) {
|
|
357
|
+
await waitForExit(this.child, 5000);
|
|
358
|
+
this.child = null;
|
|
633
359
|
}
|
|
634
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
635
|
-
throw new AgentRelayProcessError(`Failed to install broker binary: ${message}\n` +
|
|
636
|
-
'Install manually: curl -fsSL https://raw.githubusercontent.com/AgentWorkforce/relay/main/install.sh | bash');
|
|
637
360
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
644
|
-
// 1. In a source checkout, prefer Cargo's release binary to avoid stale bundled
|
|
645
|
-
// copies when local dev rebuilds happen while broker processes are running.
|
|
646
|
-
const workspaceRelease = path.resolve(moduleDir, '..', '..', '..', 'target', 'release', brokerExe);
|
|
647
|
-
if (fs.existsSync(workspaceRelease)) {
|
|
648
|
-
return workspaceRelease;
|
|
649
|
-
}
|
|
650
|
-
// 2. Check for bundled platform-specific broker binary in SDK package (npm install).
|
|
651
|
-
// Only use binaries that match the current platform to avoid running
|
|
652
|
-
// e.g. a macOS binary on Linux (or vice-versa).
|
|
653
|
-
const binDir = path.resolve(moduleDir, '..', 'bin');
|
|
654
|
-
const suffix = detectPlatformSuffix();
|
|
655
|
-
if (suffix) {
|
|
656
|
-
const ext = process.platform === 'win32' ? '.exe' : '';
|
|
657
|
-
const platformBinary = path.join(binDir, `agent-relay-broker-${suffix}${ext}`);
|
|
658
|
-
if (fs.existsSync(platformBinary)) {
|
|
659
|
-
return platformBinary;
|
|
361
|
+
/** Disconnect without shutting down the broker. Alias for cases where the intent is clear. */
|
|
362
|
+
disconnect() {
|
|
363
|
+
if (this.leaseTimer) {
|
|
364
|
+
clearInterval(this.leaseTimer);
|
|
365
|
+
this.leaseTimer = null;
|
|
660
366
|
}
|
|
367
|
+
this.transport.disconnect();
|
|
661
368
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const standaloneBroker = path.join(homeDir, '.agent-relay', 'bin', brokerExe);
|
|
665
|
-
if (fs.existsSync(standaloneBroker)) {
|
|
666
|
-
return standaloneBroker;
|
|
369
|
+
async getConfig() {
|
|
370
|
+
return this.transport.request('/api/config');
|
|
667
371
|
}
|
|
668
|
-
// 4. Auto-install from GitHub releases
|
|
669
|
-
return installBrokerBinary();
|
|
670
372
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
function sanitizeBrokerName(name) {
|
|
684
|
-
return name.replace(/[^\p{L}\p{N}-]/gu, '-');
|
|
685
|
-
}
|
|
686
|
-
function brokerPidFilename(projectRoot) {
|
|
687
|
-
const brokerName = path.basename(projectRoot) || 'project';
|
|
688
|
-
return `broker-${sanitizeBrokerName(brokerName)}.pid`;
|
|
689
|
-
}
|
|
690
|
-
export class HttpAgentRelayClient {
|
|
691
|
-
port;
|
|
692
|
-
apiKey;
|
|
693
|
-
constructor(options) {
|
|
694
|
-
this.port = options.port;
|
|
695
|
-
this.apiKey = options.apiKey;
|
|
696
|
-
}
|
|
697
|
-
/**
|
|
698
|
-
* Connect to an already-running broker on the given port.
|
|
699
|
-
*/
|
|
700
|
-
static async connectHttp(port, options) {
|
|
701
|
-
const client = new HttpAgentRelayClient({ port, apiKey: options?.apiKey });
|
|
702
|
-
// Verify connectivity
|
|
703
|
-
await client.healthCheck();
|
|
704
|
-
return client;
|
|
705
|
-
}
|
|
706
|
-
/**
|
|
707
|
-
* Discover a running broker for the current project and connect to it.
|
|
708
|
-
* Reads the broker PID file, verifies the process is alive, scans ports
|
|
709
|
-
* for the HTTP API, and returns a connected client.
|
|
710
|
-
*/
|
|
711
|
-
static async discoverAndConnect(options) {
|
|
712
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
713
|
-
const apiKey = options?.apiKey ?? process.env.RELAY_BROKER_API_KEY?.trim();
|
|
714
|
-
const autoStart = options?.autoStart ?? false;
|
|
715
|
-
const paths = getProjectPaths(cwd);
|
|
716
|
-
const preferredApiPort = DEFAULT_DASHBOARD_PORT + 1;
|
|
717
|
-
// Try to find a running broker via PID file
|
|
718
|
-
const pidFilePath = path.join(paths.dataDir, brokerPidFilename(paths.projectRoot));
|
|
719
|
-
const legacyPidPath = path.join(paths.dataDir, 'broker.pid');
|
|
720
|
-
let brokerRunning = false;
|
|
721
|
-
for (const pidPath of [pidFilePath, legacyPidPath]) {
|
|
722
|
-
if (fs.existsSync(pidPath)) {
|
|
723
|
-
const pidStr = fs.readFileSync(pidPath, 'utf-8').trim();
|
|
724
|
-
const pid = Number.parseInt(pidStr, 10);
|
|
725
|
-
if (Number.isFinite(pid) && pid > 0) {
|
|
726
|
-
try {
|
|
727
|
-
process.kill(pid, 0);
|
|
728
|
-
brokerRunning = true;
|
|
729
|
-
break;
|
|
730
|
-
}
|
|
731
|
-
catch {
|
|
732
|
-
// Process not running
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
}
|
|
373
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
374
|
+
/**
|
|
375
|
+
* Parse the API URL from the broker's stdout. The broker prints:
|
|
376
|
+
* [agent-relay] API listening on http://{bind}:{port}
|
|
377
|
+
* Returns the full URL (e.g. "http://127.0.0.1:3889").
|
|
378
|
+
*/
|
|
379
|
+
async function waitForApiUrl(child, timeoutMs) {
|
|
380
|
+
const { createInterface } = await import('node:readline');
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
if (!child.stdout) {
|
|
383
|
+
reject(new Error('Broker stdout not available'));
|
|
384
|
+
return;
|
|
736
385
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
386
|
+
let resolved = false;
|
|
387
|
+
const rl = createInterface({ input: child.stdout });
|
|
388
|
+
const timer = setTimeout(() => {
|
|
389
|
+
if (!resolved) {
|
|
390
|
+
resolved = true;
|
|
391
|
+
rl.close();
|
|
392
|
+
reject(new Error(`Broker did not report API port within ${timeoutMs}ms`));
|
|
741
393
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
// The broker binary requires the `init` subcommand with `--api-port` and
|
|
750
|
-
// `--persist` so it writes PID files for subsequent discovery.
|
|
751
|
-
const brokerBinary = options?.brokerBinaryPath ?? resolveDefaultBinaryPath();
|
|
752
|
-
const child = spawn(brokerBinary, ['init', '--persist', '--api-port', String(preferredApiPort)], {
|
|
753
|
-
cwd: paths.projectRoot,
|
|
754
|
-
env: process.env,
|
|
755
|
-
detached: true,
|
|
756
|
-
stdio: 'ignore',
|
|
757
|
-
});
|
|
758
|
-
child.unref();
|
|
759
|
-
const startedAt = Date.now();
|
|
760
|
-
while (Date.now() - startedAt < HTTP_AUTOSTART_TIMEOUT_MS) {
|
|
761
|
-
const port = await HttpAgentRelayClient.scanForBrokerPort(preferredApiPort);
|
|
762
|
-
if (port !== null) {
|
|
763
|
-
return new HttpAgentRelayClient({ port, apiKey });
|
|
394
|
+
}, timeoutMs);
|
|
395
|
+
child.on('exit', (code) => {
|
|
396
|
+
if (!resolved) {
|
|
397
|
+
resolved = true;
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
rl.close();
|
|
400
|
+
reject(new Error(`Broker process exited with code ${code} before becoming ready`));
|
|
764
401
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
try {
|
|
773
|
-
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
774
|
-
if (!res.ok)
|
|
775
|
-
continue;
|
|
776
|
-
const payload = (await res.json().catch(() => null));
|
|
777
|
-
if (payload?.service === 'agent-relay-listen') {
|
|
778
|
-
return port;
|
|
779
|
-
}
|
|
402
|
+
});
|
|
403
|
+
child.on('error', (err) => {
|
|
404
|
+
if (!resolved) {
|
|
405
|
+
resolved = true;
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
rl.close();
|
|
408
|
+
reject(new Error(`Failed to start broker: ${err.message}`));
|
|
780
409
|
}
|
|
781
|
-
|
|
782
|
-
|
|
410
|
+
});
|
|
411
|
+
rl.on('line', (line) => {
|
|
412
|
+
if (resolved)
|
|
413
|
+
return;
|
|
414
|
+
const match = line.match(/API listening on (https?:\/\/[^\s]+)/);
|
|
415
|
+
if (match) {
|
|
416
|
+
resolved = true;
|
|
417
|
+
clearTimeout(timer);
|
|
418
|
+
rl.close();
|
|
419
|
+
resolve(match[1]);
|
|
783
420
|
}
|
|
784
|
-
}
|
|
785
|
-
return null;
|
|
786
|
-
}
|
|
787
|
-
async request(pathname, init) {
|
|
788
|
-
const headers = new Headers(init?.headers);
|
|
789
|
-
if (this.apiKey && !headers.has('x-api-key') && !headers.has('authorization')) {
|
|
790
|
-
headers.set('x-api-key', this.apiKey);
|
|
791
|
-
}
|
|
792
|
-
const response = await fetch(`http://127.0.0.1:${this.port}${pathname}`, {
|
|
793
|
-
...init,
|
|
794
|
-
headers,
|
|
795
421
|
});
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
if (!response.ok) {
|
|
805
|
-
const msg = HttpAgentRelayClient.extractErrorMessage(response, payload);
|
|
806
|
-
throw new AgentRelayProcessError(msg);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
function waitForExit(child, timeoutMs) {
|
|
425
|
+
return new Promise((resolve) => {
|
|
426
|
+
if (child.exitCode !== null) {
|
|
427
|
+
resolve();
|
|
428
|
+
return;
|
|
807
429
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
return p.error;
|
|
816
|
-
if (typeof p?.error?.message === 'string')
|
|
817
|
-
return p.error.message;
|
|
818
|
-
if (typeof p?.message === 'string' && p.message.trim())
|
|
819
|
-
return p.message.trim();
|
|
820
|
-
return `${response.status} ${response.statusText}`.trim();
|
|
821
|
-
}
|
|
822
|
-
async healthCheck() {
|
|
823
|
-
return this.request('/health');
|
|
824
|
-
}
|
|
825
|
-
/** No-op — broker is already running. */
|
|
826
|
-
async start() { }
|
|
827
|
-
/** No-op — don't kill an externally-managed broker. */
|
|
828
|
-
async shutdown() { }
|
|
829
|
-
async spawnPty(input) {
|
|
830
|
-
const payload = await this.request('/api/spawn', {
|
|
831
|
-
method: 'POST',
|
|
832
|
-
headers: { 'content-type': 'application/json' },
|
|
833
|
-
body: JSON.stringify({
|
|
834
|
-
name: input.name,
|
|
835
|
-
cli: input.cli,
|
|
836
|
-
model: input.model,
|
|
837
|
-
args: input.args ?? [],
|
|
838
|
-
task: input.task,
|
|
839
|
-
channels: input.channels ?? [],
|
|
840
|
-
cwd: input.cwd,
|
|
841
|
-
team: input.team,
|
|
842
|
-
shadowOf: input.shadowOf,
|
|
843
|
-
shadowMode: input.shadowMode,
|
|
844
|
-
continueFrom: input.continueFrom,
|
|
845
|
-
idleThresholdSecs: input.idleThresholdSecs,
|
|
846
|
-
restartPolicy: input.restartPolicy,
|
|
847
|
-
skipRelayPrompt: input.skipRelayPrompt,
|
|
848
|
-
}),
|
|
849
|
-
});
|
|
850
|
-
return {
|
|
851
|
-
name: typeof payload?.name === 'string' ? payload.name : input.name,
|
|
852
|
-
runtime: 'pty',
|
|
853
|
-
};
|
|
854
|
-
}
|
|
855
|
-
async sendMessage(input) {
|
|
856
|
-
return this.request('/api/send', {
|
|
857
|
-
method: 'POST',
|
|
858
|
-
headers: { 'content-type': 'application/json' },
|
|
859
|
-
body: JSON.stringify({
|
|
860
|
-
to: input.to,
|
|
861
|
-
text: input.text,
|
|
862
|
-
from: input.from,
|
|
863
|
-
threadId: input.threadId,
|
|
864
|
-
workspaceId: input.workspaceId,
|
|
865
|
-
workspaceAlias: input.workspaceAlias,
|
|
866
|
-
priority: input.priority,
|
|
867
|
-
data: input.data,
|
|
868
|
-
mode: input.mode,
|
|
869
|
-
}),
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
async listAgents() {
|
|
873
|
-
const payload = await this.request('/api/spawned', { method: 'GET' });
|
|
874
|
-
return Array.isArray(payload?.agents) ? payload.agents : [];
|
|
875
|
-
}
|
|
876
|
-
async release(name, reason) {
|
|
877
|
-
const payload = await this.request(`/api/spawned/${encodeURIComponent(name)}`, {
|
|
878
|
-
method: 'DELETE',
|
|
879
|
-
...(reason
|
|
880
|
-
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ reason }) }
|
|
881
|
-
: {}),
|
|
882
|
-
});
|
|
883
|
-
return { name: typeof payload?.name === 'string' ? payload.name : name };
|
|
884
|
-
}
|
|
885
|
-
async subscribeChannels(_name, _channels) {
|
|
886
|
-
throw new Error('subscribeChannels is only available via the broker protocol (BrokerAgentRelayClient). ' +
|
|
887
|
-
'The HTTP API does not support dynamic channel subscription.');
|
|
888
|
-
}
|
|
889
|
-
async unsubscribeChannels(_name, _channels) {
|
|
890
|
-
throw new Error('unsubscribeChannels is only available via the broker protocol (BrokerAgentRelayClient). ' +
|
|
891
|
-
'The HTTP API does not support dynamic channel unsubscription.');
|
|
892
|
-
}
|
|
893
|
-
async setModel(name, model, opts) {
|
|
894
|
-
const payload = await this.request(`/api/spawned/${encodeURIComponent(name)}/model`, {
|
|
895
|
-
method: 'POST',
|
|
896
|
-
headers: { 'content-type': 'application/json' },
|
|
897
|
-
body: JSON.stringify({ model, timeoutMs: opts?.timeoutMs }),
|
|
430
|
+
const timer = setTimeout(() => {
|
|
431
|
+
child.kill('SIGKILL');
|
|
432
|
+
resolve();
|
|
433
|
+
}, timeoutMs);
|
|
434
|
+
child.on('exit', () => {
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
resolve();
|
|
898
437
|
});
|
|
899
|
-
|
|
900
|
-
name,
|
|
901
|
-
model: typeof payload?.model === 'string' ? payload.model : model,
|
|
902
|
-
success: payload?.success !== false,
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
async getConfig() {
|
|
906
|
-
return this.request('/api/config');
|
|
907
|
-
}
|
|
438
|
+
});
|
|
908
439
|
}
|
|
909
440
|
//# sourceMappingURL=client.js.map
|