@agent-relay/factory 0.1.17 → 0.1.18

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.
@@ -1,74 +1,133 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { AgentRelay } from '@agent-relay/sdk';
2
+ import { resolveRelayAgentToken, resolveRelayWorkspaceKey } from './relay-workspace-key.js';
2
3
  const knownCapabilities = new Set(['spawn:claude', 'spawn:codex', 'workflow:run']);
3
4
  const openStatuses = new Set(['pending', 'dispatched', 'invoked']);
4
5
  const terminalStatuses = new Set(['completed', 'failed', 'denied']);
5
- const DEFAULT_COMPLETION_TIMEOUT_MS = 5 * 60_000;
6
+ const DEFAULT_AGENT_NAME = 'factory';
7
+ const DEFAULT_SPAWN_ACK_TIMEOUT_MS = 5 * 60_000;
6
8
  const DEFAULT_POLL_INTERVAL_MS = 1_000;
9
+ const DEFAULT_EXIT_WATCH_INTERVAL_MS = 15_000;
10
+ // 2× the engine's 45s node-liveness TTL so one missed heartbeat sweep cannot
11
+ // synthesize a false exit.
12
+ const DEFAULT_NODE_OFFLINE_GRACE_MS = 90_000;
13
+ const DEFAULT_REGISTRATION_GRACE_MS = 60_000;
7
14
  export class RelayFleetClient {
8
15
  #options;
9
- // Built lazily on first network use so that constructing the client (and
10
- // therefore createFleet({ backend: 'relay' })) never throws merely because no
11
- // token is configured. The token check lives in HttpRelayFleetTransport's
12
- // constructor and only fires when a method actually needs the transport.
13
- #transport;
14
- #spawnActionName;
15
- #releaseActionName;
16
- #completionTimeoutMs;
16
+ #agentName;
17
+ #spawnAckTimeoutMs;
17
18
  #pollIntervalMs;
19
+ #exitWatchIntervalMs;
20
+ #nodeOfflineGraceMs;
21
+ #registrationGraceMs;
22
+ #createRelay;
23
+ #now;
18
24
  #sleep;
25
+ #log;
19
26
  #agentExitListeners = new Set();
20
27
  #deliveryFailedListeners = new Set();
21
28
  #agentMessageListeners = new Set();
22
29
  #eventUnsubscribers = [];
23
- #subscribed = false;
30
+ #tracked = new Map();
31
+ // Resolved lazily on first network use so constructing the client (and
32
+ // therefore createFleet({ backend: 'relay' })) never throws merely because no
33
+ // token is configured.
34
+ #messaging;
35
+ #messagingReady;
36
+ #eventsStarted = false;
24
37
  #disposed = false;
38
+ #watchTimer;
39
+ #reconciling;
25
40
  constructor(options = {}) {
26
41
  this.#options = options;
27
- this.#spawnActionName = options.spawnActionName ?? 'spawn';
28
- this.#releaseActionName = options.releaseActionName ?? 'release';
29
- this.#completionTimeoutMs = options.completionTimeoutMs ?? DEFAULT_COMPLETION_TIMEOUT_MS;
42
+ this.#agentName = options.agentName ?? DEFAULT_AGENT_NAME;
43
+ this.#spawnAckTimeoutMs = options.spawnAckTimeoutMs ?? DEFAULT_SPAWN_ACK_TIMEOUT_MS;
30
44
  this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
45
+ this.#exitWatchIntervalMs = options.exitWatchIntervalMs ?? DEFAULT_EXIT_WATCH_INTERVAL_MS;
46
+ this.#nodeOfflineGraceMs = options.nodeOfflineGraceMs ?? DEFAULT_NODE_OFFLINE_GRACE_MS;
47
+ this.#registrationGraceMs = options.registrationGraceMs ?? DEFAULT_REGISTRATION_GRACE_MS;
48
+ this.#createRelay = options.createRelay ?? ((relayOptions) => new AgentRelay(relayOptions));
49
+ this.#now = options.now ?? Date.now;
31
50
  this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
32
- this.#transport = options.transport;
33
- }
34
- #getTransport() {
35
- this.#transport ??= new HttpRelayFleetTransport(this.#options);
36
- return this.#transport;
51
+ this.#log = options.log ?? (() => { });
52
+ this.#messaging = options.messaging;
53
+ }
54
+ /** Agents spawned through this client that have not exited or been released. */
55
+ trackedAgents() {
56
+ return this.#tracked;
57
+ }
58
+ /** Re-adopt agents recorded in the in-flight registry after a restart. */
59
+ hydrateTracked(agents) {
60
+ for (const agent of agents) {
61
+ if (this.#tracked.has(agent.name))
62
+ continue;
63
+ // spawnedAtMs of 0 skips the registration grace: a hydrated agent was
64
+ // registered long ago, so absence from the roster is a real exit.
65
+ this.#tracked.set(agent.name, {
66
+ invocationId: agent.invocationId,
67
+ node: agent.node,
68
+ spawnedAtMs: 0,
69
+ });
70
+ }
71
+ this.#syncExitWatcher();
72
+ }
73
+ /**
74
+ * Reconcile tracked agents against the engine roster, synthesizing exits for
75
+ * agents that went offline (or whose node died) without a push signal. The
76
+ * exit watcher runs this on an interval; callers may invoke it directly for
77
+ * a deterministic sweep (startup recovery, tests).
78
+ */
79
+ reconcileTrackedAgents() {
80
+ this.#reconciling ??= this.#reconcileTracked().finally(() => {
81
+ this.#reconciling = undefined;
82
+ });
83
+ return this.#reconciling;
37
84
  }
38
85
  async spawn(input) {
39
- const invocationId = input.invocationId ?? newInvocationId('spawn', input.name);
40
- const actionInput = spawnActionInput(input, { includeSelfNode: false });
41
- const ack = await this.#getTransport().invokeAction(this.#spawnActionName, actionInput, { invocationId });
42
- const invocation = await this.#awaitInvocation(this.#spawnActionName, ack.invocationId || invocationId, ack);
43
- return spawnResultFromInvocation(input.name, input.sessionRef, invocation);
86
+ const messaging = await this.#ensureMessaging();
87
+ const ack = await messaging.placement.spawn({
88
+ capability: input.capability,
89
+ // 'self' from the orchestrator means "no placement preference": let the
90
+ // engine pick the least-loaded eligible node.
91
+ ...(input.node && input.node !== 'self' ? { node: input.node } : {}),
92
+ ...(input.repo ? { repo: input.repo } : {}),
93
+ input: spawnActionInput(input),
94
+ ...(this.#options.placementTtlMs !== undefined ? { ttlMs: this.#options.placementTtlMs } : {}),
95
+ log: this.#log,
96
+ });
97
+ const invocation = await this.#awaitInvocation(ack.actionName || 'spawn', ack);
98
+ const result = spawnResultFromInvocation(input.name, input.sessionRef, invocation);
99
+ this.#track(result.name, ack);
100
+ return result;
44
101
  }
45
102
  async resume(input) {
46
103
  const name = input.name ?? input.sessionRef;
47
- const invocationId = newInvocationId('resume', name);
48
- const actionInput = spawnActionInput({
104
+ return await this.spawn({
49
105
  name,
50
106
  capability: input.capability ?? 'spawn:codex',
51
107
  node: input.node,
52
108
  sessionRef: input.sessionRef,
53
- }, { includeSelfNode: false });
54
- const ack = await this.#getTransport().invokeAction(this.#spawnActionName, actionInput, { invocationId });
55
- const invocation = await this.#awaitInvocation(this.#spawnActionName, ack.invocationId || invocationId, ack);
56
- return spawnResultFromInvocation(name, input.sessionRef, invocation);
109
+ });
57
110
  }
58
111
  async release(name, reason) {
59
- const invocationId = newInvocationId('release', name);
60
- const ack = await this.#getTransport().invokeAction(this.#releaseActionName, {
61
- name,
62
- agent: name,
63
- ...(reason ? { reason } : {}),
64
- }, { invocationId });
65
- await this.#awaitInvocation(this.#releaseActionName, ack.invocationId || invocationId, ack);
112
+ const messaging = await this.#ensureMessaging();
113
+ try {
114
+ const ack = await messaging.commands.invoke('release', {
115
+ name,
116
+ agent: name,
117
+ ...(reason ? { reason } : {}),
118
+ });
119
+ await this.#awaitInvocation(ack.actionName || 'release', ack);
120
+ }
121
+ finally {
122
+ this.#tracked.delete(name);
123
+ this.#syncExitWatcher();
124
+ }
66
125
  }
67
126
  async roster() {
68
- const transport = this.#getTransport();
127
+ const messaging = await this.#ensureMessaging();
69
128
  const [agents, nodes] = await Promise.all([
70
- transport.listAgents(),
71
- transport.listNodes(),
129
+ messaging.agents.list({ status: 'all' }),
130
+ messaging.nodes.list(),
72
131
  ]);
73
132
  return {
74
133
  agents: agents.map((agent) => ({ name: agent.name })),
@@ -79,18 +138,17 @@ export class RelayFleetClient {
79
138
  })),
80
139
  };
81
140
  }
141
+ // `from`/`data` are not representable on the agent-scoped messaging surface:
142
+ // every send is authored by the factory's own agent identity.
82
143
  async sendMessage(input) {
83
- await this.#getTransport().sendMessage(input);
144
+ await this.#send(input);
84
145
  }
85
- async waitForInjected(input, opts) {
86
- const transport = this.#getTransport();
87
- const sent = await transport.sendMessage(input);
88
- const eventId = sent.eventId;
89
- const targets = sent.targets ?? [input.to];
90
- if (!eventId) {
91
- return { eventId: newInvocationId('message', input.to), targets };
92
- }
93
- return await transport.waitForDelivery?.(eventId, opts) ?? { eventId, targets };
146
+ // The SDK exposes no sender-side delivery query yet, so a successful send —
147
+ // a durable inbox row on the engine — is the injected signal. Upstream:
148
+ // deliveries.forMessage(messageId) would make this authoritative.
149
+ async waitForInjected(input, _opts) {
150
+ const message = await this.#send(input);
151
+ return { eventId: message.id, targets: [input.to] };
94
152
  }
95
153
  onDeliveryFailed(listener) {
96
154
  this.#ensureEventSubscription();
@@ -109,207 +167,254 @@ export class RelayFleetClient {
109
167
  onAgentExit(listener) {
110
168
  this.#ensureEventSubscription();
111
169
  this.#agentExitListeners.add(listener);
170
+ this.#syncExitWatcher();
112
171
  return () => {
113
172
  this.#agentExitListeners.delete(listener);
173
+ this.#syncExitWatcher();
114
174
  };
115
175
  }
116
176
  async dispose() {
117
177
  if (this.#disposed)
118
178
  return;
119
179
  this.#disposed = true;
180
+ if (this.#watchTimer) {
181
+ clearInterval(this.#watchTimer);
182
+ this.#watchTimer = undefined;
183
+ }
120
184
  for (const unsubscribe of this.#eventUnsubscribers.splice(0)) {
121
185
  unsubscribe();
122
186
  }
123
187
  this.#agentExitListeners.clear();
124
188
  this.#deliveryFailedListeners.clear();
125
189
  this.#agentMessageListeners.clear();
126
- // Only dispose a transport we actually built; never construct one here.
127
- await this.#transport?.dispose?.();
128
- this.#subscribed = false;
190
+ this.#tracked.clear();
191
+ if (this.#eventsStarted) {
192
+ await this.#messaging?.events.disconnect().catch(() => { });
193
+ }
129
194
  }
130
- async #awaitInvocation(actionName, invocationId, initial) {
131
- let status = initial.status ?? 'pending';
195
+ async #send(input) {
196
+ const messaging = await this.#ensureMessaging();
197
+ try {
198
+ if (input.to.startsWith('#')) {
199
+ return await messaging.messages.send({ channel: input.to.slice(1), text: input.text });
200
+ }
201
+ return await messaging.messages.direct({
202
+ to: input.to.startsWith('@') ? input.to.slice(1) : input.to,
203
+ text: input.text,
204
+ });
205
+ }
206
+ catch (error) {
207
+ // Keep the orchestrator's registration-lag retry classification working:
208
+ // a DM to an agent the engine has not registered yet is retryable.
209
+ if (isUnknownRecipientError(error)) {
210
+ throw new Error(`recipient unavailable: ${errorMessage(error)}`);
211
+ }
212
+ throw error;
213
+ }
214
+ }
215
+ #ensureMessaging() {
216
+ if (this.#messaging)
217
+ return Promise.resolve(this.#messaging);
218
+ this.#messagingReady ??= this.#bootstrapMessaging().catch((error) => {
219
+ // Allow a later call to retry a failed bootstrap (transient network, etc).
220
+ this.#messagingReady = undefined;
221
+ throw error;
222
+ });
223
+ return this.#messagingReady;
224
+ }
225
+ async #bootstrapMessaging() {
226
+ const env = this.#options.env;
227
+ const workspaceKey = resolveRelayWorkspaceKey({
228
+ workspaceKey: this.#options.workspaceKey,
229
+ ...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
230
+ });
231
+ let agentToken = resolveRelayAgentToken({
232
+ agentToken: this.#options.agentToken,
233
+ ...(env ? { env } : {}),
234
+ });
235
+ if (!workspaceKey && !agentToken) {
236
+ throw new Error('RelayFleetClient requires a workspace key (rk_live_…) or agent token (at_live_…); set RELAY_WORKSPACE_KEY or RELAY_AGENT_TOKEN');
237
+ }
238
+ if (!agentToken) {
239
+ agentToken = await this.#registerFactoryAgent(workspaceKey);
240
+ }
241
+ const relay = this.#createRelay({
242
+ ...(workspaceKey ? { workspaceKey } : {}),
243
+ agentToken,
244
+ ...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
245
+ });
246
+ this.#messaging = relay.messaging;
247
+ return this.#messaging;
248
+ }
249
+ // Rotate-on-start is idempotent and leaves nothing secret on disk: the
250
+ // factory adopts its standing workspace identity and mints a fresh token.
251
+ async #registerFactoryAgent(workspaceKey) {
252
+ const bootstrap = this.#createRelay({
253
+ workspaceKey,
254
+ ...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
255
+ });
256
+ const agents = bootstrap.messaging.agents;
257
+ const register = agents.registerOrRotate?.bind(agents) ?? agents.register.bind(agents);
258
+ const registration = await register({ name: this.#agentName });
259
+ return registration.token;
260
+ }
261
+ async #awaitInvocation(actionName, ack) {
262
+ const messaging = await this.#ensureMessaging();
263
+ let status = ack.status ?? 'pending';
132
264
  let invocation;
133
- const deadline = Date.now() + this.#completionTimeoutMs;
265
+ const deadline = Date.now() + this.#spawnAckTimeoutMs;
134
266
  while (!terminalStatuses.has(status)) {
135
267
  if (Date.now() > deadline) {
136
- throw new Error(`Timed out waiting for ${actionName} invocation ${invocationId} to complete (last status: ${status})`);
268
+ throw new Error(`Timed out waiting for ${actionName} invocation ${ack.invocationId} to complete (last status: ${status})`);
137
269
  }
138
270
  if (!openStatuses.has(status)) {
139
- throw new Error(`Unexpected ${actionName} invocation ${invocationId} status: ${status}`);
271
+ throw new Error(`Unexpected ${actionName} invocation ${ack.invocationId} status: ${status}`);
140
272
  }
141
273
  await this.#sleep(this.#pollIntervalMs);
142
- invocation = await this.#getTransport().getInvocation(actionName, invocationId);
143
- status = invocation.status ?? 'pending';
274
+ invocation = await messaging.commands.getInvocation(actionName, ack.invocationId);
275
+ status = invocation.status || 'pending';
144
276
  }
145
- invocation ??= await this.#getTransport().getInvocation(actionName, invocationId);
277
+ invocation ??= await messaging.commands.getInvocation(actionName, ack.invocationId);
146
278
  if (status === 'failed' || status === 'denied') {
147
- throw new Error(`${actionName} invocation ${invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
279
+ throw new Error(`${actionName} invocation ${ack.invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
148
280
  }
149
281
  return invocation;
150
282
  }
151
- #ensureEventSubscription() {
152
- if (this.#subscribed)
153
- return;
154
- this.#subscribed = true;
155
- const unsubscribe = this.#getTransport().onEvent?.((event) => this.#handleEvent(event));
156
- if (unsubscribe)
157
- this.#eventUnsubscribers.push(unsubscribe);
283
+ #track(name, ack) {
284
+ this.#tracked.set(name, {
285
+ invocationId: ack.invocationId,
286
+ node: ack.placement?.node ?? ack.dispatchedNodeId ?? undefined,
287
+ spawnedAtMs: this.#now(),
288
+ });
289
+ this.#syncExitWatcher();
290
+ }
291
+ #syncExitWatcher() {
292
+ const shouldRun = !this.#disposed && this.#tracked.size > 0 && this.#agentExitListeners.size > 0;
293
+ if (shouldRun && !this.#watchTimer) {
294
+ this.#watchTimer = setInterval(() => {
295
+ void this.reconcileTrackedAgents().catch((error) => {
296
+ this.#log(`relay fleet exit reconciliation failed: ${errorMessage(error)}`);
297
+ });
298
+ }, this.#exitWatchIntervalMs);
299
+ this.#watchTimer.unref?.();
300
+ }
301
+ else if (!shouldRun && this.#watchTimer) {
302
+ clearInterval(this.#watchTimer);
303
+ this.#watchTimer = undefined;
304
+ }
158
305
  }
159
- #handleEvent(event) {
160
- const deliveryFailed = deliveryFailedFromEvent(event);
161
- if (deliveryFailed) {
162
- for (const listener of this.#deliveryFailedListeners) {
163
- listener(deliveryFailed);
306
+ async #reconcileTracked() {
307
+ if (this.#tracked.size === 0)
308
+ return;
309
+ const messaging = await this.#ensureMessaging();
310
+ const [agents, nodes] = await Promise.all([
311
+ messaging.agents.list({ status: 'all' }),
312
+ messaging.nodes.list(),
313
+ ]);
314
+ const agentsByName = new Map(agents.map((agent) => [agent.name, agent]));
315
+ const nodeLive = new Map(nodes.map((node) => [node.name, node.live ?? node.status === 'online']));
316
+ const nowMs = this.#now();
317
+ for (const [name, entry] of [...this.#tracked]) {
318
+ const row = agentsByName.get(name);
319
+ if (!row || row.status === 'offline') {
320
+ // A just-spawned agent may not have registered with the engine yet;
321
+ // absence only counts as an exit once the grace window has passed.
322
+ if (nowMs - entry.spawnedAtMs < this.#registrationGraceMs)
323
+ continue;
324
+ this.#emitExit(name, 'exited');
325
+ continue;
164
326
  }
165
- }
166
- const message = agentMessageFromEvent(event);
167
- if (message) {
168
- for (const listener of this.#agentMessageListeners) {
169
- listener(message);
327
+ if (!entry.node)
328
+ continue;
329
+ if (nodeLive.get(entry.node) === false || !nodeLive.has(entry.node)) {
330
+ entry.nodeOfflineSinceMs ??= nowMs;
331
+ if (nowMs - entry.nodeOfflineSinceMs >= this.#nodeOfflineGraceMs) {
332
+ this.#emitExit(name, 'node-offline');
333
+ }
170
334
  }
171
- }
172
- const exit = agentExitFromEvent(event);
173
- if (exit) {
174
- for (const listener of this.#agentExitListeners) {
175
- listener(exit.name, exit.reason);
335
+ else {
336
+ entry.nodeOfflineSinceMs = undefined;
176
337
  }
177
338
  }
178
339
  }
179
- }
180
- class HttpRelayFleetTransport {
181
- #baseUrl;
182
- #token;
183
- #fetch;
184
- #eventListeners = new Set();
185
- #socket;
186
- constructor(options) {
187
- this.#baseUrl = (options.baseUrl ?? env('RELAYCAST_BASE_URL') ?? env('AGENT_RELAY_BASE_URL') ?? 'https://api.relaycast.dev').replace(/\/+$/, '');
188
- this.#token = options.agentToken ?? env('RELAY_AGENT_TOKEN') ?? options.workspaceKey ?? env('RELAY_WORKSPACE_KEY') ?? env('RELAY_API_KEY') ?? '';
189
- if (!this.#token) {
190
- throw new Error('RelayFleetClient requires agentToken or workspaceKey (or RELAY_AGENT_TOKEN / RELAY_WORKSPACE_KEY)');
340
+ #emitExit(name, reason) {
341
+ this.#tracked.delete(name);
342
+ this.#syncExitWatcher();
343
+ for (const listener of this.#agentExitListeners) {
344
+ listener(name, reason);
191
345
  }
192
- this.#fetch = options.fetch ?? globalFetch;
193
346
  }
194
- async invokeAction(actionName, input, opts) {
195
- const data = await this.#request(`/v1/actions/${encodeURIComponent(actionName)}/invoke`, {
196
- input,
197
- invocation_id: opts.invocationId,
198
- invocationId: opts.invocationId,
347
+ #ensureEventSubscription() {
348
+ if (this.#eventsStarted)
349
+ return;
350
+ this.#eventsStarted = true;
351
+ void this.#subscribeEvents().catch((error) => {
352
+ this.#eventsStarted = false;
353
+ this.#log(`relay fleet event subscription failed: ${errorMessage(error)}`);
199
354
  });
200
- return invocationAckFrom(data, opts.invocationId, actionName);
201
- }
202
- async getInvocation(actionName, invocationId) {
203
- const data = await this.#request(`/v1/actions/${encodeURIComponent(actionName)}/invocations/${encodeURIComponent(invocationId)}`);
204
- return invocationFrom(data, invocationId, actionName);
205
- }
206
- async listAgents() {
207
- const data = await this.#request('/v1/agents?status=all');
208
- return Array.isArray(data) ? data.map(agentFrom).filter(isRelayFleetAgent) : [];
209
- }
210
- async listNodes() {
211
- const data = await this.#request('/v1/nodes');
212
- return Array.isArray(data) ? data.map(nodeFrom).filter(isRelayFleetNode) : [];
213
- }
214
- async sendMessage(input) {
215
- const data = input.to.startsWith('#')
216
- ? await this.#request(`/v1/channels/${encodeURIComponent(input.to.slice(1))}/messages`, {
217
- text: input.text,
218
- ...(input.data ? { metadata: input.data } : {}),
219
- })
220
- : await this.#request('/v1/dm', {
221
- to: input.to.startsWith('@') ? input.to.slice(1) : input.to,
222
- text: input.text,
223
- ...(input.data ? { metadata: input.data } : {}),
224
- });
225
- return {
226
- eventId: readString(asRecord(data), 'id', 'message_id', 'event_id'),
227
- targets: [input.to],
228
- };
229
- }
230
- onEvent(listener) {
231
- this.#eventListeners.add(listener);
232
- this.#connectEvents();
233
- return () => {
234
- this.#eventListeners.delete(listener);
235
- if (this.#eventListeners.size === 0) {
236
- this.#socket?.close();
237
- this.#socket = undefined;
238
- }
239
- };
240
355
  }
241
- dispose() {
242
- this.#socket?.close();
243
- this.#socket = undefined;
244
- this.#eventListeners.clear();
356
+ async #subscribeEvents() {
357
+ const messaging = await this.#ensureMessaging();
358
+ if (this.#disposed)
359
+ return;
360
+ messaging.events.connect();
361
+ this.#eventUnsubscribers.push(messaging.events.on('any', (event) => this.#handleEvent(event)));
245
362
  }
246
- async #request(path, body) {
247
- const response = await this.#fetch(`${this.#baseUrl}${path}`, {
248
- method: body ? 'POST' : 'GET',
249
- headers: {
250
- Authorization: `Bearer ${this.#token}`,
251
- ...(body ? { 'Content-Type': 'application/json' } : {}),
252
- },
253
- ...(body ? { body: JSON.stringify(body) } : {}),
254
- });
255
- const payload = await readResponsePayload(response);
256
- const record = asRecord(payload);
257
- if (!response.ok || record?.ok === false) {
258
- const error = asRecord(record?.error);
259
- const message = readString(error, 'message') ?? response.statusText;
260
- throw new Error(`Relay fleet request failed (${response.status}): ${message}`);
363
+ #handleEvent(event) {
364
+ switch (event.type) {
365
+ case 'dmReceived':
366
+ case 'groupDmReceived':
367
+ this.#emitAgentMessage(event.message, this.#agentName);
368
+ break;
369
+ case 'messageCreated':
370
+ case 'threadReply':
371
+ this.#emitAgentMessage(event.message, event.channel);
372
+ break;
373
+ case 'agentOffline':
374
+ this.#handleAgentOffline(event.agent.name);
375
+ break;
376
+ default:
377
+ break;
261
378
  }
262
- return record && 'data' in record ? record.data : payload;
263
379
  }
264
- #connectEvents() {
265
- if (this.#socket)
380
+ #emitAgentMessage(message, fallbackTarget) {
381
+ const from = message.from?.name;
382
+ if (!from || from === this.#agentName)
266
383
  return;
267
- const WebSocketCtor = globalThis.WebSocket;
268
- if (!WebSocketCtor)
269
- return;
270
- const url = new URL('/v1/ws', this.#baseUrl.replace(/^http/, 'ws'));
271
- url.searchParams.set('token', this.#token);
272
- const socket = new WebSocketCtor(url.toString());
273
- this.#socket = socket;
274
- socket.onmessage = (message) => {
275
- try {
276
- const parsed = JSON.parse(String(message.data));
277
- if (parsed && typeof parsed === 'object') {
278
- this.#emitEvent(parsed);
279
- }
280
- }
281
- catch {
282
- // Drop malformed event frames; the polling path remains authoritative for invocations.
283
- }
284
- };
285
- socket.onclose = () => {
286
- if (this.#socket === socket)
287
- this.#socket = undefined;
288
- };
289
- socket.onerror = () => {
290
- if (this.#socket === socket)
291
- this.#socket = undefined;
292
- try {
293
- socket.close();
294
- }
295
- catch {
296
- // noop
297
- }
384
+ let target;
385
+ const messageTarget = message.target;
386
+ if (messageTarget?.kind === 'agent' && typeof messageTarget.agentName === 'string') {
387
+ target = messageTarget.agentName;
388
+ }
389
+ else if (messageTarget?.kind === 'channel' && typeof messageTarget.channelName === 'string') {
390
+ target = messageTarget.channelName;
391
+ }
392
+ const agentMessage = {
393
+ from,
394
+ target: target ?? fallbackTarget,
395
+ body: message.text,
396
+ ...(message.threadId || message.parentId ? { threadId: message.threadId ?? message.parentId } : {}),
397
+ ...(message.id ? { eventId: message.id } : {}),
298
398
  };
299
- }
300
- #emitEvent(event) {
301
- for (const listener of this.#eventListeners) {
302
- listener(event);
399
+ for (const listener of this.#agentMessageListeners) {
400
+ listener(agentMessage);
303
401
  }
304
402
  }
403
+ // Presence offline is a push hint for exits of agents this client spawned.
404
+ // The roster reconciliation watcher remains the authoritative exit detector.
405
+ #handleAgentOffline(name) {
406
+ if (!this.#tracked.has(name))
407
+ return;
408
+ this.#emitExit(name, 'offline');
409
+ }
305
410
  }
306
- function spawnActionInput(input, opts) {
411
+ // Placement injects capability/node/target_node/repo/cli on top of this
412
+ // payload; spawn_mode/exit_after_task request task-exit lifecycle from the
413
+ // broker on the placed node.
414
+ function spawnActionInput(input) {
307
415
  return definedRecord({
308
- capability: input.capability,
309
416
  name: input.name,
310
417
  agent: input.name,
311
- node: input.node && (opts.includeSelfNode || input.node !== 'self') ? input.node : undefined,
312
- repo: input.repo,
313
418
  clone_path: input.clonePath,
314
419
  clonePath: input.clonePath,
315
420
  session_ref: input.sessionRef,
@@ -321,6 +426,7 @@ function spawnActionInput(input, opts) {
321
426
  cwd: input.cwd,
322
427
  channels: input.channel ? [input.channel] : undefined,
323
428
  restart_policy: input.restartPolicy,
429
+ ...(input.capability.startsWith('spawn:') ? { spawn_mode: 'task_exit', exit_after_task: true } : {}),
324
430
  });
325
431
  }
326
432
  function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation) {
@@ -341,109 +447,16 @@ function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation)
341
447
  }
342
448
  function normalizeCapabilities(capabilities) {
343
449
  const names = (capabilities ?? [])
344
- .map((capability) => typeof capability === 'string' ? capability : capability.name)
345
- .filter((capability) => typeof capability === 'string' && knownCapabilities.has(capability));
450
+ .map((capability) => capability.name)
451
+ .filter((name) => knownCapabilities.has(name));
346
452
  return [...new Set(names)];
347
453
  }
348
- function deliveryFailedFromEvent(event) {
349
- const type = event.type;
350
- if (type !== 'delivery.failed' && type !== 'delivery_failed')
351
- return undefined;
352
- const to = readString(event, 'to', 'recipient', 'recipient_name', 'recipientName', 'agent_name', 'agentName');
353
- if (!to)
354
- return undefined;
355
- return definedRecord({
356
- to,
357
- msgId: readString(event, 'message_id', 'messageId', 'msg_id', 'msgId', 'id'),
358
- reason: readString(event, 'reason', 'error'),
359
- });
454
+ function isUnknownRecipientError(error) {
455
+ const message = errorMessage(error);
456
+ return /not[ _]found|unknown (agent|recipient)|no such (agent|recipient)|unregistered/i.test(message);
360
457
  }
361
- function agentMessageFromEvent(event) {
362
- const type = event.type;
363
- if (type !== 'message.created' && type !== 'dm.received' && type !== 'group_dm.received' && type !== 'thread.reply') {
364
- return undefined;
365
- }
366
- const message = asRecord(event.message) ?? event;
367
- const fromRecord = asRecord(message.from);
368
- const target = asRecord(message.target);
369
- const from = readString(fromRecord, 'name') ?? readString(message, 'from', 'from_name', 'fromName', 'agent_name', 'agentName');
370
- const body = readString(message, 'text', 'body');
371
- if (!from || body === undefined)
372
- return undefined;
373
- const targetName = readString(target, 'agentName', 'agent_name', 'channelName', 'channel_name')
374
- ?? readString(message, 'to', 'target', 'channel', 'channel_name', 'channelName')
375
- ?? 'factory';
376
- return definedRecord({
377
- from,
378
- target: targetName,
379
- body,
380
- threadId: readString(message, 'threadId', 'thread_id', 'parentId', 'parent_id'),
381
- eventId: readString(message, 'id', 'messageId', 'message_id', 'event_id', 'eventId'),
382
- });
383
- }
384
- function agentExitFromEvent(event) {
385
- const type = event.type;
386
- if (type !== 'agent.exited' && type !== 'agent.exit' && type !== 'agent.status.offline' && type !== 'session.released') {
387
- return undefined;
388
- }
389
- const agent = asRecord(event.agent);
390
- const name = readString(event, 'name', 'agent_name', 'agentName') ?? readString(agent, 'name');
391
- if (!name)
392
- return undefined;
393
- return definedRecord({
394
- name,
395
- reason: readString(event, 'reason', 'status') ?? (type === 'agent.status.offline' ? 'offline' : undefined),
396
- });
397
- }
398
- function invocationAckFrom(value, fallbackInvocationId, fallbackActionName) {
399
- const record = asRecord(value) ?? {};
400
- return {
401
- invocationId: readString(record, 'invocation_id', 'invocationId') ?? fallbackInvocationId,
402
- actionName: readString(record, 'action_name', 'actionName') ?? fallbackActionName,
403
- status: readString(record, 'status'),
404
- dispatchedNodeId: readString(record, 'dispatched_node_id', 'dispatchedNodeId') ?? null,
405
- input: asRecord(record.input),
406
- };
407
- }
408
- function invocationFrom(value, fallbackInvocationId, fallbackActionName) {
409
- const record = asRecord(value) ?? {};
410
- return {
411
- ...invocationAckFrom(record, fallbackInvocationId, fallbackActionName),
412
- output: record.output,
413
- error: readString(record, 'error') ?? null,
414
- completedAt: readString(record, 'completed_at', 'completedAt') ?? null,
415
- };
416
- }
417
- function agentFrom(value) {
418
- const record = asRecord(value);
419
- const name = readString(record, 'name');
420
- return name ? { name } : undefined;
421
- }
422
- function isRelayFleetAgent(value) {
423
- return !!value;
424
- }
425
- function nodeFrom(value) {
426
- const record = asRecord(value);
427
- const name = readString(record, 'name');
428
- if (!record || !name)
429
- return undefined;
430
- return {
431
- name,
432
- status: readString(record, 'status'),
433
- live: readBoolean(record, 'live') ?? readBoolean(record, 'handlers_live', 'handlersLive'),
434
- capabilities: Array.isArray(record.capabilities) ? record.capabilities : [],
435
- };
436
- }
437
- function isRelayFleetNode(value) {
438
- return !!value;
439
- }
440
- async function readResponsePayload(response) {
441
- try {
442
- return await response.json();
443
- }
444
- catch {
445
- return await response.text();
446
- }
458
+ function errorMessage(error) {
459
+ return error instanceof Error ? error.message : String(error);
447
460
  }
448
461
  function definedRecord(input) {
449
462
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
@@ -473,16 +486,6 @@ function readNumber(record, ...keys) {
473
486
  }
474
487
  return undefined;
475
488
  }
476
- function readBoolean(record, ...keys) {
477
- if (!record)
478
- return undefined;
479
- for (const key of keys) {
480
- const value = record[key];
481
- if (typeof value === 'boolean')
482
- return value;
483
- }
484
- return undefined;
485
- }
486
489
  function readNumberArray(record, ...keys) {
487
490
  if (!record)
488
491
  return undefined;
@@ -496,14 +499,4 @@ function readNumberArray(record, ...keys) {
496
499
  }
497
500
  return undefined;
498
501
  }
499
- function newInvocationId(prefix, name) {
500
- return `factory:${prefix}:${name}:${randomUUID()}`;
501
- }
502
- function env(name) {
503
- const value = process.env[name];
504
- return value && value.trim() ? value.trim() : undefined;
505
- }
506
- function globalFetch(...args) {
507
- return fetch(...args);
508
- }
509
502
  //# sourceMappingURL=relay-fleet-client.js.map