agent-relay 3.2.21 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +5065 -1422
  6. package/dist/src/cli/bootstrap.d.ts.map +1 -1
  7. package/dist/src/cli/bootstrap.js +2 -0
  8. package/dist/src/cli/bootstrap.js.map +1 -1
  9. package/dist/src/cli/commands/agent-management.d.ts.map +1 -1
  10. package/dist/src/cli/commands/agent-management.js +14 -4
  11. package/dist/src/cli/commands/agent-management.js.map +1 -1
  12. package/dist/src/cli/commands/core.d.ts +2 -6
  13. package/dist/src/cli/commands/core.d.ts.map +1 -1
  14. package/dist/src/cli/commands/core.js +30 -12
  15. package/dist/src/cli/commands/core.js.map +1 -1
  16. package/dist/src/cli/commands/messaging.d.ts.map +1 -1
  17. package/dist/src/cli/commands/messaging.js +10 -3
  18. package/dist/src/cli/commands/messaging.js.map +1 -1
  19. package/dist/src/cli/commands/monitoring.d.ts +2 -2
  20. package/dist/src/cli/commands/monitoring.d.ts.map +1 -1
  21. package/dist/src/cli/commands/monitoring.js +15 -6
  22. package/dist/src/cli/commands/monitoring.js.map +1 -1
  23. package/dist/src/cli/commands/on/dotfiles.d.ts +35 -0
  24. package/dist/src/cli/commands/on/dotfiles.d.ts.map +1 -0
  25. package/dist/src/cli/commands/on/dotfiles.js +157 -0
  26. package/dist/src/cli/commands/on/dotfiles.js.map +1 -0
  27. package/dist/src/cli/commands/on/prereqs.d.ts +15 -0
  28. package/dist/src/cli/commands/on/prereqs.d.ts.map +1 -0
  29. package/dist/src/cli/commands/on/prereqs.js +103 -0
  30. package/dist/src/cli/commands/on/prereqs.js.map +1 -0
  31. package/dist/src/cli/commands/on/provision.d.ts +22 -0
  32. package/dist/src/cli/commands/on/provision.d.ts.map +1 -0
  33. package/dist/src/cli/commands/on/provision.js +157 -0
  34. package/dist/src/cli/commands/on/provision.js.map +1 -0
  35. package/dist/src/cli/commands/on/scan.d.ts +8 -0
  36. package/dist/src/cli/commands/on/scan.d.ts.map +1 -0
  37. package/dist/src/cli/commands/on/scan.js +59 -0
  38. package/dist/src/cli/commands/on/scan.js.map +1 -0
  39. package/dist/src/cli/commands/on/services.d.ts +17 -0
  40. package/dist/src/cli/commands/on/services.d.ts.map +1 -0
  41. package/dist/src/cli/commands/on/services.js +328 -0
  42. package/dist/src/cli/commands/on/services.js.map +1 -0
  43. package/dist/src/cli/commands/on/start.d.ts +61 -0
  44. package/dist/src/cli/commands/on/start.d.ts.map +1 -0
  45. package/dist/src/cli/commands/on/start.js +1071 -0
  46. package/dist/src/cli/commands/on/start.js.map +1 -0
  47. package/dist/src/cli/commands/on/stop.d.ts +4 -0
  48. package/dist/src/cli/commands/on/stop.d.ts.map +1 -0
  49. package/dist/src/cli/commands/on/stop.js +11 -0
  50. package/dist/src/cli/commands/on/stop.js.map +1 -0
  51. package/dist/src/cli/commands/on/token.d.ts +8 -0
  52. package/dist/src/cli/commands/on/token.d.ts.map +1 -0
  53. package/dist/src/cli/commands/on/token.js +26 -0
  54. package/dist/src/cli/commands/on/token.js.map +1 -0
  55. package/dist/src/cli/commands/on/workspace.d.ts +4 -0
  56. package/dist/src/cli/commands/on/workspace.d.ts.map +1 -0
  57. package/dist/src/cli/commands/on/workspace.js +241 -0
  58. package/dist/src/cli/commands/on/workspace.js.map +1 -0
  59. package/dist/src/cli/commands/on.d.ts +10 -0
  60. package/dist/src/cli/commands/on.d.ts.map +1 -0
  61. package/dist/src/cli/commands/on.js +52 -0
  62. package/dist/src/cli/commands/on.js.map +1 -0
  63. package/dist/src/cli/commands/setup.d.ts.map +1 -1
  64. package/dist/src/cli/commands/setup.js +10 -21
  65. package/dist/src/cli/commands/setup.js.map +1 -1
  66. package/dist/src/cli/lib/bridge.js +1 -1
  67. package/dist/src/cli/lib/bridge.js.map +1 -1
  68. package/dist/src/cli/lib/broker-lifecycle.d.ts +14 -4
  69. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  70. package/dist/src/cli/lib/broker-lifecycle.js +82 -120
  71. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  72. package/dist/src/cli/lib/client-factory.d.ts +2 -2
  73. package/dist/src/cli/lib/client-factory.d.ts.map +1 -1
  74. package/dist/src/cli/lib/client-factory.js +14 -11
  75. package/dist/src/cli/lib/client-factory.js.map +1 -1
  76. package/dist/src/cli/lib/core-maintenance.d.ts.map +1 -1
  77. package/dist/src/cli/lib/core-maintenance.js +11 -22
  78. package/dist/src/cli/lib/core-maintenance.js.map +1 -1
  79. package/package.json +14 -11
  80. package/packages/acp-bridge/package.json +2 -2
  81. package/packages/brand/package.json +1 -1
  82. package/packages/cloud/package.json +2 -2
  83. package/packages/config/package.json +1 -1
  84. package/packages/hooks/package.json +4 -4
  85. package/packages/memory/package.json +2 -2
  86. package/packages/openclaw/package.json +2 -2
  87. package/packages/policy/package.json +2 -2
  88. package/packages/sdk/README.md +10 -3
  89. package/packages/sdk/dist/client.d.ts +108 -196
  90. package/packages/sdk/dist/client.d.ts.map +1 -1
  91. package/packages/sdk/dist/client.js +336 -824
  92. package/packages/sdk/dist/client.js.map +1 -1
  93. package/packages/sdk/dist/examples/example.js +2 -5
  94. package/packages/sdk/dist/examples/example.js.map +1 -1
  95. package/packages/sdk/dist/index.d.ts +3 -1
  96. package/packages/sdk/dist/index.d.ts.map +1 -1
  97. package/packages/sdk/dist/index.js +3 -1
  98. package/packages/sdk/dist/index.js.map +1 -1
  99. package/packages/sdk/dist/relay-adapter.d.ts +9 -26
  100. package/packages/sdk/dist/relay-adapter.d.ts.map +1 -1
  101. package/packages/sdk/dist/relay-adapter.js +75 -47
  102. package/packages/sdk/dist/relay-adapter.js.map +1 -1
  103. package/packages/sdk/dist/relay.d.ts +24 -5
  104. package/packages/sdk/dist/relay.d.ts.map +1 -1
  105. package/packages/sdk/dist/relay.js +213 -43
  106. package/packages/sdk/dist/relay.js.map +1 -1
  107. package/packages/sdk/dist/transport.d.ts +58 -0
  108. package/packages/sdk/dist/transport.d.ts.map +1 -0
  109. package/packages/sdk/dist/transport.js +184 -0
  110. package/packages/sdk/dist/transport.js.map +1 -0
  111. package/packages/sdk/dist/types.d.ts +69 -0
  112. package/packages/sdk/dist/types.d.ts.map +1 -0
  113. package/packages/sdk/dist/types.js +5 -0
  114. package/packages/sdk/dist/types.js.map +1 -0
  115. package/packages/sdk/dist/workflows/cli.js +46 -2
  116. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  117. package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
  118. package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
  119. package/packages/sdk/dist/workflows/file-db.js +20 -3
  120. package/packages/sdk/dist/workflows/file-db.js.map +1 -1
  121. package/packages/sdk/dist/workflows/runner.d.ts +6 -1
  122. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  123. package/packages/sdk/dist/workflows/runner.js +157 -11
  124. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  125. package/packages/sdk/dist/workflows/validator.d.ts.map +1 -1
  126. package/packages/sdk/dist/workflows/validator.js +17 -2
  127. package/packages/sdk/dist/workflows/validator.js.map +1 -1
  128. package/packages/sdk/package.json +2 -2
  129. package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
  130. package/packages/sdk/src/__tests__/unit.test.ts +100 -1
  131. package/packages/sdk/src/client.ts +422 -1072
  132. package/packages/sdk/src/examples/example.ts +2 -5
  133. package/packages/sdk/src/index.ts +8 -1
  134. package/packages/sdk/src/relay-adapter.ts +75 -55
  135. package/packages/sdk/src/relay.ts +260 -57
  136. package/packages/sdk/src/transport.ts +216 -0
  137. package/packages/sdk/src/types.ts +75 -0
  138. package/packages/sdk/src/workflows/cli.ts +53 -2
  139. package/packages/sdk/src/workflows/file-db.ts +22 -3
  140. package/packages/sdk/src/workflows/runner.ts +178 -11
  141. package/packages/sdk/src/workflows/validator.ts +20 -2
  142. package/packages/sdk-py/pyproject.toml +1 -1
  143. package/packages/sdk-py/src/agent_relay/__init__.py +0 -8
  144. package/packages/sdk-py/src/agent_relay/client.py +329 -522
  145. package/packages/sdk-py/src/agent_relay/protocol.py +2 -96
  146. package/packages/sdk-py/src/agent_relay/relay.py +1 -4
  147. package/packages/sdk-py/tests/test_wait_for_api_url.py +92 -0
  148. package/packages/sdk-py/uv.lock +5388 -0
  149. package/packages/telemetry/dist/client.d.ts.map +1 -1
  150. package/packages/telemetry/dist/client.js +1 -1
  151. package/packages/telemetry/dist/client.js.map +1 -1
  152. package/packages/telemetry/package.json +1 -1
  153. package/packages/telemetry/src/client.ts +3 -10
  154. package/packages/trajectory/package.json +2 -2
  155. package/packages/user-directory/package.json +2 -2
  156. package/packages/utils/package.json +2 -2
  157. package/scripts/postinstall.js +121 -1
@@ -24,15 +24,14 @@
24
24
  */
25
25
 
26
26
  import { randomBytes } from 'node:crypto';
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
28
  import path from 'node:path';
28
29
 
29
- import {
30
- AgentRelayClient,
31
- AgentRelayProtocolError,
32
- type AgentRelayClientOptions,
33
- type SendMessageInput,
34
- type SpawnPtyInput,
35
- } from './client.js';
30
+ import { RelayCast } from '@relaycast/sdk';
31
+
32
+ import { AgentRelayClient, type AgentRelaySpawnOptions } from './client.js';
33
+ import { AgentRelayProtocolError } from './transport.js';
34
+ import type { SendMessageInput, SpawnPtyInput } from './types.js';
36
35
  import type {
37
36
  AgentRuntime,
38
37
  BrokerEvent,
@@ -75,6 +74,71 @@ function buildUnsupportedOperationMessage(
75
74
  };
76
75
  }
77
76
 
77
+ interface WorkspaceRegistryEntry {
78
+ relaycastApiKey?: string;
79
+ relayfileUrl?: string;
80
+ createdAt?: string;
81
+ agents?: string[];
82
+ }
83
+
84
+ type WorkspaceRegistry = Record<string, WorkspaceRegistryEntry>;
85
+
86
+ const WORKSPACE_ID_PREFIX = 'rw_';
87
+ const WORKSPACE_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
88
+
89
+ function normalizeWorkspaceId(value: string | undefined): string | undefined {
90
+ const trimmed = value?.trim();
91
+ return trimmed ? trimmed : undefined;
92
+ }
93
+
94
+ function generateWorkspaceId(): string {
95
+ const alphabetLength = WORKSPACE_ID_ALPHABET.length;
96
+ const maxUnbiasedValue = Math.floor(256 / alphabetLength) * alphabetLength;
97
+ let suffix = '';
98
+
99
+ while (suffix.length < 8) {
100
+ const bytes = randomBytes(8 - suffix.length);
101
+ for (const byte of bytes) {
102
+ if (byte >= maxUnbiasedValue) continue;
103
+ suffix += WORKSPACE_ID_ALPHABET[byte % alphabetLength];
104
+ if (suffix.length === 8) break;
105
+ }
106
+ }
107
+
108
+ return `${WORKSPACE_ID_PREFIX}${suffix}`;
109
+ }
110
+
111
+ function toWorkspaceRegistryEntry(value: unknown): WorkspaceRegistryEntry {
112
+ if (!value || typeof value !== 'object') {
113
+ return {};
114
+ }
115
+
116
+ const record = value as Record<string, unknown>;
117
+ const relaycastApiKey =
118
+ typeof record.relaycastApiKey === 'string' && record.relaycastApiKey.trim()
119
+ ? record.relaycastApiKey.trim()
120
+ : undefined;
121
+ const relayfileUrl =
122
+ typeof record.relayfileUrl === 'string' && record.relayfileUrl.trim()
123
+ ? record.relayfileUrl.trim()
124
+ : undefined;
125
+ const createdAt =
126
+ typeof record.createdAt === 'string' && record.createdAt.trim() ? record.createdAt.trim() : undefined;
127
+ const agents = Array.isArray(record.agents)
128
+ ? record.agents
129
+ .filter((agent): agent is string => typeof agent === 'string')
130
+ .map((agent) => agent.trim())
131
+ .filter((agent) => agent.length > 0)
132
+ : undefined;
133
+
134
+ return {
135
+ ...(relaycastApiKey ? { relaycastApiKey } : {}),
136
+ ...(relayfileUrl ? { relayfileUrl } : {}),
137
+ ...(createdAt ? { createdAt } : {}),
138
+ ...(agents && agents.length > 0 ? { agents } : {}),
139
+ };
140
+ }
141
+
78
142
  // ── Public types ────────────────────────────────────────────────────────────
79
143
 
80
144
  export interface Message {
@@ -201,7 +265,10 @@ export interface Agent {
201
265
  * @param options.stream — if provided, only invoke callback when the event stream matches (e.g. 'stdout', 'stderr')
202
266
  * @param options.mode — 'chunk' for raw string callbacks, 'structured' for { stream, chunk } callbacks. Auto-detected if omitted.
203
267
  */
204
- onOutput(callback: AgentOutputCallback, options?: { stream?: string; mode?: 'chunk' | 'structured' }): () => void;
268
+ onOutput(
269
+ callback: AgentOutputCallback,
270
+ options?: { stream?: string; mode?: 'chunk' | 'structured' }
271
+ ): () => void;
205
272
  }
206
273
 
207
274
  export interface HumanHandle {
@@ -242,11 +309,18 @@ export interface AgentRelayOptions {
242
309
  cwd?: string;
243
310
  env?: NodeJS.ProcessEnv;
244
311
  requestTimeoutMs?: number;
245
- shutdownTimeoutMs?: number;
246
312
  /**
247
- * Name for the auto-created Relaycast workspace.
248
- * If omitted, a random name is generated.
249
- * Ignored when RELAY_API_KEY is already set in env or process.env.
313
+ * Unified workspace ID shared across relayfile, relayauth claims, and
314
+ * relaycast key lookup.
315
+ */
316
+ workspaceId?: string;
317
+ /**
318
+ * Display name for an auto-created Relaycast workspace.
319
+ * If omitted, the unified workspace ID is used.
320
+ *
321
+ * @deprecated Since v1.x this field falls back to workspaceId when omitted,
322
+ * changing prior behavior where it was required for workspace naming.
323
+ * Callers relying on distinct naming should set this explicitly.
250
324
  */
251
325
  workspaceName?: string;
252
326
  /**
@@ -302,14 +376,17 @@ export class AgentRelay {
302
376
  readonly gemini: AgentSpawner;
303
377
  readonly opencode: AgentSpawner;
304
378
 
305
- private readonly clientOptions: AgentRelayClientOptions;
379
+ private readonly clientOptions: AgentRelaySpawnOptions;
306
380
  private readonly defaultChannels: string[];
381
+ private readonly requestedWorkspaceId?: string;
307
382
  private readonly workspaceName?: string;
308
383
  private readonly relaycastBaseUrl?: string;
309
384
  private relayApiKey?: string;
385
+ private resolvedWorkspaceId?: string;
310
386
  private client?: AgentRelayClient;
311
387
  private startPromise?: Promise<AgentRelayClient>;
312
388
  private unsubEvent?: () => void;
389
+ private readonly stderrListeners = new Set<(line: string) => void>();
313
390
  private readonly knownAgents = new Map<string, Agent>();
314
391
  private readonly readyAgents = new Set<string>();
315
392
  private readonly messageReadyAgents = new Set<string>();
@@ -329,18 +406,25 @@ export class AgentRelay {
329
406
  private idleResolverSeq = 0;
330
407
 
331
408
  constructor(options: AgentRelayOptions = {}) {
409
+ const requestedWorkspaceId = normalizeWorkspaceId(options.workspaceId);
332
410
  this.defaultChannels = options.channels ?? ['general'];
411
+ this.requestedWorkspaceId = requestedWorkspaceId;
333
412
  this.workspaceName = options.workspaceName;
413
+ if (options.workspaceName && !options.workspaceId) {
414
+ console.warn(
415
+ '[AgentRelay] workspaceName without workspaceId is deprecated and will be removed in a future major version. ' +
416
+ 'Set workspaceId explicitly to avoid silent behavior changes.'
417
+ );
418
+ }
334
419
  this.relaycastBaseUrl = options.relaycastBaseUrl;
335
420
  this.clientOptions = {
336
421
  binaryPath: options.binaryPath,
337
422
  binaryArgs: options.binaryArgs,
338
- brokerName: options.brokerName ?? options.workspaceName,
423
+ brokerName: options.brokerName ?? options.workspaceName ?? requestedWorkspaceId,
339
424
  channels: this.defaultChannels,
340
425
  cwd: options.cwd,
341
426
  env: options.env,
342
427
  requestTimeoutMs: options.requestTimeoutMs,
343
- shutdownTimeoutMs: options.shutdownTimeoutMs,
344
428
  };
345
429
 
346
430
  this.codex = this.createSpawner('codex', 'Codex', 'pty');
@@ -349,28 +433,122 @@ export class AgentRelay {
349
433
  this.opencode = this.createSpawner('opencode', 'OpenCode', 'headless');
350
434
  }
351
435
 
436
+ private getWorkspaceRegistryPath(): string {
437
+ return path.join(this.clientOptions.cwd ?? process.cwd(), '.relay', 'workspaces.json');
438
+ }
439
+
440
+ private readWorkspaceRegistry(): WorkspaceRegistry {
441
+ const registryPath = this.getWorkspaceRegistryPath();
442
+ if (!existsSync(registryPath)) {
443
+ return {};
444
+ }
445
+
446
+ let raw: string;
447
+ try {
448
+ raw = readFileSync(registryPath, 'utf8').trim();
449
+ } catch {
450
+ return {};
451
+ }
452
+ if (!raw) {
453
+ return {};
454
+ }
455
+
456
+ let parsed: unknown;
457
+ try {
458
+ parsed = JSON.parse(raw);
459
+ } catch {
460
+ // Registry file is corrupted (partial write, disk full, concurrent access).
461
+ // Return empty registry so callers can re-create it.
462
+ return {};
463
+ }
464
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
465
+ return {};
466
+ }
467
+
468
+ const registry: WorkspaceRegistry = {};
469
+ for (const [workspaceId, entry] of Object.entries(parsed as Record<string, unknown>)) {
470
+ const normalizedId = normalizeWorkspaceId(workspaceId);
471
+ if (!normalizedId) continue;
472
+ registry[normalizedId] = toWorkspaceRegistryEntry(entry);
473
+ }
474
+ return registry;
475
+ }
476
+
477
+ private writeWorkspaceRegistry(registry: WorkspaceRegistry): void {
478
+ const registryPath = this.getWorkspaceRegistryPath();
479
+ mkdirSync(path.dirname(registryPath), { recursive: true });
480
+ writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
481
+ }
482
+
483
+ private persistWorkspaceMapping(workspaceId: string, apiKey: string): void {
484
+ const registry = this.readWorkspaceRegistry();
485
+ const existing = registry[workspaceId] ?? {};
486
+ registry[workspaceId] = {
487
+ ...existing,
488
+ relaycastApiKey: apiKey,
489
+ relayfileUrl: existing.relayfileUrl,
490
+ createdAt: existing.createdAt ?? new Date().toISOString(),
491
+ agents: existing.agents ?? [],
492
+ };
493
+ this.writeWorkspaceRegistry(registry);
494
+ }
495
+
496
+ private findMappedWorkspaceIdByApiKey(apiKey: string): string | undefined {
497
+ const registry = this.readWorkspaceRegistry();
498
+ for (const [workspaceId, entry] of Object.entries(registry)) {
499
+ if (entry.relaycastApiKey === apiKey) {
500
+ return workspaceId;
501
+ }
502
+ }
503
+ return undefined;
504
+ }
505
+
506
+ private getResolvedWorkspaceId(): string | undefined {
507
+ return this.resolvedWorkspaceId ?? this.requestedWorkspaceId;
508
+ }
509
+
510
+ private getRelaycastBaseUrl(): string {
511
+ return (
512
+ this.relaycastBaseUrl ??
513
+ this.clientOptions.env?.RELAYCAST_BASE_URL ??
514
+ process.env.RELAYCAST_BASE_URL ??
515
+ 'https://api.relaycast.dev'
516
+ );
517
+ }
518
+
519
+ private applyWorkspaceEnv(workspaceId: string, apiKey: string): void {
520
+ const env: NodeJS.ProcessEnv = {
521
+ ...(this.clientOptions.env ?? process.env),
522
+ RELAY_API_KEY: apiKey,
523
+ RELAYFILE_WORKSPACE: workspaceId,
524
+ RELAY_DEFAULT_WORKSPACE: workspaceId,
525
+ RELAY_WORKSPACE_ID: workspaceId,
526
+ RELAY_WORKSPACES_JSON: JSON.stringify([{ workspace_id: workspaceId, api_key: apiKey }]),
527
+ };
528
+ if (this.relaycastBaseUrl) {
529
+ env.RELAYCAST_BASE_URL = this.relaycastBaseUrl;
530
+ }
531
+ this.clientOptions.env = env;
532
+ }
533
+
534
+ private async createMappedRelaycastWorkspace(workspaceId: string): Promise<string> {
535
+ const created = await RelayCast.createWorkspace(
536
+ this.workspaceName ?? workspaceId,
537
+ this.getRelaycastBaseUrl()
538
+ );
539
+ return created.apiKey;
540
+ }
541
+
352
542
  /**
353
543
  * Subscribe to broker stderr output. Listener is wired immediately if the
354
544
  * client is already started, otherwise it is attached when the client starts.
355
545
  * Returns an unsubscribe function.
356
546
  */
357
547
  onBrokerStderr(listener: (line: string) => void): () => void {
358
- if (this.client) {
359
- return this.client.onBrokerStderr(listener);
360
- }
361
- // Queue it: once ensureStarted completes, wire it up
362
- let unsub: (() => void) | undefined;
363
- const queuedUnsub = () => {
364
- unsub?.();
548
+ this.stderrListeners.add(listener);
549
+ return () => {
550
+ this.stderrListeners.delete(listener);
365
551
  };
366
- // Use the start promise if one is pending
367
- const promise = this.startPromise ?? this.ensureStarted();
368
- promise
369
- .then((c) => {
370
- unsub = c.onBrokerStderr(listener);
371
- })
372
- .catch(() => {});
373
- return queuedUnsub;
374
552
  }
375
553
 
376
554
  // ── Spawning ────────────────────────────────────────────────────────────
@@ -594,7 +772,7 @@ export class AgentRelay {
594
772
  /** Pre-register a batch of agents with Relaycast before steps execute. */
595
773
  async preflightAgents(agents: Array<{ name: string; cli: string }>): Promise<void> {
596
774
  const client = await this.ensureStarted();
597
- await client.preflightAgents(agents);
775
+ await client.preflight(agents);
598
776
  }
599
777
 
600
778
  /** List agents with PIDs from the broker (for worker registration). */
@@ -939,36 +1117,39 @@ export class AgentRelay {
939
1117
  */
940
1118
  private async ensureRelaycastApiKey(): Promise<void> {
941
1119
  if (this.relayApiKey) {
942
- this.wireRelaycastBaseUrl();
1120
+ const workspaceId = this.getResolvedWorkspaceId();
1121
+ if (workspaceId) {
1122
+ this.applyWorkspaceEnv(workspaceId, this.relayApiKey);
1123
+ try { this.persistWorkspaceMapping(workspaceId, this.relayApiKey); } catch { /* non-critical */ }
1124
+ } else {
1125
+ this.wireRelaycastBaseUrl();
1126
+ }
943
1127
  return;
944
1128
  }
945
1129
 
946
1130
  const envKey = this.clientOptions.env?.RELAY_API_KEY ?? process.env.RELAY_API_KEY;
947
- if (envKey) {
948
- this.relayApiKey = envKey;
949
- // Ensure the broker subprocess inherits the full process env + the key.
950
- // Without this, spawning with an explicit binaryPath but no env option
951
- // would cause the broker to start with an empty environment (no PATH,
952
- // no RELAY_API_KEY), making connect_relay() hang and triggering the
953
- // hello-handshake timeout.
954
- if (!this.clientOptions.env) {
955
- this.clientOptions.env = { ...process.env, RELAY_API_KEY: envKey };
956
- } else if (!this.clientOptions.env.RELAY_API_KEY) {
957
- this.clientOptions.env.RELAY_API_KEY = envKey;
958
- }
959
- this.wireRelaycastBaseUrl();
1131
+ const requestedWorkspaceId = this.requestedWorkspaceId;
1132
+ if (requestedWorkspaceId) {
1133
+ const registry = this.readWorkspaceRegistry();
1134
+ const mappedKey = registry[requestedWorkspaceId]?.relaycastApiKey;
1135
+ const resolvedKey =
1136
+ mappedKey ?? envKey ?? (await this.createMappedRelaycastWorkspace(requestedWorkspaceId));
1137
+ this.relayApiKey = resolvedKey;
1138
+ this.resolvedWorkspaceId = requestedWorkspaceId;
1139
+ this.applyWorkspaceEnv(requestedWorkspaceId, resolvedKey);
1140
+ try { this.persistWorkspaceMapping(requestedWorkspaceId, resolvedKey); } catch { /* non-critical */ }
960
1141
  return;
961
1142
  }
962
1143
 
963
- // No API key in env — broker will create/select its own workspace.
964
- // Ensure the broker process inherits the full environment (PATH, etc.)
965
- // so it can connect to Relaycast. The actual workspace key will be
966
- // read from the broker's hello_ack response in ensureStarted().
967
- if (!this.clientOptions.env) {
968
- this.clientOptions.env = { ...process.env };
969
- }
1144
+ const resolvedWorkspaceId = envKey
1145
+ ? (this.findMappedWorkspaceIdByApiKey(envKey) ?? generateWorkspaceId())
1146
+ : generateWorkspaceId();
1147
+ const resolvedKey = envKey ?? (await this.createMappedRelaycastWorkspace(resolvedWorkspaceId));
970
1148
 
971
- this.wireRelaycastBaseUrl();
1149
+ this.relayApiKey = resolvedKey;
1150
+ this.resolvedWorkspaceId = resolvedWorkspaceId;
1151
+ this.applyWorkspaceEnv(resolvedWorkspaceId, resolvedKey);
1152
+ try { this.persistWorkspaceMapping(resolvedWorkspaceId, resolvedKey); } catch { /* non-critical */ }
972
1153
  }
973
1154
 
974
1155
  /** Inject relaycastBaseUrl into broker env. Explicit option wins over inherited env. */
@@ -983,19 +1164,38 @@ export class AgentRelay {
983
1164
  if (this.startPromise) return this.startPromise;
984
1165
 
985
1166
  this.startPromise = this.ensureRelaycastApiKey()
986
- .then(() => AgentRelayClient.start(this.clientOptions))
1167
+ .then(() =>
1168
+ AgentRelayClient.spawn({
1169
+ ...this.clientOptions,
1170
+ onStderr: (line) => {
1171
+ for (const listener of this.stderrListeners) {
1172
+ try {
1173
+ listener(line);
1174
+ } catch {
1175
+ /* ignore */
1176
+ }
1177
+ }
1178
+ },
1179
+ })
1180
+ )
987
1181
  .then((c) => {
988
- this.client = c;
989
- this.startPromise = undefined;
990
1182
  // Use the workspace key the broker actually connected with.
991
1183
  // This ensures SDK and workers are always on the same workspace.
992
1184
  if (c.workspaceKey) {
993
1185
  this.relayApiKey = c.workspaceKey;
1186
+ const workspaceId = this.getResolvedWorkspaceId();
1187
+ if (workspaceId) {
1188
+ this.applyWorkspaceEnv(workspaceId, c.workspaceKey);
1189
+ try { this.persistWorkspaceMapping(workspaceId, c.workspaceKey); } catch { /* non-critical */ }
1190
+ }
994
1191
  }
995
1192
  this.wireEvents(c);
1193
+ this.client = c;
1194
+ this.startPromise = undefined;
996
1195
  return c;
997
1196
  })
998
1197
  .catch((err) => {
1198
+ this.client = undefined;
999
1199
  this.startPromise = undefined;
1000
1200
  throw err;
1001
1201
  });
@@ -1332,7 +1532,10 @@ export class AgentRelay {
1332
1532
  async unsubscribe(channelsToRemove: string[]) {
1333
1533
  await relay.unsubscribe({ agent: name, channels: channelsToRemove });
1334
1534
  },
1335
- onOutput(callback: AgentOutputCallback, options?: { stream?: string; mode?: 'chunk' | 'structured' }): () => void {
1535
+ onOutput(
1536
+ callback: AgentOutputCallback,
1537
+ options?: { stream?: string; mode?: 'chunk' | 'structured' }
1538
+ ): () => void {
1336
1539
  let listeners = relay.outputListeners.get(name);
1337
1540
  if (!listeners) {
1338
1541
  listeners = new Set();
@@ -0,0 +1,216 @@
1
+ /**
2
+ * BrokerTransport — HTTP/WS transport layer for communicating with the
3
+ * agent-relay broker. Used internally by AgentRelayClient.
4
+ *
5
+ * Handles:
6
+ * - HTTP requests with API key auth and structured error parsing
7
+ * - WebSocket connection for real-time event streaming
8
+ * - Event buffering, replay, and query (mirrors stdio client behavior)
9
+ */
10
+
11
+ import WebSocket from 'ws';
12
+ import type { BrokerEvent } from './protocol.js';
13
+
14
+ export class AgentRelayProtocolError extends Error {
15
+ code: string;
16
+ retryable: boolean;
17
+ data?: unknown;
18
+
19
+ constructor(payload: { code: string; message: string; retryable?: boolean; data?: unknown }) {
20
+ super(payload.message);
21
+ this.name = 'AgentRelayProtocolError';
22
+ this.code = payload.code;
23
+ this.retryable = payload.retryable ?? false;
24
+ this.data = payload.data;
25
+ }
26
+ }
27
+
28
+ export interface BrokerTransportOptions {
29
+ baseUrl: string;
30
+ apiKey?: string;
31
+ /** Timeout in ms for HTTP requests. Default: 30000. */
32
+ requestTimeoutMs?: number;
33
+ /** Maximum number of events to buffer in memory for queryEvents/getLastEvent */
34
+ maxBufferSize?: number;
35
+ }
36
+
37
+ export class BrokerTransport {
38
+ private readonly baseUrl: string;
39
+ private readonly apiKey?: string;
40
+ private readonly requestTimeoutMs: number;
41
+ private readonly maxBufferSize: number;
42
+
43
+ private ws: WebSocket | null = null;
44
+ private eventListeners = new Set<(event: BrokerEvent) => void>();
45
+ private eventBuffer: BrokerEvent[] = [];
46
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
47
+ private sinceSeq = 0;
48
+ private _connected = false;
49
+ private _intentionalClose = false;
50
+
51
+ constructor(options: BrokerTransportOptions) {
52
+ this.baseUrl = options.baseUrl.replace(/\/$/, '');
53
+ this.apiKey = options.apiKey;
54
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
55
+ this.maxBufferSize = options.maxBufferSize ?? 1000;
56
+ }
57
+
58
+ get connected(): boolean {
59
+ return this._connected;
60
+ }
61
+
62
+ get wsUrl(): string {
63
+ return this.baseUrl.replace(/^http/, 'ws') + '/ws';
64
+ }
65
+
66
+ // ── HTTP ─────────────────────────────────────────────────────────────
67
+
68
+ async request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
69
+ const headers = new Headers(init?.headers);
70
+ if (!headers.has('Content-Type')) {
71
+ headers.set('Content-Type', 'application/json');
72
+ }
73
+ if (this.apiKey) {
74
+ headers.set('X-API-Key', this.apiKey);
75
+ }
76
+
77
+ const signal = init?.signal ?? AbortSignal.timeout(this.requestTimeoutMs);
78
+ const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers, signal });
79
+
80
+ if (!res.ok) {
81
+ let body: { code?: string; message?: string; error?: string } | undefined;
82
+ try {
83
+ body = (await res.json()) as { code?: string; message?: string; error?: string };
84
+ } catch {
85
+ // non-JSON error
86
+ }
87
+ throw new AgentRelayProtocolError({
88
+ code: body?.code ?? `http_${res.status}`,
89
+ message: body?.message ?? body?.error ?? res.statusText,
90
+ retryable: res.status >= 500,
91
+ });
92
+ }
93
+
94
+ return res.json() as Promise<T>;
95
+ }
96
+
97
+ // ── WebSocket events ─────────────────────────────────────────────────
98
+
99
+ connect(sinceSeq?: number): void {
100
+ if (this.ws) return;
101
+ // Clear any pending reconnect timer to avoid duplicate connections
102
+ if (this.reconnectTimer) {
103
+ clearTimeout(this.reconnectTimer);
104
+ this.reconnectTimer = null;
105
+ }
106
+ this.sinceSeq = sinceSeq ?? this.sinceSeq;
107
+ this._connect();
108
+ }
109
+
110
+ private _connect(): void {
111
+ this._intentionalClose = false;
112
+ const url = `${this.wsUrl}?sinceSeq=${this.sinceSeq}`;
113
+ const headers: Record<string, string> = {};
114
+ if (this.apiKey) {
115
+ headers['X-API-Key'] = this.apiKey;
116
+ }
117
+
118
+ this.ws = new WebSocket(url, { headers });
119
+
120
+ this.ws.on('open', () => {
121
+ this._connected = true;
122
+ if (this.reconnectTimer) {
123
+ clearTimeout(this.reconnectTimer);
124
+ this.reconnectTimer = null;
125
+ }
126
+ });
127
+
128
+ this.ws.on('message', (data) => {
129
+ try {
130
+ const event = JSON.parse(data.toString()) as BrokerEvent & { seq?: number };
131
+ // Track sequence for replay on reconnect
132
+ if (typeof event.seq === 'number' && event.seq > this.sinceSeq) {
133
+ this.sinceSeq = event.seq;
134
+ }
135
+ // Buffer the event
136
+ this.eventBuffer.push(event);
137
+ if (this.eventBuffer.length > this.maxBufferSize) {
138
+ this.eventBuffer = this.eventBuffer.slice(-this.maxBufferSize);
139
+ }
140
+ // Notify listeners
141
+ for (const listener of this.eventListeners) {
142
+ try {
143
+ listener(event);
144
+ } catch {
145
+ // don't let a bad listener break the stream
146
+ }
147
+ }
148
+ } catch {
149
+ // ignore non-JSON frames (ping/pong)
150
+ }
151
+ });
152
+
153
+ this.ws.on('close', () => {
154
+ this._connected = false;
155
+ this.ws = null;
156
+ // Auto-reconnect after 2s unless intentionally closed
157
+ if (!this._intentionalClose) {
158
+ this.reconnectTimer = setTimeout(() => this._connect(), 2000);
159
+ }
160
+ });
161
+
162
+ this.ws.on('error', () => {
163
+ // error always followed by close
164
+ });
165
+ }
166
+
167
+ disconnect(): void {
168
+ this._intentionalClose = true;
169
+ if (this.reconnectTimer) {
170
+ clearTimeout(this.reconnectTimer);
171
+ this.reconnectTimer = null;
172
+ }
173
+ if (this.ws) {
174
+ this.ws.close();
175
+ this.ws = null;
176
+ }
177
+ this._connected = false;
178
+ }
179
+
180
+ onEvent(listener: (event: BrokerEvent) => void): () => void {
181
+ this.eventListeners.add(listener);
182
+ return () => {
183
+ this.eventListeners.delete(listener);
184
+ };
185
+ }
186
+
187
+ queryEvents(filter?: { kind?: string; name?: string; since?: number; limit?: number }): BrokerEvent[] {
188
+ let events = [...this.eventBuffer];
189
+ if (filter?.kind) {
190
+ events = events.filter((e) => e.kind === filter.kind);
191
+ }
192
+ if (filter?.name) {
193
+ events = events.filter((e) => 'name' in e && e.name === filter.name);
194
+ }
195
+ if (filter?.since !== undefined) {
196
+ const since = filter.since;
197
+ events = events.filter(
198
+ (e) => 'timestamp' in e && typeof e.timestamp === 'number' && e.timestamp >= since
199
+ );
200
+ }
201
+ if (filter?.limit !== undefined) {
202
+ events = events.slice(-filter.limit);
203
+ }
204
+ return events;
205
+ }
206
+
207
+ getLastEvent(kind: string, name?: string): BrokerEvent | undefined {
208
+ for (let i = this.eventBuffer.length - 1; i >= 0; i -= 1) {
209
+ const event = this.eventBuffer[i];
210
+ if (event.kind === kind && (!name || ('name' in event && event.name === name))) {
211
+ return event;
212
+ }
213
+ }
214
+ return undefined;
215
+ }
216
+ }