@agent-relay/factory 0.1.17 → 0.1.19

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 (52) hide show
  1. package/README.md +46 -10
  2. package/dist/cli/fleet.d.ts +8 -0
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +150 -3
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/dispatch/templates.d.ts +14 -0
  7. package/dist/dispatch/templates.d.ts.map +1 -1
  8. package/dist/dispatch/templates.js +59 -3
  9. package/dist/dispatch/templates.js.map +1 -1
  10. package/dist/fleet/create-fleet.d.ts +2 -0
  11. package/dist/fleet/create-fleet.d.ts.map +1 -1
  12. package/dist/fleet/create-fleet.js +5 -1
  13. package/dist/fleet/create-fleet.js.map +1 -1
  14. package/dist/fleet/internal-fleet-client.d.ts +1 -0
  15. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  16. package/dist/fleet/internal-fleet-client.js +3 -0
  17. package/dist/fleet/internal-fleet-client.js.map +1 -1
  18. package/dist/fleet/relay-fleet-client.d.ts +52 -63
  19. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  20. package/dist/fleet/relay-fleet-client.js +319 -323
  21. package/dist/fleet/relay-fleet-client.js.map +1 -1
  22. package/dist/fleet/relay-workspace-key.d.ts +5 -0
  23. package/dist/fleet/relay-workspace-key.d.ts.map +1 -1
  24. package/dist/fleet/relay-workspace-key.js +7 -0
  25. package/dist/fleet/relay-workspace-key.js.map +1 -1
  26. package/dist/github/index.d.ts +2 -0
  27. package/dist/github/index.d.ts.map +1 -1
  28. package/dist/github/index.js +1 -0
  29. package/dist/github/index.js.map +1 -1
  30. package/dist/github/standalone-babysitter.d.ts +33 -0
  31. package/dist/github/standalone-babysitter.d.ts.map +1 -0
  32. package/dist/github/standalone-babysitter.js +214 -0
  33. package/dist/github/standalone-babysitter.js.map +1 -0
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/node/factory-node.d.ts.map +1 -1
  39. package/dist/node/factory-node.js +14 -0
  40. package/dist/node/factory-node.js.map +1 -1
  41. package/dist/orchestrator/factory.d.ts.map +1 -1
  42. package/dist/orchestrator/factory.js +27 -1
  43. package/dist/orchestrator/factory.js.map +1 -1
  44. package/dist/ports/fleet.d.ts +12 -0
  45. package/dist/ports/fleet.d.ts.map +1 -1
  46. package/dist/testing/fakes.d.ts +18 -0
  47. package/dist/testing/fakes.d.ts.map +1 -1
  48. package/dist/testing/fakes.js +26 -0
  49. package/dist/testing/fakes.js.map +1 -1
  50. package/dist/types.d.ts +2 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +8 -7
@@ -1,74 +1,136 @@
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
+ // Roster means agents that can currently collide with a spawn. Including
130
+ // offline task-exit rows makes deterministic one-shot names permanently
131
+ // sticky and prevents a later review round from spawning a fresh worker.
132
+ messaging.agents.list({ status: 'online' }),
133
+ messaging.nodes.list(),
72
134
  ]);
73
135
  return {
74
136
  agents: agents.map((agent) => ({ name: agent.name })),
@@ -79,18 +141,17 @@ export class RelayFleetClient {
79
141
  })),
80
142
  };
81
143
  }
144
+ // `from`/`data` are not representable on the agent-scoped messaging surface:
145
+ // every send is authored by the factory's own agent identity.
82
146
  async sendMessage(input) {
83
- await this.#getTransport().sendMessage(input);
147
+ await this.#send(input);
84
148
  }
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 };
149
+ // The SDK exposes no sender-side delivery query yet, so a successful send —
150
+ // a durable inbox row on the engine — is the injected signal. Upstream:
151
+ // deliveries.forMessage(messageId) would make this authoritative.
152
+ async waitForInjected(input, _opts) {
153
+ const message = await this.#send(input);
154
+ return { eventId: message.id, targets: [input.to] };
94
155
  }
95
156
  onDeliveryFailed(listener) {
96
157
  this.#ensureEventSubscription();
@@ -109,207 +170,254 @@ export class RelayFleetClient {
109
170
  onAgentExit(listener) {
110
171
  this.#ensureEventSubscription();
111
172
  this.#agentExitListeners.add(listener);
173
+ this.#syncExitWatcher();
112
174
  return () => {
113
175
  this.#agentExitListeners.delete(listener);
176
+ this.#syncExitWatcher();
114
177
  };
115
178
  }
116
179
  async dispose() {
117
180
  if (this.#disposed)
118
181
  return;
119
182
  this.#disposed = true;
183
+ if (this.#watchTimer) {
184
+ clearInterval(this.#watchTimer);
185
+ this.#watchTimer = undefined;
186
+ }
120
187
  for (const unsubscribe of this.#eventUnsubscribers.splice(0)) {
121
188
  unsubscribe();
122
189
  }
123
190
  this.#agentExitListeners.clear();
124
191
  this.#deliveryFailedListeners.clear();
125
192
  this.#agentMessageListeners.clear();
126
- // Only dispose a transport we actually built; never construct one here.
127
- await this.#transport?.dispose?.();
128
- this.#subscribed = false;
193
+ this.#tracked.clear();
194
+ if (this.#eventsStarted) {
195
+ await this.#messaging?.events.disconnect().catch(() => { });
196
+ }
129
197
  }
130
- async #awaitInvocation(actionName, invocationId, initial) {
131
- let status = initial.status ?? 'pending';
198
+ async #send(input) {
199
+ const messaging = await this.#ensureMessaging();
200
+ try {
201
+ if (input.to.startsWith('#')) {
202
+ return await messaging.messages.send({ channel: input.to.slice(1), text: input.text });
203
+ }
204
+ return await messaging.messages.direct({
205
+ to: input.to.startsWith('@') ? input.to.slice(1) : input.to,
206
+ text: input.text,
207
+ });
208
+ }
209
+ catch (error) {
210
+ // Keep the orchestrator's registration-lag retry classification working:
211
+ // a DM to an agent the engine has not registered yet is retryable.
212
+ if (isUnknownRecipientError(error)) {
213
+ throw new Error(`recipient unavailable: ${errorMessage(error)}`);
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ #ensureMessaging() {
219
+ if (this.#messaging)
220
+ return Promise.resolve(this.#messaging);
221
+ this.#messagingReady ??= this.#bootstrapMessaging().catch((error) => {
222
+ // Allow a later call to retry a failed bootstrap (transient network, etc).
223
+ this.#messagingReady = undefined;
224
+ throw error;
225
+ });
226
+ return this.#messagingReady;
227
+ }
228
+ async #bootstrapMessaging() {
229
+ const env = this.#options.env;
230
+ const workspaceKey = resolveRelayWorkspaceKey({
231
+ workspaceKey: this.#options.workspaceKey,
232
+ ...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
233
+ });
234
+ let agentToken = resolveRelayAgentToken({
235
+ agentToken: this.#options.agentToken,
236
+ ...(env ? { env } : {}),
237
+ });
238
+ if (!workspaceKey && !agentToken) {
239
+ throw new Error('RelayFleetClient requires a workspace key (rk_live_…) or agent token (at_live_…); set RELAY_WORKSPACE_KEY or RELAY_AGENT_TOKEN');
240
+ }
241
+ if (!agentToken) {
242
+ agentToken = await this.#registerFactoryAgent(workspaceKey);
243
+ }
244
+ const relay = this.#createRelay({
245
+ ...(workspaceKey ? { workspaceKey } : {}),
246
+ agentToken,
247
+ ...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
248
+ });
249
+ this.#messaging = relay.messaging;
250
+ return this.#messaging;
251
+ }
252
+ // Rotate-on-start is idempotent and leaves nothing secret on disk: the
253
+ // factory adopts its standing workspace identity and mints a fresh token.
254
+ async #registerFactoryAgent(workspaceKey) {
255
+ const bootstrap = this.#createRelay({
256
+ workspaceKey,
257
+ ...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
258
+ });
259
+ const agents = bootstrap.messaging.agents;
260
+ const register = agents.registerOrRotate?.bind(agents) ?? agents.register.bind(agents);
261
+ const registration = await register({ name: this.#agentName });
262
+ return registration.token;
263
+ }
264
+ async #awaitInvocation(actionName, ack) {
265
+ const messaging = await this.#ensureMessaging();
266
+ let status = ack.status ?? 'pending';
132
267
  let invocation;
133
- const deadline = Date.now() + this.#completionTimeoutMs;
268
+ const deadline = Date.now() + this.#spawnAckTimeoutMs;
134
269
  while (!terminalStatuses.has(status)) {
135
270
  if (Date.now() > deadline) {
136
- throw new Error(`Timed out waiting for ${actionName} invocation ${invocationId} to complete (last status: ${status})`);
271
+ throw new Error(`Timed out waiting for ${actionName} invocation ${ack.invocationId} to complete (last status: ${status})`);
137
272
  }
138
273
  if (!openStatuses.has(status)) {
139
- throw new Error(`Unexpected ${actionName} invocation ${invocationId} status: ${status}`);
274
+ throw new Error(`Unexpected ${actionName} invocation ${ack.invocationId} status: ${status}`);
140
275
  }
141
276
  await this.#sleep(this.#pollIntervalMs);
142
- invocation = await this.#getTransport().getInvocation(actionName, invocationId);
143
- status = invocation.status ?? 'pending';
277
+ invocation = await messaging.commands.getInvocation(actionName, ack.invocationId);
278
+ status = invocation.status || 'pending';
144
279
  }
145
- invocation ??= await this.#getTransport().getInvocation(actionName, invocationId);
280
+ invocation ??= await messaging.commands.getInvocation(actionName, ack.invocationId);
146
281
  if (status === 'failed' || status === 'denied') {
147
- throw new Error(`${actionName} invocation ${invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
282
+ throw new Error(`${actionName} invocation ${ack.invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
148
283
  }
149
284
  return invocation;
150
285
  }
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);
286
+ #track(name, ack) {
287
+ this.#tracked.set(name, {
288
+ invocationId: ack.invocationId,
289
+ node: ack.placement?.node ?? ack.dispatchedNodeId ?? undefined,
290
+ spawnedAtMs: this.#now(),
291
+ });
292
+ this.#syncExitWatcher();
293
+ }
294
+ #syncExitWatcher() {
295
+ const shouldRun = !this.#disposed && this.#tracked.size > 0 && this.#agentExitListeners.size > 0;
296
+ if (shouldRun && !this.#watchTimer) {
297
+ this.#watchTimer = setInterval(() => {
298
+ void this.reconcileTrackedAgents().catch((error) => {
299
+ this.#log(`relay fleet exit reconciliation failed: ${errorMessage(error)}`);
300
+ });
301
+ }, this.#exitWatchIntervalMs);
302
+ this.#watchTimer.unref?.();
303
+ }
304
+ else if (!shouldRun && this.#watchTimer) {
305
+ clearInterval(this.#watchTimer);
306
+ this.#watchTimer = undefined;
307
+ }
158
308
  }
159
- #handleEvent(event) {
160
- const deliveryFailed = deliveryFailedFromEvent(event);
161
- if (deliveryFailed) {
162
- for (const listener of this.#deliveryFailedListeners) {
163
- listener(deliveryFailed);
309
+ async #reconcileTracked() {
310
+ if (this.#tracked.size === 0)
311
+ return;
312
+ const messaging = await this.#ensureMessaging();
313
+ const [agents, nodes] = await Promise.all([
314
+ messaging.agents.list({ status: 'all' }),
315
+ messaging.nodes.list(),
316
+ ]);
317
+ const agentsByName = new Map(agents.map((agent) => [agent.name, agent]));
318
+ const nodeLive = new Map(nodes.map((node) => [node.name, node.live ?? node.status === 'online']));
319
+ const nowMs = this.#now();
320
+ for (const [name, entry] of [...this.#tracked]) {
321
+ const row = agentsByName.get(name);
322
+ if (!row || row.status === 'offline') {
323
+ // A just-spawned agent may not have registered with the engine yet;
324
+ // absence only counts as an exit once the grace window has passed.
325
+ if (nowMs - entry.spawnedAtMs < this.#registrationGraceMs)
326
+ continue;
327
+ this.#emitExit(name, 'exited');
328
+ continue;
164
329
  }
165
- }
166
- const message = agentMessageFromEvent(event);
167
- if (message) {
168
- for (const listener of this.#agentMessageListeners) {
169
- listener(message);
330
+ if (!entry.node)
331
+ continue;
332
+ if (nodeLive.get(entry.node) === false || !nodeLive.has(entry.node)) {
333
+ entry.nodeOfflineSinceMs ??= nowMs;
334
+ if (nowMs - entry.nodeOfflineSinceMs >= this.#nodeOfflineGraceMs) {
335
+ this.#emitExit(name, 'node-offline');
336
+ }
170
337
  }
171
- }
172
- const exit = agentExitFromEvent(event);
173
- if (exit) {
174
- for (const listener of this.#agentExitListeners) {
175
- listener(exit.name, exit.reason);
338
+ else {
339
+ entry.nodeOfflineSinceMs = undefined;
176
340
  }
177
341
  }
178
342
  }
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)');
343
+ #emitExit(name, reason) {
344
+ this.#tracked.delete(name);
345
+ this.#syncExitWatcher();
346
+ for (const listener of this.#agentExitListeners) {
347
+ listener(name, reason);
191
348
  }
192
- this.#fetch = options.fetch ?? globalFetch;
193
349
  }
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,
350
+ #ensureEventSubscription() {
351
+ if (this.#eventsStarted)
352
+ return;
353
+ this.#eventsStarted = true;
354
+ void this.#subscribeEvents().catch((error) => {
355
+ this.#eventsStarted = false;
356
+ this.#log(`relay fleet event subscription failed: ${errorMessage(error)}`);
199
357
  });
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
358
  }
241
- dispose() {
242
- this.#socket?.close();
243
- this.#socket = undefined;
244
- this.#eventListeners.clear();
359
+ async #subscribeEvents() {
360
+ const messaging = await this.#ensureMessaging();
361
+ if (this.#disposed)
362
+ return;
363
+ messaging.events.connect();
364
+ this.#eventUnsubscribers.push(messaging.events.on('any', (event) => this.#handleEvent(event)));
245
365
  }
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}`);
366
+ #handleEvent(event) {
367
+ switch (event.type) {
368
+ case 'dmReceived':
369
+ case 'groupDmReceived':
370
+ this.#emitAgentMessage(event.message, this.#agentName);
371
+ break;
372
+ case 'messageCreated':
373
+ case 'threadReply':
374
+ this.#emitAgentMessage(event.message, event.channel);
375
+ break;
376
+ case 'agentOffline':
377
+ this.#handleAgentOffline(event.agent.name);
378
+ break;
379
+ default:
380
+ break;
261
381
  }
262
- return record && 'data' in record ? record.data : payload;
263
382
  }
264
- #connectEvents() {
265
- if (this.#socket)
383
+ #emitAgentMessage(message, fallbackTarget) {
384
+ const from = message.from?.name;
385
+ if (!from || from === this.#agentName)
266
386
  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
- }
387
+ let target;
388
+ const messageTarget = message.target;
389
+ if (messageTarget?.kind === 'agent' && typeof messageTarget.agentName === 'string') {
390
+ target = messageTarget.agentName;
391
+ }
392
+ else if (messageTarget?.kind === 'channel' && typeof messageTarget.channelName === 'string') {
393
+ target = messageTarget.channelName;
394
+ }
395
+ const agentMessage = {
396
+ from,
397
+ target: target ?? fallbackTarget,
398
+ body: message.text,
399
+ ...(message.threadId || message.parentId ? { threadId: message.threadId ?? message.parentId } : {}),
400
+ ...(message.id ? { eventId: message.id } : {}),
298
401
  };
299
- }
300
- #emitEvent(event) {
301
- for (const listener of this.#eventListeners) {
302
- listener(event);
402
+ for (const listener of this.#agentMessageListeners) {
403
+ listener(agentMessage);
303
404
  }
304
405
  }
406
+ // Presence offline is a push hint for exits of agents this client spawned.
407
+ // The roster reconciliation watcher remains the authoritative exit detector.
408
+ #handleAgentOffline(name) {
409
+ if (!this.#tracked.has(name))
410
+ return;
411
+ this.#emitExit(name, 'offline');
412
+ }
305
413
  }
306
- function spawnActionInput(input, opts) {
414
+ // Placement injects capability/node/target_node/repo/cli on top of this
415
+ // payload; spawn_mode/exit_after_task request task-exit lifecycle from the
416
+ // broker on the placed node.
417
+ function spawnActionInput(input) {
307
418
  return definedRecord({
308
- capability: input.capability,
309
419
  name: input.name,
310
420
  agent: input.name,
311
- node: input.node && (opts.includeSelfNode || input.node !== 'self') ? input.node : undefined,
312
- repo: input.repo,
313
421
  clone_path: input.clonePath,
314
422
  clonePath: input.clonePath,
315
423
  session_ref: input.sessionRef,
@@ -321,6 +429,7 @@ function spawnActionInput(input, opts) {
321
429
  cwd: input.cwd,
322
430
  channels: input.channel ? [input.channel] : undefined,
323
431
  restart_policy: input.restartPolicy,
432
+ ...(input.capability.startsWith('spawn:') ? { spawn_mode: 'task_exit', exit_after_task: true } : {}),
324
433
  });
325
434
  }
326
435
  function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation) {
@@ -341,109 +450,16 @@ function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation)
341
450
  }
342
451
  function normalizeCapabilities(capabilities) {
343
452
  const names = (capabilities ?? [])
344
- .map((capability) => typeof capability === 'string' ? capability : capability.name)
345
- .filter((capability) => typeof capability === 'string' && knownCapabilities.has(capability));
453
+ .map((capability) => capability.name)
454
+ .filter((name) => knownCapabilities.has(name));
346
455
  return [...new Set(names)];
347
456
  }
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
- });
457
+ function isUnknownRecipientError(error) {
458
+ const message = errorMessage(error);
459
+ return /not[ _]found|unknown (agent|recipient)|no such (agent|recipient)|unregistered/i.test(message);
360
460
  }
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
- }
461
+ function errorMessage(error) {
462
+ return error instanceof Error ? error.message : String(error);
447
463
  }
448
464
  function definedRecord(input) {
449
465
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
@@ -473,16 +489,6 @@ function readNumber(record, ...keys) {
473
489
  }
474
490
  return undefined;
475
491
  }
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
492
  function readNumberArray(record, ...keys) {
487
493
  if (!record)
488
494
  return undefined;
@@ -496,14 +502,4 @@ function readNumberArray(record, ...keys) {
496
502
  }
497
503
  return undefined;
498
504
  }
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
505
  //# sourceMappingURL=relay-fleet-client.js.map