@agent-relay/factory 0.1.72 → 0.1.74

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 (105) hide show
  1. package/README.md +9 -0
  2. package/dist/cli/diagnose.d.ts +18 -2
  3. package/dist/cli/diagnose.d.ts.map +1 -1
  4. package/dist/cli/diagnose.js +94 -5
  5. package/dist/cli/diagnose.js.map +1 -1
  6. package/dist/cli/fleet.d.ts.map +1 -1
  7. package/dist/cli/fleet.js +10 -0
  8. package/dist/cli/fleet.js.map +1 -1
  9. package/dist/cli/teammate-mcp.d.ts +19 -0
  10. package/dist/cli/teammate-mcp.d.ts.map +1 -0
  11. package/dist/cli/teammate-mcp.js +102 -0
  12. package/dist/cli/teammate-mcp.js.map +1 -0
  13. package/dist/environments/load-profile.d.ts +4 -4
  14. package/dist/environments/stack-descriptor.d.ts +17 -17
  15. package/dist/environments/verification-stack-descriptor.d.ts +32 -32
  16. package/dist/fleet/control-plane-circuit.d.ts +10 -0
  17. package/dist/fleet/control-plane-circuit.d.ts.map +1 -1
  18. package/dist/fleet/control-plane-circuit.js +9 -1
  19. package/dist/fleet/control-plane-circuit.js.map +1 -1
  20. package/dist/fleet/create-fleet.d.ts +2 -0
  21. package/dist/fleet/create-fleet.d.ts.map +1 -1
  22. package/dist/fleet/create-fleet.js +1 -0
  23. package/dist/fleet/create-fleet.js.map +1 -1
  24. package/dist/fleet/internal-fleet-client.d.ts +29 -1
  25. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  26. package/dist/fleet/internal-fleet-client.js +161 -11
  27. package/dist/fleet/internal-fleet-client.js.map +1 -1
  28. package/dist/fleet/relay-fleet-client.d.ts +43 -2
  29. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  30. package/dist/fleet/relay-fleet-client.js +264 -19
  31. package/dist/fleet/relay-fleet-client.js.map +1 -1
  32. package/dist/fleet/teammates.d.ts +46 -0
  33. package/dist/fleet/teammates.d.ts.map +1 -0
  34. package/dist/fleet/teammates.js +424 -0
  35. package/dist/fleet/teammates.js.map +1 -0
  36. package/dist/index.d.ts +11 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/mount/relayfile-cloud-mount-client.d.ts +4 -1
  41. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  42. package/dist/mount/relayfile-cloud-mount-client.js +18 -6
  43. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  44. package/dist/mount/relayfile-github-connection-write.d.ts +3 -2
  45. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
  46. package/dist/mount/relayfile-github-connection-write.js +33 -0
  47. package/dist/mount/relayfile-github-connection-write.js.map +1 -1
  48. package/dist/node/factory-node-runtime.d.ts +17 -0
  49. package/dist/node/factory-node-runtime.d.ts.map +1 -0
  50. package/dist/node/factory-node-runtime.js +100 -0
  51. package/dist/node/factory-node-runtime.js.map +1 -0
  52. package/dist/node/factory-node.d.ts +10 -1
  53. package/dist/node/factory-node.d.ts.map +1 -1
  54. package/dist/node/factory-node.js +13 -2
  55. package/dist/node/factory-node.js.map +1 -1
  56. package/dist/node/factory-persona-card.d.ts +38 -0
  57. package/dist/node/factory-persona-card.d.ts.map +1 -0
  58. package/dist/node/factory-persona-card.js +163 -0
  59. package/dist/node/factory-persona-card.js.map +1 -0
  60. package/dist/node/factory.node.d.ts +1 -1
  61. package/dist/observability/events.d.ts +38 -38
  62. package/dist/orchestrator/dispatch-failure-reason.d.ts +60 -0
  63. package/dist/orchestrator/dispatch-failure-reason.d.ts.map +1 -0
  64. package/dist/orchestrator/dispatch-failure-reason.js +127 -0
  65. package/dist/orchestrator/dispatch-failure-reason.js.map +1 -0
  66. package/dist/orchestrator/factory.d.ts.map +1 -1
  67. package/dist/orchestrator/factory.js +1175 -103
  68. package/dist/orchestrator/factory.js.map +1 -1
  69. package/dist/orchestrator/public-health.d.ts.map +1 -1
  70. package/dist/orchestrator/public-health.js +152 -6
  71. package/dist/orchestrator/public-health.js.map +1 -1
  72. package/dist/orchestrator/release-error.d.ts +26 -0
  73. package/dist/orchestrator/release-error.d.ts.map +1 -0
  74. package/dist/orchestrator/release-error.js +33 -0
  75. package/dist/orchestrator/release-error.js.map +1 -0
  76. package/dist/ports/fleet.d.ts +104 -0
  77. package/dist/ports/fleet.d.ts.map +1 -1
  78. package/dist/ports/fleet.js +10 -1
  79. package/dist/ports/fleet.js.map +1 -1
  80. package/dist/ports/index.d.ts +4 -3
  81. package/dist/ports/index.d.ts.map +1 -1
  82. package/dist/ports/index.js +1 -1
  83. package/dist/ports/index.js.map +1 -1
  84. package/dist/ports/mount.d.ts +10 -2
  85. package/dist/ports/mount.d.ts.map +1 -1
  86. package/dist/ports/writeback.d.ts +45 -4
  87. package/dist/ports/writeback.d.ts.map +1 -1
  88. package/dist/state/watch-state-document.js +4 -1
  89. package/dist/state/watch-state-document.js.map +1 -1
  90. package/dist/testing/fakes.d.ts +8 -3
  91. package/dist/testing/fakes.d.ts.map +1 -1
  92. package/dist/testing/fakes.js +25 -1
  93. package/dist/testing/fakes.js.map +1 -1
  94. package/dist/triage/schema.d.ts +14 -14
  95. package/dist/types.d.ts +170 -14
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/writeback/github.d.ts +17 -9
  98. package/dist/writeback/github.d.ts.map +1 -1
  99. package/dist/writeback/github.js +331 -9
  100. package/dist/writeback/github.js.map +1 -1
  101. package/dist/writeback/linear.d.ts +5 -1
  102. package/dist/writeback/linear.d.ts.map +1 -1
  103. package/dist/writeback/linear.js +89 -1
  104. package/dist/writeback/linear.js.map +1 -1
  105. package/package.json +4 -2
@@ -1,5 +1,8 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { AgentRelay } from '@agent-relay/sdk';
3
+ import { describeControlPlaneError } from './control-plane-circuit.js';
2
4
  import { resolveRelayAgentToken, resolveRelayWorkspaceKey } from './relay-workspace-key.js';
5
+ import { RelaycastTeammateDirectory } from './teammates.js';
3
6
  const knownCapabilities = new Set(['spawn:claude', 'spawn:codex', 'workflow:run', 'preview:tailscale-serve']);
4
7
  const openStatuses = new Set(['pending', 'dispatched', 'invoked']);
5
8
  const terminalStatuses = new Set(['completed', 'failed', 'denied']);
@@ -112,11 +115,26 @@ export class RelayFleetClient {
112
115
  // therefore createFleet({ backend: 'relay' })) never throws merely because no
113
116
  // token is configured.
114
117
  #messaging;
118
+ #teammateDirectory;
115
119
  #messagingReady;
120
+ #messageStreamIdentityReady;
116
121
  #lifecycleActionReady;
117
122
  #authenticatedAgentName;
118
123
  #eventsStarted = false;
124
+ #eventSubscriptionReady;
125
+ #messageObservabilityReady;
126
+ #resolveMessageObservability;
127
+ #rejectMessageObservability;
119
128
  #disposed = false;
129
+ /**
130
+ * The fleet socket's own status.
131
+ *
132
+ * Kept here rather than inferred by callers because the dial is
133
+ * fire-and-forget: `#ensureEventSubscription` cannot return its outcome, so
134
+ * without this the ONLY trace of a failed connect was a `#log` line that no
135
+ * health surface reads.
136
+ */
137
+ #fleetConnect = { state: 'never-attempted', attempts: 0 };
120
138
  #watchTimer;
121
139
  #reconciling;
122
140
  #pendingReleaseRetry;
@@ -146,6 +164,7 @@ export class RelayFleetClient {
146
164
  this.#log = options.log ?? (() => { });
147
165
  this.#readOnly = options.readOnly ?? false;
148
166
  this.#messaging = options.messaging;
167
+ this.#teammateDirectory = options.teammateDirectory;
149
168
  }
150
169
  /** Agents spawned through this client that have not exited or been released. */
151
170
  trackedAgents() {
@@ -416,8 +435,84 @@ export class RelayFleetClient {
416
435
  })),
417
436
  };
418
437
  }
438
+ async discoverTeammates(query) {
439
+ this.#teammateDirectory ??= this.#createTeammateDirectory();
440
+ return await this.#teammateDirectory.discover(query);
441
+ }
419
442
  // `from`/`data` are not representable on the agent-scoped messaging surface:
420
443
  // every send is authored by the factory's own agent identity.
444
+ //
445
+ // That identity is also the `target` stamped on every inbound message (see
446
+ // `#emitAgentMessage`), so a reply waiter must match against it rather than
447
+ // against the `from` its caller asked to send as.
448
+ //
449
+ // `#authenticatedAgentName` starts life as the CONFIGURED `agentName` and is
450
+ // only replaced with the server's answer once something has called
451
+ // `agents.me()`. Returning it synchronously would hand out that pre-auth
452
+ // guess, which is wrong whenever an injected `messaging` or an existing
453
+ // `agentToken` authenticates as a different name. Resolve it for real.
454
+ async effectiveSender() {
455
+ const messaging = await this.#ensureMessaging();
456
+ const identity = await messaging.agents.me();
457
+ const resolved = identity.name?.trim();
458
+ if (resolved)
459
+ this.#authenticatedAgentName = resolved;
460
+ return this.#authenticatedAgentName;
461
+ }
462
+ /**
463
+ * Identify the actual Relay workspace stream rather than this wrapper.
464
+ * Multiple clients authenticated to one workspace receive the same direct
465
+ * messages, so their uncorrelated ask/reply claims must contend.
466
+ */
467
+ async messageStreamIdentity() {
468
+ this.#messageStreamIdentityReady ??= (async () => {
469
+ const messaging = await this.#ensureMessaging();
470
+ const baseUrl = canonicalRelayBaseUrl(this.#options.baseUrl);
471
+ const explicitScope = this.#options.messageStreamScope;
472
+ if (this.#options.messaging && explicitScope !== undefined) {
473
+ if (typeof explicitScope === 'string') {
474
+ const fingerprint = createHash('sha256').update(explicitScope).digest('base64url');
475
+ return `relay:${baseUrl}:explicit:${fingerprint}`;
476
+ }
477
+ return explicitScope;
478
+ }
479
+ try {
480
+ const workspace = await messaging.workspace.info();
481
+ const workspaceId = typeof workspace.id === 'string' ? workspace.id.trim() : '';
482
+ if (workspaceId)
483
+ return `relay:${baseUrl}:workspace:${workspaceId}`;
484
+ }
485
+ catch (error) {
486
+ // Older/injected messaging surfaces may not expose workspace.info(). A
487
+ // credential fingerprint or the injected object below still preserves
488
+ // a stable boundary without failing an otherwise usable message path.
489
+ this.#log(`Unable to resolve Relay workspace identity for teammate claims: ${errorMessage(error)}`);
490
+ }
491
+ // Separate injected wrappers are not evidence of separate streams. If
492
+ // the surface cannot identify its workspace and the caller supplied no
493
+ // explicit scope, fail closed by sharing one endpoint-scoped registry.
494
+ // This may conservatively serialize identical participants in two legacy
495
+ // workspaces, but it cannot admit two waiters for one unknown stream.
496
+ if (this.#options.messaging)
497
+ return `relay:${baseUrl}:injected:unknown`;
498
+ const env = this.#options.env;
499
+ const agentToken = resolveRelayAgentToken({
500
+ agentToken: this.#registeredAgentToken ?? this.#options.agentToken,
501
+ ...(env ? { env } : {}),
502
+ });
503
+ const workspaceKey = resolveRelayWorkspaceKey({
504
+ workspaceKey: this.#options.workspaceKey,
505
+ ...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
506
+ });
507
+ const credential = agentToken ?? workspaceKey;
508
+ if (credential) {
509
+ const fingerprint = createHash('sha256').update(credential).digest('base64url');
510
+ return `relay:${baseUrl}:credential:${fingerprint}`;
511
+ }
512
+ return messaging;
513
+ })();
514
+ return await this.#messageStreamIdentityReady;
515
+ }
421
516
  async sendMessage(input) {
422
517
  await this.#send(input);
423
518
  }
@@ -484,6 +579,7 @@ export class RelayFleetClient {
484
579
  }
485
580
  }
486
581
  this.#disposed = true;
582
+ this.#settleMessageObservability(new Error('Relay fleet client disposed before its event stream connected'));
487
583
  if (this.#watchTimer) {
488
584
  clearInterval(this.#watchTimer);
489
585
  this.#watchTimer = undefined;
@@ -527,6 +623,25 @@ export class RelayFleetClient {
527
623
  throw error;
528
624
  }
529
625
  }
626
+ #createTeammateDirectory() {
627
+ const env = this.#options.env;
628
+ const token = resolveRelayWorkspaceKey({
629
+ workspaceKey: this.#options.workspaceKey,
630
+ ...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
631
+ }) ?? resolveRelayAgentToken({
632
+ agentToken: this.#options.agentToken,
633
+ ...(env ? { env } : {}),
634
+ });
635
+ if (!token) {
636
+ throw new Error('RelayFleetClient teammate discovery requires a workspace key or agent token');
637
+ }
638
+ return new RelaycastTeammateDirectory({
639
+ baseUrl: this.#options.baseUrl,
640
+ token,
641
+ fetch: this.#options.directoryFetch,
642
+ timeoutMs: this.#options.directoryTimeoutMs,
643
+ });
644
+ }
530
645
  #ensureMessaging() {
531
646
  if (this.#messaging)
532
647
  return Promise.resolve(this.#messaging);
@@ -586,7 +701,7 @@ export class RelayFleetClient {
586
701
  async #registerFactoryAgent(workspaceKey) {
587
702
  this.#registrationAttempts += 1;
588
703
  if (this.#registrationAttempts > MAX_REGISTRATION_ATTEMPTS) {
589
- throw new FactoryAgentRegistrationError(this.#agentName, `gave up after ${MAX_REGISTRATION_ATTEMPTS} registration attempts`);
704
+ throw new FactoryAgentRegistrationError(this.#agentName, 'MAX_ATTEMPTS', `gave up after ${MAX_REGISTRATION_ATTEMPTS} registration attempts`);
590
705
  }
591
706
  const bootstrap = this.#createRelay({
592
707
  workspaceKey,
@@ -721,7 +836,7 @@ export class RelayFleetClient {
721
836
  const existing = asRecord(await agents.get(this.#agentName).catch(() => undefined));
722
837
  const agentId = readString(existing, 'id', 'agent_id', 'agentId');
723
838
  if (!agentId) {
724
- throw new FactoryAgentRegistrationError(this.#agentName, `name is taken but the record could not be read back: ${errorMessage(lastError)}`, { cause: lastError });
839
+ throw new FactoryAgentRegistrationError(this.#agentName, 'RECORD_UNREADABLE', `name is taken but the record could not be read back: ${errorMessage(lastError)}`, { cause: lastError });
725
840
  }
726
841
  await this.#assertAgentNotLive(agents, existing);
727
842
  try {
@@ -733,10 +848,10 @@ export class RelayFleetClient {
733
848
  continue;
734
849
  if (error instanceof FactoryAgentRegistrationError)
735
850
  throw error;
736
- throw new FactoryAgentRegistrationError(this.#agentName, `could not reclaim the existing identity: ${errorMessage(error)}`, { cause: error });
851
+ throw new FactoryAgentRegistrationError(this.#agentName, 'TAKEOVER_FAILED', `could not reclaim the existing identity: ${errorMessage(error)}`, { cause: error });
737
852
  }
738
853
  }
739
- throw new FactoryAgentRegistrationError(this.#agentName, `could not reclaim the existing identity: ${errorMessage(lastError)}`, { cause: lastError });
854
+ throw new FactoryAgentRegistrationError(this.#agentName, 'TAKEOVER_EXHAUSTED', `could not reclaim the existing identity: ${errorMessage(lastError)}`, { cause: lastError });
740
855
  }
741
856
  // Seizing a name from a LIVE agent would strand its credential. Our case is a
742
857
  // collision with our own dead record, but the guard is coded rather than
@@ -748,7 +863,7 @@ export class RelayFleetClient {
748
863
  // record that says offline gets as far as the presence check.
749
864
  const status = readString(existing, 'status');
750
865
  if (status !== 'offline') {
751
- throw new FactoryAgentRegistrationError(this.#agentName, `refusing to take over agent in status "${status ?? 'unknown'}"; another factory may still hold this identity`);
866
+ throw new FactoryAgentRegistrationError(this.#agentName, 'STATUS_NOT_OFFLINE', `refusing to take over agent in status "${status ?? 'unknown'}"; another factory may still hold this identity`);
752
867
  }
753
868
  // Presence is the canonical liveness signal, and the agent record can be
754
869
  // stale. Two outcomes are routed apart here, and collapsing them swaps one
@@ -782,10 +897,10 @@ export class RelayFleetClient {
782
897
  }
783
898
  const seenStatus = readString(entry, 'status');
784
899
  if (seenStatus === undefined) {
785
- throw new FactoryAgentRegistrationError(this.#agentName, 'presence lists this agent with no status, so it cannot be confirmed offline');
900
+ throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_STATUS_MISSING', 'presence reported no status for this agent, so it cannot be confirmed offline');
786
901
  }
787
902
  if (LIVE_AGENT_STATUSES.has(seenStatus)) {
788
- throw new FactoryAgentRegistrationError(this.#agentName, `presence reports this agent as "${seenStatus}"; another factory may still hold this identity`);
903
+ throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_REPORTS_LIVE', `presence reports this agent as "${seenStatus}"; another factory may still hold this identity`);
789
904
  }
790
905
  }
791
906
  /**
@@ -803,10 +918,10 @@ export class RelayFleetClient {
803
918
  presence = await agents.presence();
804
919
  }
805
920
  catch (error) {
806
- throw new FactoryAgentRegistrationError(this.#agentName, `presence is unreadable, so the agent cannot be confirmed offline: ${errorMessage(error)}`, { cause: error });
921
+ throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_UNREADABLE', `presence is unreadable, so the agent cannot be confirmed offline: ${errorMessage(error)}`, { cause: error });
807
922
  }
808
923
  if (!Array.isArray(presence)) {
809
- throw new FactoryAgentRegistrationError(this.#agentName, 'presence is unreadable (it did not return a list), so the agent cannot be confirmed offline');
924
+ throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_NOT_A_LIST', 'presence is unreadable (it did not return a list), so the agent cannot be confirmed offline');
810
925
  }
811
926
  // A list whose rows we cannot name is not readable either, however
812
927
  // well-formed the array around them is. Omission only means absence if a
@@ -817,7 +932,7 @@ export class RelayFleetClient {
817
932
  // here, on the read, rather than inferred from the lookup missing.
818
933
  const unnamed = presence.findIndex((row) => readString(asRecord(row), 'agentName', 'agent_name', 'name') === undefined);
819
934
  if (unnamed !== -1) {
820
- throw new FactoryAgentRegistrationError(this.#agentName, `presence is unreadable (row ${unnamed} carries no agent name), so the agent cannot be confirmed offline`);
935
+ throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_ROW_UNNAMED', `presence is unreadable (row ${unnamed} carries no agent name), so the agent cannot be confirmed offline`);
821
936
  }
822
937
  return presence;
823
938
  }
@@ -977,20 +1092,103 @@ export class RelayFleetClient {
977
1092
  if (this.#eventsStarted)
978
1093
  return;
979
1094
  this.#eventsStarted = true;
980
- void this.#subscribeEvents().catch((error) => {
1095
+ this.#fleetConnect = {
1096
+ ...this.#fleetConnect,
1097
+ state: 'connecting',
1098
+ attempts: this.#fleetConnect.attempts + 1,
1099
+ lastAttemptAtMs: this.#now(),
1100
+ };
1101
+ this.#eventSubscriptionReady = this.#subscribeEvents().catch((error) => {
981
1102
  this.#eventsStarted = false;
1103
+ this.#eventSubscriptionReady = undefined;
1104
+ // The rejection now lands in a field a health surface can publish. It kept
1105
+ // going only to `#log` before, which is why a fleet client that never
1106
+ // connected was indistinguishable from a healthy one everywhere.
1107
+ this.#fleetConnect = {
1108
+ ...this.#fleetConnect,
1109
+ state: 'failed',
1110
+ lastFailureAtMs: this.#now(),
1111
+ lastError: describeControlPlaneError(error),
1112
+ };
982
1113
  this.#log(`relay fleet event subscription failed: ${errorMessage(error)}`);
1114
+ throw error;
983
1115
  });
1116
+ // Nothing here awaits it -- callers that must not miss an inbound message
1117
+ // use `whenMessagesObservable()`. The rejection is re-thrown for them and
1118
+ // swallowed here so a background subscription failure stays non-fatal.
1119
+ void this.#eventSubscriptionReady.catch(() => { });
1120
+ }
1121
+ // `onAgentMessage` returns as soon as the listener is in the local set, but
1122
+ // the SDK handler behind it is installed by an async chain (messaging
1123
+ // bootstrap, then lifecycle registration, then `events.connect()`). A caller
1124
+ // that registers a listener and immediately sends can therefore lose a fast
1125
+ // reply that lands before the transport is actually listening. Await this
1126
+ // between the two.
1127
+ async whenMessagesObservable() {
1128
+ this.#ensureEventSubscription();
1129
+ await this.#eventSubscriptionReady;
1130
+ if (this.#fleetConnect.state === 'connected')
1131
+ return;
1132
+ await this.#messageObservabilityPromise();
1133
+ }
1134
+ #messageObservabilityPromise() {
1135
+ if (this.#fleetConnect.state === 'connected')
1136
+ return Promise.resolve();
1137
+ if (!this.#messageObservabilityReady) {
1138
+ const pending = new Promise((resolve, reject) => {
1139
+ this.#resolveMessageObservability = resolve;
1140
+ this.#rejectMessageObservability = reject;
1141
+ });
1142
+ this.#messageObservabilityReady = pending;
1143
+ // The ask-level deadline may stop awaiting this shared handshake first.
1144
+ // Retain the waiter for another caller, but never surface its later
1145
+ // transport rejection as an unhandled promise.
1146
+ void pending.catch(() => { });
1147
+ }
1148
+ return this.#messageObservabilityReady;
1149
+ }
1150
+ #settleMessageObservability(error) {
1151
+ const resolve = this.#resolveMessageObservability;
1152
+ const reject = this.#rejectMessageObservability;
1153
+ this.#messageObservabilityReady = undefined;
1154
+ this.#resolveMessageObservability = undefined;
1155
+ this.#rejectMessageObservability = undefined;
1156
+ if (error)
1157
+ reject?.(error);
1158
+ else
1159
+ resolve?.();
1160
+ }
1161
+ fleetConnectStatus() {
1162
+ return { ...this.#fleetConnect };
984
1163
  }
985
1164
  async #subscribeEvents() {
986
1165
  const messaging = await this.#ensureMessaging();
987
- await this.#ensureLifecycleAction(messaging);
1166
+ if (this.#options.registerLifecycleAction !== false) {
1167
+ await this.#ensureLifecycleAction(messaging);
1168
+ }
988
1169
  if (this.#disposed)
989
1170
  return;
990
- messaging.events.connect();
991
- this.#eventUnsubscribers.push(messaging.events.on('any', (event) => this.#handleEvent(event)));
1171
+ // Listen before dialing so a synchronous lifecycle event cannot race past
1172
+ // the observer. `connect()` is void: returning only proves the SDK accepted
1173
+ // the dial, not that the WebSocket handshake completed.
1174
+ const unsubscribe = messaging.events.on('any', (event) => this.#handleEvent(event));
1175
+ const statusBeforeDial = this.#fleetConnect;
1176
+ try {
1177
+ messaging.events.connect();
1178
+ }
1179
+ catch (error) {
1180
+ unsubscribe();
1181
+ throw error;
1182
+ }
1183
+ this.#eventUnsubscribers.push(unsubscribe);
1184
+ this.#fleetConnect = {
1185
+ ...this.#fleetConnect,
1186
+ ...(this.#fleetConnect === statusBeforeDial ? { state: 'dialed' } : {}),
1187
+ lastDialedAtMs: this.#now(),
1188
+ };
992
1189
  }
993
1190
  #handleEvent(event) {
1191
+ this.#observeFleetConnectionEvent(event);
994
1192
  switch (event.type) {
995
1193
  case 'dmReceived':
996
1194
  case 'groupDmReceived':
@@ -1012,6 +1210,45 @@ export class RelayFleetClient {
1012
1210
  break;
1013
1211
  }
1014
1212
  }
1213
+ /** Translate the SDK's real stream lifecycle into the published status. */
1214
+ #observeFleetConnectionEvent(event) {
1215
+ const now = this.#now();
1216
+ switch (event.type) {
1217
+ case 'disconnected':
1218
+ case 'permanentlyDisconnected':
1219
+ this.#fleetConnect = {
1220
+ ...this.#fleetConnect,
1221
+ state: 'failed',
1222
+ lastFailureAtMs: now,
1223
+ lastError: 'RelayEventStreamDisconnected',
1224
+ };
1225
+ this.#settleMessageObservability(new Error('Relay event stream disconnected before becoming observable'));
1226
+ return;
1227
+ case 'error':
1228
+ this.#fleetConnect = {
1229
+ ...this.#fleetConnect,
1230
+ state: 'failed',
1231
+ lastFailureAtMs: now,
1232
+ lastError: 'RelayEventStreamError',
1233
+ };
1234
+ this.#settleMessageObservability(new Error('Relay event stream failed before becoming observable'));
1235
+ return;
1236
+ case 'reconnecting':
1237
+ this.#fleetConnect = { ...this.#fleetConnect, state: 'connecting' };
1238
+ return;
1239
+ default:
1240
+ // `connected` and every data event are confirmations that the event
1241
+ // stream opened. A void `connect()` call alone never reaches this arm.
1242
+ this.#fleetConnect = {
1243
+ ...this.#fleetConnect,
1244
+ state: 'connected',
1245
+ firstEventAtMs: this.#fleetConnect.firstEventAtMs ?? now,
1246
+ ...(this.#fleetConnect.state !== 'connected' ? { lastConnectedAtMs: now } : {}),
1247
+ lastError: undefined,
1248
+ };
1249
+ this.#settleMessageObservability();
1250
+ }
1251
+ }
1015
1252
  #emitAgentMessage(message, fallbackTarget) {
1016
1253
  const from = message.from?.name;
1017
1254
  if (!from || from === this.#authenticatedAgentName || from === this.#agentName)
@@ -1303,16 +1540,15 @@ function previewReference(value, placementNode) {
1303
1540
  ...(node ? { node } : {}),
1304
1541
  };
1305
1542
  }
1306
- /**
1307
- * Registration could not converge on a usable agent identity. Named so a
1308
- * six-day silent 409 loop surfaces as one actionable failure instead.
1309
- */
1310
1543
  export class FactoryAgentRegistrationError extends Error {
1311
1544
  agentName;
1312
- constructor(agentName, detail, options) {
1545
+ /** Uppercase token so `describeControlPlaneError` renders it after the name. */
1546
+ code;
1547
+ constructor(agentName, code, detail, options) {
1313
1548
  super(`Factory agent "${agentName}" could not register with the relay workspace: ${detail}`);
1314
1549
  this.name = 'FactoryAgentRegistrationError';
1315
1550
  this.agentName = agentName;
1551
+ this.code = code;
1316
1552
  if (options && 'cause' in options) {
1317
1553
  ;
1318
1554
  this.cause = options.cause;
@@ -1341,6 +1577,15 @@ function isIdentityMovedError(error) {
1341
1577
  return true;
1342
1578
  return /agent_identity_conflict|no longer has expected id/i.test(errorMessage(error));
1343
1579
  }
1580
+ function canonicalRelayBaseUrl(value) {
1581
+ const candidate = value ?? DEFAULT_RELAY_BASE_URL;
1582
+ try {
1583
+ return new URL(candidate).toString().replace(/\/$/u, '');
1584
+ }
1585
+ catch {
1586
+ return candidate.replace(/\/$/u, '');
1587
+ }
1588
+ }
1344
1589
  function isUnknownRecipientError(error) {
1345
1590
  const message = errorMessage(error);
1346
1591
  return /not[ _]found|unknown (agent|recipient)|no such (agent|recipient)|unregistered/i.test(message);