@moxt-ai/mobius 0.0.25 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { basename, join } from "node:path";
3
3
  import { styleText } from "node:util";
4
- import { AGENT_ACTIVITY_KIND, AGENT_INSTALL_CANCEL_KIND, AGENT_INSTALL_CANCELLED_KIND, AGENT_INSTALL_COMPLETED_KIND, AGENT_INSTALL_FAILED_KIND, AGENT_INSTALL_PROGRESS_KIND, AGENT_INSTALL_REQUEST_KIND, AGENT_INSTALL_STARTED_KIND, AGENT_MESSAGE_DELTA_KIND, BOT_BINDING_APPROVAL_REQUEST_KIND, BOT_BINDING_APPROVED_KIND, BOT_BINDING_UNAVAILABLE_KIND, BOT_SESSION_PROMPT_KIND, BotExecutionBindingUnavailableEvent, decodeAgentInstallCancelCommand, decodeAgentInstallRequestCommand, decodeBotExecutionBindingApprovalRequest, decodeBotExecutionBindingApprovedEvent, decodeBotExecutionBindingUnavailableEvent, decodeBotSessionPromptCommand, decodeMessageKind, decodeNativeSessionCancel, decodeNativeSessionCommand, decodeNativeSessionTurnForkCommand, decodeRuntimeHeartbeatAckEvent, decodeRuntimeLivenessReadyEvent, decodeSessionCancelCommand, decodeSessionConfigureCommand, decodeSessionElicitationRespondCommand, decodeSessionEventAcknowledgement, decodeSessionEventRejection, decodeSessionPermissionRespondCommand, decodeSessionPrepareCommand, decodeSessionPromptCommand, decodeWorkspaceListCommand, encodeAgentActivityEvent, encodeAgentContentEvent, encodeAgentInstallCancelledEvent, encodeAgentInstallCompletedEvent, encodeAgentInstallFailedEvent, encodeAgentInstallProgressEvent, encodeAgentInstallStartedEvent, encodeAgentMessageDeltaEvent, encodeBotExecutionBindingApprovedEvent, encodeBotExecutionBindingUnavailableEvent, encodeNativeSessionResult, encodeSessionCommandsUpdatedEvent, encodeSessionCompletedEvent, encodeSessionConfigUpdatedEvent, encodeSessionElicitationRequestedEvent, encodeSessionElicitationResolvedEvent, encodeSessionFailedEvent, encodeSessionInfoUpdatedEvent, encodeSessionOperationFailedEvent, encodeSessionPermissionRequestedEvent, encodeSessionPermissionResolvedEvent, encodeSessionReadyEvent, encodeSessionStartedEvent, encodeSessionUsageUpdatedEvent, encodeWorkspaceEntriesEvent, NATIVE_SESSION_CANCEL_KIND, NATIVE_SESSION_REQUEST_KIND, NATIVE_SESSION_TURN_FORK_KIND, OwnerBotReplyRoute, RUNTIME_HEARTBEAT_ACK_KIND, RUNTIME_LIVENESS_READY_KIND, SESSION_CANCEL_KIND, SESSION_COMPLETED_KIND, SESSION_CONFIGURE_KIND, SESSION_ELICITATION_RESPOND_KIND, SESSION_EVENT_ACK_KIND, SESSION_EVENT_REJECTED_KIND, SESSION_FAILED_KIND, SESSION_PERMISSION_RESPOND_KIND, SESSION_PREPARE_KIND, SESSION_PROMPT_KIND, SESSION_STARTED_KIND, SessionPromptDisposition, WORKSPACE_LIST_KIND, } from "@moxt-ai/mobius-protocol";
4
+ import { AGENT_ACTIVITY_KIND, AGENT_INSTALL_CANCEL_KIND, AGENT_INSTALL_CANCELLED_KIND, AGENT_INSTALL_COMPLETED_KIND, AGENT_INSTALL_FAILED_KIND, AGENT_INSTALL_PROGRESS_KIND, AGENT_INSTALL_REQUEST_KIND, AGENT_INSTALL_STARTED_KIND, AGENT_MESSAGE_DELTA_KIND, BOT_BINDING_APPROVAL_REQUEST_KIND, BOT_BINDING_APPROVED_KIND, BOT_BINDING_UNAVAILABLE_KIND, BOT_SESSION_PROMPT_KIND, BotExecutionBindingUnavailableEvent, decodeAgentInstallCancelCommand, decodeAgentInstallRequestCommand, decodeBotExecutionBindingApprovalRequest, decodeBotExecutionBindingApprovedEvent, decodeBotExecutionBindingUnavailableEvent, decodeBotSessionPromptCommand, decodeMessageKind, decodeNativeSessionCancel, decodeNativeSessionCommand, decodeNativeSessionTurnForkCommand, decodeRuntimeHeartbeatAckEvent, decodeRuntimeLivenessReadyEvent, decodeSessionCancelCommand, decodeSessionConfigureCommand, decodeSessionElicitationRespondCommand, decodeSessionEventAcknowledgement, decodeSessionEventEnvelope, decodeSessionEventRejection, decodeSessionPermissionRespondCommand, decodeSessionPrepareCommand, decodeSessionPromptCommand, decodeWorkspaceListCommand, encodeAgentActivityEvent, encodeAgentContentEvent, encodeAgentInstallCancelledEvent, encodeAgentInstallCompletedEvent, encodeAgentInstallFailedEvent, encodeAgentInstallProgressEvent, encodeAgentInstallStartedEvent, encodeAgentMessageDeltaEvent, encodeBotExecutionBindingApprovedEvent, encodeBotExecutionBindingUnavailableEvent, encodeNativeSessionResult, encodeSessionCommandsUpdatedEvent, encodeSessionCompletedEvent, encodeSessionConfigUpdatedEvent, encodeSessionElicitationRequestedEvent, encodeSessionElicitationResolvedEvent, encodeSessionFailedEvent, encodeSessionInfoUpdatedEvent, encodeSessionOperationFailedEvent, encodeSessionPermissionRequestedEvent, encodeSessionPermissionResolvedEvent, encodeSessionReadyEvent, encodeSessionStartedEvent, encodeSessionUsageUpdatedEvent, encodeWorkspaceEntriesEvent, NATIVE_SESSION_CANCEL_KIND, NATIVE_SESSION_REQUEST_KIND, NATIVE_SESSION_TURN_FORK_KIND, OwnerBotReplyRoute, RUNTIME_HEARTBEAT_ACK_KIND, RUNTIME_LIVENESS_READY_KIND, SESSION_CANCEL_KIND, SESSION_COMPLETED_KIND, SESSION_CONFIGURE_KIND, SESSION_ELICITATION_RESPOND_KIND, SESSION_EVENT_ACK_KIND, SESSION_EVENT_REJECTED_KIND, SESSION_FAILED_KIND, SESSION_PERMISSION_RESPOND_KIND, SESSION_PREPARE_KIND, SESSION_PROMPT_KIND, SESSION_STARTED_KIND, SessionPromptDisposition, WORKSPACE_LIST_KIND, } from "@moxt-ai/mobius-protocol";
5
5
  import { fetch, WebSocket } from "undici";
6
6
  import { AcpExecutionError, AcpSessionManager } from "./acp/session-manager.js";
7
7
  import { SqliteAcpSessionStore } from "./acp/session-store.js";
@@ -20,12 +20,13 @@ import { NativeSessionService } from "./native-sessions/service.js";
20
20
  import { environmentProxyDispatcher } from "./network/environment-proxy.js";
21
21
  import { PromptArtifactError, resolvePromptArtifacts } from "./prompt-artifact-resolver.js";
22
22
  import { connectRelay } from "./relay-connection/connect.js";
23
+ import { RelayConnectionDiagnostics } from "./relay-connection/diagnostics.js";
23
24
  import { RelayConnectionLiveness } from "./relay-connection/liveness.js";
24
25
  import { SessionEventOutbox } from "./relay-connection/session-event-outbox.js";
25
26
  import { systemDaemonTimer } from "./relay-connection/timer.js";
26
27
  import { readStateDirectory } from "./storage/state-directory.js";
27
28
  const RELAY_RECONNECT_DELAY_MILLISECONDS = 500;
28
- const RELAY_CONNECTION_REFRESH_MILLISECONDS = 20 * 60 * 1_000;
29
+ const AGENT_ACTIVITY_COALESCING_MILLISECONDS = 100;
29
30
  const ACTIVE_TURN_RECONCILIATION_TIMEOUT_MILLISECONDS = 20_000;
30
31
  const ACTIVE_TURN_RECONCILIATION_CLOSE_CODE = 4009;
31
32
  function botIpcSocketPath() {
@@ -34,56 +35,6 @@ function botIpcSocketPath() {
34
35
  }
35
36
  return join("/tmp", `mobius-bot-${randomUUID()}.sock`);
36
37
  }
37
- class RelayConnectionRefreshState {
38
- }
39
- class ScheduledRelayConnectionRefreshState extends RelayConnectionRefreshState {
40
- }
41
- class DueRelayConnectionRefreshState extends RelayConnectionRefreshState {
42
- }
43
- class FinishedRelayConnectionRefreshState extends RelayConnectionRefreshState {
44
- }
45
- class RelayConnectionRefresh {
46
- #isSafe;
47
- #refresh;
48
- #scheduledTask;
49
- #state = new ScheduledRelayConnectionRefreshState();
50
- constructor(timer, isSafe, refresh) {
51
- this.#isSafe = isSafe;
52
- this.#refresh = refresh;
53
- this.#scheduledTask = timer.schedule(this.#request, RELAY_CONNECTION_REFRESH_MILLISECONDS);
54
- }
55
- afterTurnFinished = () => {
56
- if (this.#state instanceof DueRelayConnectionRefreshState) {
57
- this.#request();
58
- return;
59
- }
60
- if (this.#state instanceof ScheduledRelayConnectionRefreshState ||
61
- this.#state instanceof FinishedRelayConnectionRefreshState) {
62
- return;
63
- }
64
- throw new Error("Unknown relay connection refresh state");
65
- };
66
- close = () => {
67
- this.#scheduledTask.cancel();
68
- this.#state = new FinishedRelayConnectionRefreshState();
69
- };
70
- #request = () => {
71
- if (this.#state instanceof FinishedRelayConnectionRefreshState) {
72
- return;
73
- }
74
- if (!(this.#state instanceof ScheduledRelayConnectionRefreshState) &&
75
- !(this.#state instanceof DueRelayConnectionRefreshState)) {
76
- throw new Error("Unknown relay connection refresh state");
77
- }
78
- if (!this.#isSafe()) {
79
- this.#state = new DueRelayConnectionRefreshState();
80
- return;
81
- }
82
- this.#state = new FinishedRelayConnectionRefreshState();
83
- this.#scheduledTask.cancel();
84
- this.#refresh();
85
- };
86
- }
87
38
  class DaemonAcpObserver {
88
39
  #record;
89
40
  constructor(record) {
@@ -335,7 +286,8 @@ class DaemonSessionEventConnectionState {
335
286
  }
336
287
  class DisconnectedDaemonSessionEventState extends DaemonSessionEventConnectionState {
337
288
  disconnect = () => this;
338
- send = () => undefined;
289
+ matches = () => false;
290
+ send = () => false;
339
291
  }
340
292
  class ConnectedDaemonSessionEventState extends DaemonSessionEventConnectionState {
341
293
  #socket;
@@ -344,40 +296,151 @@ class ConnectedDaemonSessionEventState extends DaemonSessionEventConnectionState
344
296
  this.#socket = socket;
345
297
  }
346
298
  disconnect = (socket) => socket === this.#socket ? new DisconnectedDaemonSessionEventState() : this;
299
+ matches = (socket) => socket === this.#socket;
347
300
  send = (message) => {
348
- if (this.#socket.readyState === WebSocket.OPEN) {
349
- try {
350
- this.#socket.send(message);
351
- }
352
- catch {
353
- // The durable outbox retains the event for the replacement connection.
354
- }
301
+ if (this.#socket.readyState !== WebSocket.OPEN) {
302
+ return false;
303
+ }
304
+ try {
305
+ this.#socket.send(message);
306
+ return true;
307
+ }
308
+ catch {
309
+ // The durable outbox retains the event for the replacement connection.
310
+ return false;
355
311
  }
356
312
  };
357
313
  }
314
+ const SESSION_EVENT_REPLAY_WINDOW = 4;
358
315
  class DaemonSessionEventPublisher {
359
316
  #outbox;
317
+ #inFlightEventIds = new Set();
360
318
  #connection = new DisconnectedDaemonSessionEventState();
361
319
  constructor(outbox) {
362
320
  this.#outbox = outbox;
363
321
  }
364
322
  connect = (socket) => {
365
- const connection = new ConnectedDaemonSessionEventState(socket);
366
- this.#connection = connection;
367
- for (const pending of this.#outbox.pending()) {
368
- connection.send(pending);
369
- }
323
+ this.#connection = new ConnectedDaemonSessionEventState(socket);
324
+ this.#inFlightEventIds.clear();
325
+ this.#publishNextBatch();
370
326
  };
371
327
  disconnect = (socket) => {
328
+ if (!this.#connection.matches(socket)) {
329
+ return;
330
+ }
372
331
  this.#connection = this.#connection.disconnect(socket);
332
+ this.#inFlightEventIds.clear();
373
333
  };
374
334
  recordAttempt = (sessionId, payload, attemptId) => {
375
- this.#connection.send(this.#outbox.enqueue(sessionId, payload, attemptId));
335
+ this.#outbox.enqueue(sessionId, payload, attemptId);
336
+ this.#publishNextBatch();
376
337
  };
377
338
  recordRuntime = (sessionId, payload) => {
378
- this.#connection.send(this.#outbox.enqueue(sessionId, payload));
339
+ this.#outbox.enqueue(sessionId, payload);
340
+ this.#publishNextBatch();
341
+ };
342
+ acknowledge = (acknowledgement) => {
343
+ if (!this.#outbox.acknowledge(acknowledgement)) {
344
+ return;
345
+ }
346
+ this.#inFlightEventIds.delete(acknowledgement.eventId);
347
+ this.#publishNextBatch();
348
+ };
349
+ quarantine = (rejection) => {
350
+ const quarantined = this.#outbox.quarantine(rejection);
351
+ if (!quarantined) {
352
+ return false;
353
+ }
354
+ this.#inFlightEventIds.delete(rejection.eventId);
355
+ this.#publishNextBatch();
356
+ return true;
357
+ };
358
+ #publishNextBatch = () => {
359
+ if (this.#inFlightEventIds.size > 0) {
360
+ return;
361
+ }
362
+ const batch = this.#outbox.pendingBatch(SESSION_EVENT_REPLAY_WINDOW);
363
+ for (const encoded of batch) {
364
+ const envelope = decodeSessionEventEnvelope(encoded);
365
+ if (!this.#connection.send(encoded)) {
366
+ this.#inFlightEventIds.clear();
367
+ return;
368
+ }
369
+ this.#inFlightEventIds.add(envelope.eventId);
370
+ }
371
+ };
372
+ }
373
+ class CoalescingDaemonActivityPublisher {
374
+ #pending = new Map();
375
+ #publish;
376
+ #publishedActivityIds = new Set();
377
+ #scheduled = new Map();
378
+ #timer;
379
+ constructor(timer, publish) {
380
+ this.#publish = publish;
381
+ this.#timer = timer;
382
+ }
383
+ flush = () => {
384
+ const activities = [...this.#pending.values()].sort((left, right) => left.sequence - right.sequence);
385
+ for (const task of this.#scheduled.values()) {
386
+ task.cancel();
387
+ }
388
+ this.#pending.clear();
389
+ this.#scheduled.clear();
390
+ for (const activity of activities) {
391
+ this.#publish(activity);
392
+ }
393
+ };
394
+ record = (activity) => {
395
+ if (activity.status !== "pending" && activity.status !== "running") {
396
+ this.#discardPending(activity.activityId);
397
+ this.#publishedActivityIds.add(activity.activityId);
398
+ this.#publish(activity);
399
+ return Promise.resolve();
400
+ }
401
+ if (!this.#publishedActivityIds.has(activity.activityId)) {
402
+ this.#publishedActivityIds.add(activity.activityId);
403
+ this.#publish(activity);
404
+ return Promise.resolve();
405
+ }
406
+ this.#pending.set(activity.activityId, activity);
407
+ if (!this.#scheduled.has(activity.activityId)) {
408
+ this.#scheduled.set(activity.activityId, this.#timer.schedule(() => this.#flushActivity(activity.activityId), AGENT_ACTIVITY_COALESCING_MILLISECONDS));
409
+ }
410
+ return Promise.resolve();
411
+ };
412
+ #discardPending = (activityId) => {
413
+ this.#scheduled.get(activityId)?.cancel();
414
+ this.#scheduled.delete(activityId);
415
+ this.#pending.delete(activityId);
416
+ };
417
+ #flushActivity = (activityId) => {
418
+ const activity = this.#pending.get(activityId);
419
+ this.#discardPending(activityId);
420
+ if (activity !== undefined) {
421
+ this.#publish(activity);
422
+ }
379
423
  };
380
424
  }
425
+ function encodeDaemonActivity(command, activity) {
426
+ return encodeAgentActivityEvent({
427
+ activityId: activity.activityId,
428
+ activityType: activity.activityType,
429
+ commandId: command.commandId,
430
+ content: activity.content,
431
+ detail: activity.detail,
432
+ input: activity.input,
433
+ kind: AGENT_ACTIVITY_KIND,
434
+ locations: activity.locations,
435
+ name: activity.name,
436
+ output: activity.output,
437
+ sequence: activity.sequence,
438
+ sessionId: command.sessionId,
439
+ status: activity.status,
440
+ title: activity.title,
441
+ toolKind: activity.toolKind,
442
+ });
443
+ }
381
444
  function reportFailure(publisher, command, code, message, attemptId) {
382
445
  publisher.recordAttempt(command.sessionId, encodeSessionFailedEvent({
383
446
  code,
@@ -537,7 +600,10 @@ async function executeSessionConfigure(publisher, socket, command, config, direc
537
600
  reportOperationFailure(socket, command.requestId, command.sessionId, error);
538
601
  }
539
602
  }
540
- async function executePrompt(outbox, publisher, command, config, directories, audit, sessions, attemptId, signal) {
603
+ async function executePrompt(outbox, publisher, command, config, directories, audit, sessions, attemptId, timer, signal) {
604
+ const activities = new CoalescingDaemonActivityPublisher(timer, (activity) => {
605
+ publisher.recordAttempt(command.sessionId, encodeDaemonActivity(command, activity), attemptId);
606
+ });
541
607
  try {
542
608
  const resolved = await resolveSessionDirectories(directories, config.bindingId, command.directoryId, command.additionalDirectoryIds, signal);
543
609
  const agent = config.catalog.find(command.agentId);
@@ -582,26 +648,7 @@ async function executePrompt(outbox, publisher, command, config, directories, au
582
648
  cwd: resolved.cwd,
583
649
  disposition: command.disposition,
584
650
  images: attachments.images,
585
- onActivity: (activity) => {
586
- publisher.recordAttempt(command.sessionId, encodeAgentActivityEvent({
587
- activityId: activity.activityId,
588
- activityType: activity.activityType,
589
- commandId: command.commandId,
590
- content: activity.content,
591
- detail: activity.detail,
592
- input: activity.input,
593
- kind: AGENT_ACTIVITY_KIND,
594
- locations: activity.locations,
595
- name: activity.name,
596
- output: activity.output,
597
- sequence: activity.sequence,
598
- sessionId: command.sessionId,
599
- status: activity.status,
600
- title: activity.title,
601
- toolKind: activity.toolKind,
602
- }), attemptId);
603
- return Promise.resolve();
604
- },
651
+ onActivity: activities.record,
605
652
  onText: (text, messageId) => {
606
653
  publisher.recordAttempt(command.sessionId, encodeAgentMessageDeltaEvent({
607
654
  commandId: command.commandId,
@@ -620,6 +667,7 @@ async function executePrompt(outbox, publisher, command, config, directories, au
620
667
  });
621
668
  stopReason = result.stopReason;
622
669
  }
670
+ activities.flush();
623
671
  publisher.recordAttempt(command.sessionId, encodeSessionCompletedEvent({
624
672
  commandId: command.commandId,
625
673
  kind: SESSION_COMPLETED_KIND,
@@ -634,6 +682,7 @@ async function executePrompt(outbox, publisher, command, config, directories, au
634
682
  });
635
683
  }
636
684
  catch (error) {
685
+ activities.flush();
637
686
  audit.record({
638
687
  commandId: command.commandId,
639
688
  event: "session_failed",
@@ -656,7 +705,10 @@ function botExecutionAgent(agent, contextHandle, socketPath) {
656
705
  MOBIUS_RELAY_URL: "",
657
706
  }, agent.adapterVersion, agent.agentVersion, agent.adapterIdentity, agent.agentIdentity, agent.promptTimeoutMilliseconds, agent.capabilities);
658
707
  }
659
- async function executeBotPrompt(outbox, publisher, command, config, directories, audit, sessions, grants, contexts, applicator, ipc, attemptId, signal) {
708
+ async function executeBotPrompt(outbox, publisher, command, config, directories, audit, sessions, grants, contexts, applicator, ipc, attemptId, timer, signal) {
709
+ const activities = new CoalescingDaemonActivityPublisher(timer, (activity) => {
710
+ publisher.recordAttempt(command.sessionId, encodeDaemonActivity(command, activity), attemptId);
711
+ });
660
712
  try {
661
713
  const deadline = Date.parse(command.executionDeadlineAt);
662
714
  const remaining = deadline - Date.now();
@@ -713,26 +765,7 @@ async function executeBotPrompt(outbox, publisher, command, config, directories,
713
765
  commandId: command.commandId,
714
766
  cwd: resolved.cwd,
715
767
  images: attachments.images,
716
- onActivity: (activity) => {
717
- publisher.recordAttempt(command.sessionId, encodeAgentActivityEvent({
718
- activityId: activity.activityId,
719
- activityType: activity.activityType,
720
- commandId: command.commandId,
721
- content: activity.content,
722
- detail: activity.detail,
723
- input: activity.input,
724
- kind: AGENT_ACTIVITY_KIND,
725
- locations: activity.locations,
726
- name: activity.name,
727
- output: activity.output,
728
- sequence: activity.sequence,
729
- sessionId: command.sessionId,
730
- status: activity.status,
731
- title: activity.title,
732
- toolKind: activity.toolKind,
733
- }), attemptId);
734
- return Promise.resolve();
735
- },
768
+ onActivity: activities.record,
736
769
  onText: (text, messageId) => {
737
770
  publisher.recordAttempt(command.sessionId, encodeAgentMessageDeltaEvent({
738
771
  commandId: command.commandId,
@@ -748,6 +781,7 @@ async function executeBotPrompt(outbox, publisher, command, config, directories,
748
781
  sessionId: command.sessionId,
749
782
  signal: executionSignal,
750
783
  });
784
+ activities.flush();
751
785
  publisher.recordAttempt(command.sessionId, encodeSessionCompletedEvent({
752
786
  commandId: command.commandId,
753
787
  kind: SESSION_COMPLETED_KIND,
@@ -767,6 +801,7 @@ async function executeBotPrompt(outbox, publisher, command, config, directories,
767
801
  }
768
802
  }
769
803
  catch (error) {
804
+ activities.flush();
770
805
  const failure = Date.parse(command.executionDeadlineAt) <= Date.now()
771
806
  ? new LocalBotAuthorizationError("turn_expired", "The Bot execution deadline has expired", {
772
807
  cause: error,
@@ -804,16 +839,16 @@ function waitForRelayRetry(signal, timer) {
804
839
  signal.addEventListener("abort", finish, { once: true });
805
840
  });
806
841
  }
807
- async function publishAgentCatalogUntilAvailable(config, signal, timer) {
842
+ async function publishAgentCatalogUntilAvailable(config, diagnostics, signal, timer) {
808
843
  try {
809
844
  await publishAgentCatalog(config, signal);
810
845
  return;
811
846
  }
812
- catch {
847
+ catch (error) {
813
848
  if (signal.aborted) {
814
849
  return;
815
850
  }
816
- console.warn("Mobius daemon is waiting to publish its Agent catalog");
851
+ diagnostics.catalogFailed(error);
817
852
  }
818
853
  while (!signal.aborted) {
819
854
  await waitForRelayRetry(signal, timer);
@@ -828,11 +863,12 @@ async function publishAgentCatalogUntilAvailable(config, signal, timer) {
828
863
  }
829
864
  }
830
865
  }
831
- async function runRelayConnection(config, directories, audit, sessions, installations, installationObserver, nativeSessions, outbox, publisher, grants, contexts, applicator, ipc, activeTurns, activeAttemptIds, onConnected, signal, timer) {
866
+ async function runRelayConnection(config, directories, audit, sessions, installations, installationObserver, nativeSessions, outbox, publisher, grants, contexts, applicator, ipc, activeTurns, activeAttemptIds, connectionEpoch, diagnostics, onConnected, signal, timer) {
832
867
  const relayUrl = new URL(config.relayUrl);
833
868
  relayUrl.searchParams.set("liveness", "1");
834
869
  const socket = await connectRelay(relayUrl, signal);
835
870
  const reconnectingTurns = [...activeTurns.values()];
871
+ diagnostics.connectionOpened(connectionEpoch);
836
872
  onConnected();
837
873
  const connectionController = new AbortController();
838
874
  const nativeRequests = new Map();
@@ -841,7 +877,6 @@ async function runRelayConnection(config, directories, audit, sessions, installa
841
877
  await new Promise((resolve, reject) => {
842
878
  let finished = false;
843
879
  const cleanup = () => {
844
- connectionRefresh.close();
845
880
  connectionLiveness.close();
846
881
  signal.removeEventListener("abort", stop);
847
882
  socket.removeEventListener("close", handleClose);
@@ -859,57 +894,65 @@ async function runRelayConnection(config, directories, audit, sessions, installa
859
894
  cleanup();
860
895
  resolve();
861
896
  };
862
- const rejectConnection = () => {
897
+ const rejectConnection = (failure) => {
863
898
  if (finished) {
864
899
  return;
865
900
  }
866
901
  finished = true;
867
902
  cleanup();
868
- reject(new Error("Relay WebSocket failed"));
903
+ reject(failure);
869
904
  };
870
905
  const stop = () => {
906
+ diagnostics.connectionClosed(connectionEpoch, 1000, "Daemon stopped", "daemon_stop");
871
907
  closeRelaySocket(socket, 1000, "Daemon stopped");
872
908
  resolveConnection();
873
909
  };
874
- const refreshConnection = () => {
875
- closeRelaySocket(socket, 1000, "Refreshing idle relay connection");
876
- resolveConnection();
910
+ const failAndClose = (failure, code, reason, source) => {
911
+ diagnostics.connectionFailed(connectionEpoch, failure, source);
912
+ diagnostics.connectionClosed(connectionEpoch, code, reason, source);
913
+ closeRelaySocket(socket, code, reason);
914
+ rejectConnection(failure);
877
915
  };
878
916
  const connectionLiveness = new RelayConnectionLiveness(timer, {
879
917
  send: (message) => send(socket, message),
880
- unresponsive: () => {
918
+ unresponsive: (heartbeatId, timeoutMilliseconds) => {
919
+ diagnostics.heartbeatTimedOut(connectionEpoch, heartbeatId, timeoutMilliseconds);
920
+ diagnostics.connectionClosed(connectionEpoch, 4001, "Relay liveness check timed out", "heartbeat_timeout");
881
921
  closeRelaySocket(socket, 4001, "Relay liveness check timed out");
882
922
  resolveConnection();
883
923
  },
884
924
  });
885
- const connectionRefresh = new RelayConnectionRefresh(timer, () => activeTurns.size === 0, refreshConnection);
886
925
  signal.addEventListener("abort", stop, { once: true });
887
- const handleClose = () => {
926
+ const handleClose = (event) => {
927
+ diagnostics.connectionClosed(connectionEpoch, event.code, event.reason, "relay");
888
928
  resolveConnection();
889
929
  };
890
- const handleError = () => {
930
+ const handleError = (event) => {
891
931
  if (signal.aborted) {
892
932
  resolveConnection();
893
933
  return;
894
934
  }
895
- rejectConnection();
935
+ const cause = Reflect.get(event, "error");
936
+ const failure = new Error("Relay WebSocket emitted an error", { cause: cause ?? event });
937
+ diagnostics.connectionFailed(connectionEpoch, failure, "socket_error");
938
+ diagnostics.connectionClosed(connectionEpoch, 1006, "WebSocket error", "socket_error");
939
+ rejectConnection(failure);
896
940
  };
897
941
  const handleMessage = (event) => {
898
942
  const encoded = event.data;
899
943
  if (typeof encoded !== "string") {
900
- closeRelaySocket(socket, 4003, "Text message required");
901
- rejectConnection();
944
+ failAndClose(new Error("Relay sent a non-text message"), 4003, "Text message required", "protocol");
902
945
  return;
903
946
  }
904
947
  try {
905
948
  const kind = decodeMessageKind(encoded);
906
949
  if (kind === SESSION_EVENT_ACK_KIND) {
907
- outbox.acknowledge(decodeSessionEventAcknowledgement(encoded));
950
+ publisher.acknowledge(decodeSessionEventAcknowledgement(encoded));
908
951
  return;
909
952
  }
910
953
  if (kind === SESSION_EVENT_REJECTED_KIND) {
911
954
  const rejection = decodeSessionEventRejection(encoded);
912
- if (outbox.quarantine(rejection)) {
955
+ if (publisher.quarantine(rejection)) {
913
956
  audit.record({
914
957
  commandId: rejection.eventId,
915
958
  details: { code: rejection.code },
@@ -1205,7 +1248,7 @@ async function runRelayConnection(config, directories, audit, sessions, installa
1205
1248
  const turn = new ActiveDaemonTurn(command, receipt.attemptId);
1206
1249
  activeTurns.set(command.commandId, turn);
1207
1250
  activeAttemptIds.set(command.sessionId, receipt.attemptId);
1208
- void executeBotPrompt(outbox, publisher, command, config, directories, audit, sessions, grants, contexts, applicator, ipc, receipt.attemptId, turn.signal)
1251
+ void executeBotPrompt(outbox, publisher, command, config, directories, audit, sessions, grants, contexts, applicator, ipc, receipt.attemptId, timer, turn.signal)
1209
1252
  .catch((error) => {
1210
1253
  console.error("Mobius daemon Bot turn failed", error);
1211
1254
  })
@@ -1215,7 +1258,6 @@ async function runRelayConnection(config, directories, audit, sessions, installa
1215
1258
  activeAttemptIds.delete(command.sessionId);
1216
1259
  }
1217
1260
  turn.finish();
1218
- connectionRefresh.afterTurnFinished();
1219
1261
  });
1220
1262
  return;
1221
1263
  }
@@ -1235,7 +1277,7 @@ async function runRelayConnection(config, directories, audit, sessions, installa
1235
1277
  if (command.disposition !== SessionPromptDisposition.Steer) {
1236
1278
  activeAttemptIds.set(command.sessionId, receipt.attemptId);
1237
1279
  }
1238
- void executePrompt(outbox, publisher, command, config, directories, audit, sessions, receipt.attemptId, turn.signal)
1280
+ void executePrompt(outbox, publisher, command, config, directories, audit, sessions, receipt.attemptId, timer, turn.signal)
1239
1281
  .catch((error) => {
1240
1282
  console.error("Mobius daemon turn failed", error);
1241
1283
  })
@@ -1245,13 +1287,11 @@ async function runRelayConnection(config, directories, audit, sessions, installa
1245
1287
  activeAttemptIds.delete(command.sessionId);
1246
1288
  }
1247
1289
  turn.finish();
1248
- connectionRefresh.afterTurnFinished();
1249
1290
  });
1250
1291
  }
1251
1292
  catch (error) {
1252
1293
  console.error("Mobius daemon rejected a message", error);
1253
- closeRelaySocket(socket, 4008, "Invalid protocol message");
1254
- rejectConnection();
1294
+ failAndClose(new Error("Relay sent an invalid protocol message", { cause: error }), 4008, "Invalid protocol message", "protocol");
1255
1295
  }
1256
1296
  };
1257
1297
  socket.addEventListener("close", handleClose);
@@ -1262,14 +1302,12 @@ async function runRelayConnection(config, directories, audit, sessions, installa
1262
1302
  return;
1263
1303
  }
1264
1304
  publisher.connect(socket);
1265
- void Promise.all(reconnectingTurns.map((turn) => turn.completion)).then(connectionRefresh.afterTurnFinished);
1266
1305
  }, (error) => {
1267
1306
  if (finished) {
1268
1307
  return;
1269
1308
  }
1270
1309
  console.error("Mobius daemon could not reconcile active Agent turns", error);
1271
- closeRelaySocket(socket, ACTIVE_TURN_RECONCILIATION_CLOSE_CODE, "Active Agent turn reconciliation failed");
1272
- rejectConnection();
1310
+ failAndClose(new Error("Active Agent turn reconciliation failed", { cause: error }), ACTIVE_TURN_RECONCILIATION_CLOSE_CODE, "Active Agent turn reconciliation failed", "reconciliation");
1273
1311
  });
1274
1312
  if (signal.aborted) {
1275
1313
  stop();
@@ -1313,9 +1351,10 @@ export async function runDaemon(config, audit, signal, timer = systemDaemonTimer
1313
1351
  const sessions = new AcpSessionManager(new DaemonAcpObserver(recordSessionEvent), sessionStore);
1314
1352
  const directories = new WorkspaceDirectoryService(config.bindingId, config.defaultWorkspace);
1315
1353
  const installationObserver = new DaemonInstallationObserver();
1354
+ const diagnostics = new RelayConnectionDiagnostics(audit, config.channelId, config.relayUrl);
1316
1355
  const nativeSessions = new NativeSessionService(config, directories, sessions, audit);
1317
1356
  const installations = new AgentInstallationManager(specifications, installationExecutor, installationObserver, installationAudit);
1318
- let retryAnnounced = false;
1357
+ let connectionEpoch = 0;
1319
1358
  let connectionAnnounced = false;
1320
1359
  const announceConnection = () => {
1321
1360
  if (connectionAnnounced) {
@@ -1326,22 +1365,19 @@ export async function runDaemon(config, audit, signal, timer = systemDaemonTimer
1326
1365
  process.stdout.write("Mobius daemon connected to the relay.\n\n");
1327
1366
  process.stdout.write(`${styleText(["bold", "yellow"], `Reconnect this channel later:\n ${reconnectCommand}`)}\n\n`);
1328
1367
  };
1329
- const catalogPublication = publishAgentCatalogUntilAvailable(config, signal, timer);
1368
+ const catalogPublication = publishAgentCatalogUntilAvailable(config, diagnostics, signal, timer);
1330
1369
  try {
1331
1370
  await ipc.listen(signal);
1332
1371
  while (!signal.aborted) {
1372
+ connectionEpoch += 1;
1333
1373
  try {
1334
- await runRelayConnection(config, directories, audit, sessions, installations, installationObserver, nativeSessions, outbox, publisher, grants, contexts, applicator, ipc, activeTurns, activeAttemptIds, announceConnection, signal, timer);
1335
- retryAnnounced = false;
1374
+ await runRelayConnection(config, directories, audit, sessions, installations, installationObserver, nativeSessions, outbox, publisher, grants, contexts, applicator, ipc, activeTurns, activeAttemptIds, connectionEpoch, diagnostics, announceConnection, signal, timer);
1336
1375
  }
1337
- catch {
1376
+ catch (error) {
1338
1377
  if (signal.aborted) {
1339
1378
  return;
1340
1379
  }
1341
- if (!retryAnnounced) {
1342
- console.warn("Mobius daemon is waiting for the relay");
1343
- retryAnnounced = true;
1344
- }
1380
+ diagnostics.connectionFailed(connectionEpoch, error, "connection");
1345
1381
  }
1346
1382
  await waitForRelayRetry(signal, timer);
1347
1383
  }