@camerontaylor/paseo-client 0.12.0-beta.1.fork.1 → 0.13.0-fork.1

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,3 +1,4 @@
1
+ import { HOST_ROUTE_OPCODE, encodeHostRouteFrame, decodeHostRouteFrame, } from "@camerontaylor/paseo-protocol/binary-frames/index";
1
2
  import { messageTooLargeError } from "./daemon-client-transport-utils.js";
2
3
  import { legacyUsageIcon } from "./legacy-usage-icons.js";
3
4
  import { subscribeTimeline } from "./timeline-subscription/index.js";
@@ -10,7 +11,7 @@ import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema,
10
11
  import { validateWSOutboundMessage } from "@camerontaylor/paseo-protocol/validation/ws-outbound";
11
12
  import { isRelayClientWebSocketUrl } from "@camerontaylor/paseo-protocol/daemon-endpoints";
12
13
  import { asUint8Array, decodeFileTransferFrame, encodeFileTransferFrame, decodeTerminalStreamFrame, FileTransferOpcode, TerminalStreamOpcode, } from "@camerontaylor/paseo-protocol/binary-frames/index";
13
- import { createRelayE2eeTransportFactory, createRelayTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, describeTransportClose, describeTransportError, } from "./daemon-client-transport.js";
14
+ import { createRelayE2eeTransportFactory, createRelayUpgradeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, describeEndpointSecurityClose, describeTransportClose, describeTransportError, } from "./daemon-client-transport.js";
14
15
  import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
15
16
  import { normalizeListProviderModelsPayload, normalizeProviderSnapshotUpdateMessage, normalizeProvidersSnapshotPayload, } from "./compat/normalize-provider-models.js";
16
17
  import { TerminalStreamRouter } from "./terminal-stream-router.js";
@@ -128,9 +129,11 @@ function normalizeListCommandsOptions(input, legacyOptions) {
128
129
  return { agentId: input, ...legacyOptions };
129
130
  }
130
131
  export class DaemonConnectionError extends Error {
131
- constructor(message, code = "DAEMON_CONNECTION_LOST") {
132
- super(message);
132
+ constructor(message, code = "DAEMON_CONNECTION_LOST", context) {
133
+ super(message, context?.cause ? { cause: context.cause } : undefined);
133
134
  this.code = code;
135
+ this.serverId = context?.serverId;
136
+ this.outcome = context?.outcome;
134
137
  this.name = "DaemonConnectionError";
135
138
  }
136
139
  }
@@ -146,6 +149,8 @@ class DaemonRpcError extends Error {
146
149
  this.requestId = params.requestId;
147
150
  this.requestType = params.requestType;
148
151
  this.code = params.code;
152
+ this.serverId = params.serverId;
153
+ this.userMessage = params.error;
149
154
  }
150
155
  }
151
156
  class DaemonProtocolError extends Error {
@@ -261,15 +266,32 @@ function assertTransportFrameFits(frame) {
261
266
  if (size > MAX_WEBSOCKET_MESSAGE_BYTES)
262
267
  throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES);
263
268
  }
264
- function describeInboundTransportFrame(frame, rawBytes) {
269
+ function describeInboundTransportFrame(frame) {
265
270
  if (typeof frame === "string") {
266
- return { kind: "text", size: String(frame.length) };
271
+ return { kind: "text", size: String(frame.length), sizeUnit: "utf16-code-units" };
267
272
  }
268
- if (rawBytes) {
269
- return { kind: "binary", size: String(rawBytes.byteLength) };
273
+ if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) {
274
+ return { kind: "binary", size: String(frame.byteLength), sizeUnit: "bytes" };
270
275
  }
271
276
  return { kind: "unknown", size: "0" };
272
277
  }
278
+ function describeRequestIdentity(value) {
279
+ if (!value || typeof value !== "object")
280
+ return {};
281
+ const message = "message" in value ? value.message : value;
282
+ if (!message || typeof message !== "object")
283
+ return {};
284
+ if ("requestId" in message && typeof message.requestId === "string")
285
+ return { requestId: message.requestId };
286
+ if ("payload" in message &&
287
+ message.payload &&
288
+ typeof message.payload === "object" &&
289
+ "requestId" in message.payload &&
290
+ typeof message.payload.requestId === "string") {
291
+ return { requestId: message.payload.requestId };
292
+ }
293
+ return {};
294
+ }
273
295
  function hashForLog(value) {
274
296
  let hash = 0;
275
297
  for (let index = 0; index < value.length; index += 1) {
@@ -371,6 +393,7 @@ export class DaemonClient {
371
393
  this.connectTimeout = null;
372
394
  this.pendingGenericTransportErrorTimeout = null;
373
395
  this.reconnectAttempt = 0;
396
+ this.traceConnectionAttempt = 0;
374
397
  this.shouldReconnect = true;
375
398
  this.connectPromise = null;
376
399
  this.connectResolve = null;
@@ -391,11 +414,14 @@ export class DaemonClient {
391
414
  this.livenessHeartbeatTimer = null;
392
415
  this.lastLivenessRttMs = null;
393
416
  this.consecutiveLivenessFailures = 0;
417
+ this.hostFrameListeners = new Set();
394
418
  // ============================================================================
395
419
  // Agent Lifecycle
396
420
  // ============================================================================
397
421
  this.creations = new CreationClient({
398
- supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true,
422
+ supports: (feature) => {
423
+ return this.lastServerInfoMessage?.features?.[feature] === true;
424
+ },
399
425
  requestId: () => this.createRequestId(),
400
426
  request: (kind, input) => kind === "workspace"
401
427
  ? this.sendCorrelatedSessionRequest({
@@ -432,6 +458,7 @@ export class DaemonClient {
432
458
  legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
433
459
  });
434
460
  this.logger = config.logger ?? consoleLogger;
461
+ this.relaySecurityFloorValue = config.e2ee?.minimumMode;
435
462
  this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct";
436
463
  let parsedUrlForLog = null;
437
464
  try {
@@ -521,13 +548,14 @@ export class DaemonClient {
521
548
  const baseTransportFactory = this.config.transportFactory ??
522
549
  createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory);
523
550
  const isRelayTransport = isRelayClientWebSocketUrl(this.config.url);
524
- const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport;
525
551
  this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport);
552
+ // Relay upgrades are routing only: credentials never reach the relay socket.
526
553
  let transportFactory = isRelayTransport
527
- ? createRelayTransportFactory(baseTransportFactory)
554
+ ? createRelayUpgradeTransportFactory(baseTransportFactory)
528
555
  : baseTransportFactory;
529
- if (shouldUseRelayE2ee) {
530
- const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64;
556
+ // Endpoint security is selected by configuration, never inferred from the URL.
557
+ if (this.config.e2ee?.enabled === true) {
558
+ const daemonPublicKeyB64 = this.config.e2ee.daemonPublicKeyB64;
531
559
  if (!daemonPublicKeyB64) {
532
560
  throw new Error("daemonPublicKeyB64 is required for relay E2EE");
533
561
  }
@@ -535,6 +563,12 @@ export class DaemonClient {
535
563
  baseFactory: transportFactory,
536
564
  daemonPublicKeyB64,
537
565
  logger: this.logger,
566
+ ...(this.relaySecurityFloorValue ? { minimumMode: this.relaySecurityFloorValue } : {}),
567
+ onSecurityMode: (mode) => {
568
+ if (mode === "protected")
569
+ this.relaySecurityFloorValue = "protected";
570
+ this.config.e2ee?.onSecurityMode?.(mode);
571
+ },
538
572
  });
539
573
  }
540
574
  const transportUrl = this.resolveTransportUrlForAttempt();
@@ -543,6 +577,7 @@ export class DaemonClient {
543
577
  headers: selected.headers,
544
578
  ...(selected.protocols ? { protocols: selected.protocols } : {}),
545
579
  });
580
+ this.traceConnectionAttempt += 1;
546
581
  this.transport = transport;
547
582
  this.lastServerInfoMessage = null;
548
583
  this.authFailureReasonValue = null;
@@ -566,6 +601,7 @@ export class DaemonClient {
566
601
  }, timeoutMs);
567
602
  this.transportCleanup = [
568
603
  transport.onOpen(() => {
604
+ this.traceInstant("paseo.connection.open");
569
605
  if (this.pendingGenericTransportErrorTimeout) {
570
606
  clearTimeout(this.pendingGenericTransportErrorTimeout);
571
607
  this.pendingGenericTransportErrorTimeout = null;
@@ -574,25 +610,34 @@ export class DaemonClient {
574
610
  void this.sendHelloMessage();
575
611
  }),
576
612
  transport.onClose((event) => {
613
+ this.traceInstant("paseo.connection.close", {
614
+ code: String(event && typeof event === "object" && "code" in event ? event.code : "unknown"),
615
+ });
577
616
  this.resetConnectTimeout();
578
617
  if (this.pendingGenericTransportErrorTimeout) {
579
618
  clearTimeout(this.pendingGenericTransportErrorTimeout);
580
619
  this.pendingGenericTransportErrorTimeout = null;
581
620
  }
582
621
  this.authFailureReasonValue ?? (this.authFailureReasonValue = authFailureFromLegacyClose(event));
622
+ // An endpoint security policy close cannot be fixed by retrying.
623
+ const securityFailure = describeEndpointSecurityClose(event);
624
+ if (securityFailure)
625
+ this.setReconnectEnabled(false);
583
626
  const reason = this.authFailureReasonValue
584
627
  ? new DaemonAuthenticationError(this.authFailureReasonValue).message
585
- : describeTransportClose(event);
628
+ : (securityFailure ?? describeTransportClose(event));
586
629
  if (reason) {
587
630
  this.lastErrorValue = reason;
588
631
  }
589
632
  this.scheduleReconnect({
590
633
  reason,
634
+ failure: event instanceof Error ? event : undefined,
591
635
  event: "TRANSPORT_CLOSE",
592
636
  reasonCode: "transport_closed",
593
637
  });
594
638
  }),
595
639
  transport.onError((event) => {
640
+ this.traceInstant("paseo.connection.error");
596
641
  this.resetConnectTimeout();
597
642
  const reason = describeTransportError(event);
598
643
  const isGeneric = reason === "Transport error";
@@ -609,6 +654,7 @@ export class DaemonClient {
609
654
  this.lastErrorValue = reason;
610
655
  this.scheduleReconnect({
611
656
  reason,
657
+ failure: event instanceof Error ? event : undefined,
612
658
  event: "TRANSPORT_ERROR",
613
659
  reasonCode: "transport_error",
614
660
  });
@@ -624,6 +670,7 @@ export class DaemonClient {
624
670
  this.lastErrorValue = reason;
625
671
  this.scheduleReconnect({
626
672
  reason,
673
+ failure: event instanceof Error ? event : undefined,
627
674
  event: "TRANSPORT_ERROR",
628
675
  reasonCode: "transport_error",
629
676
  });
@@ -673,9 +720,7 @@ export class DaemonClient {
673
720
  return;
674
721
  }
675
722
  this.shouldReconnect = false;
676
- this.connectPromise = null;
677
- this.connectResolve = null;
678
- this.connectReject = null;
723
+ this.rejectConnect(new Error("Daemon client closed"));
679
724
  if (this.reconnectTimeout) {
680
725
  clearTimeout(this.reconnectTimeout);
681
726
  this.reconnectTimeout = null;
@@ -716,7 +761,7 @@ export class DaemonClient {
716
761
  this.attemptConnect();
717
762
  return;
718
763
  }
719
- void this.connect();
764
+ void this.connect().catch((error) => this.logger.debug({ err: error }, "Connection attempt ended"));
720
765
  }
721
766
  verifyConnection() {
722
767
  const transport = this.transport;
@@ -742,6 +787,111 @@ export class DaemonClient {
742
787
  this.connectionVerification = null;
743
788
  });
744
789
  }
790
+ requireConnectedHosts() {
791
+ if (!this.isConnected)
792
+ throw Object.assign(new Error("The managing daemon is offline"), {
793
+ code: "host_offline",
794
+ serverId: this.destinationServerId,
795
+ });
796
+ if (this.lastServerInfoMessage?.features?.connectedHosts !== true)
797
+ throw Object.assign(new Error("Update the managing daemon to use connected hosts"), {
798
+ code: "unsupported_capability",
799
+ });
800
+ }
801
+ async listHosts() {
802
+ this.requireConnectedHosts();
803
+ return (await this.sendCorrelatedSessionRequest({
804
+ message: { type: "hosts.list.request" },
805
+ responseType: "hosts.list.response",
806
+ })).directory;
807
+ }
808
+ observeHosts() {
809
+ this.requireConnectedHosts();
810
+ return this.owned.observe(async (accept) => {
811
+ const result = await this.sendCorrelatedSessionRequest({
812
+ message: { type: "hosts.list.request", observe: true },
813
+ responseType: "hosts.list.response",
814
+ });
815
+ if (!result.subscriptionId)
816
+ throw new Error("Host omitted subscription identity");
817
+ const snapshot = { ...result.directory, subscriptionId: result.subscriptionId };
818
+ accept(snapshot);
819
+ return snapshot;
820
+ });
821
+ }
822
+ async addHost(input) {
823
+ this.requireConnectedHosts();
824
+ return (await this.sendCorrelatedSessionRequest({
825
+ message: { type: "hosts.add.request", input },
826
+ responseType: "hosts.add.response",
827
+ })).host;
828
+ }
829
+ async updateHost(registrationId, patch) {
830
+ this.requireConnectedHosts();
831
+ return (await this.sendCorrelatedSessionRequest({
832
+ message: { type: "hosts.update.request", registrationId, patch },
833
+ responseType: "hosts.update.response",
834
+ })).host;
835
+ }
836
+ async removeHost(registrationId) {
837
+ this.requireConnectedHosts();
838
+ await this.sendCorrelatedSessionRequest({
839
+ message: { type: "hosts.remove.request", registrationId },
840
+ responseType: "hosts.remove.response",
841
+ });
842
+ }
843
+ async openHostRoute(serverId, scopeId) {
844
+ this.requireConnectedHosts();
845
+ try {
846
+ return (await this.sendCorrelatedSessionRequest({
847
+ message: { type: "hosts.route.open.request", serverId, scopeId },
848
+ responseType: "hosts.route.open.response",
849
+ })).routeId;
850
+ }
851
+ catch (error) {
852
+ throw Object.assign(error instanceof Error ? error : new Error("Host route failed"), {
853
+ serverId,
854
+ });
855
+ }
856
+ }
857
+ async openHostLink(channelId) {
858
+ this.requireConnectedHosts();
859
+ if (this.getLastServerInfoMessage()?.features?.hostMultiplexing !== true)
860
+ throw Object.assign(new Error("Update the destination daemon"), {
861
+ code: "unsupported_capability",
862
+ });
863
+ return (await this.sendCorrelatedSessionRequest({
864
+ message: { type: "hosts.link.open.request", channelId },
865
+ responseType: "hosts.link.open.response",
866
+ })).routeId;
867
+ }
868
+ sendHostFrame(routeId, data) {
869
+ if (typeof data === "string") {
870
+ this.sendSessionMessageStrict({
871
+ type: "hosts.route.frame.request",
872
+ routeId,
873
+ data,
874
+ binary: false,
875
+ });
876
+ }
877
+ else {
878
+ if (!this.isConnected || !this.transport)
879
+ throw this.unavailableError();
880
+ this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
881
+ }
882
+ }
883
+ subscribeHostFrames(handler) {
884
+ this.hostFrameListeners.add(handler);
885
+ return () => this.hostFrameListeners.delete(handler);
886
+ }
887
+ cancelHostRoute(scopeId) {
888
+ if (this.isConnected && this.transport)
889
+ this.sendSessionMessageStrict({ type: "hosts.route.close.request", scopeId });
890
+ }
891
+ closeHostRoute(routeId) {
892
+ if (this.isConnected && this.transport)
893
+ this.sendSessionMessageStrict({ type: "hosts.route.close.request", routeId });
894
+ }
745
895
  getConnectionState() {
746
896
  return this.connectionState;
747
897
  }
@@ -758,6 +908,10 @@ export class DaemonClient {
758
908
  get isConnecting() {
759
909
  return this.connectionState.status === "connecting";
760
910
  }
911
+ /** The relay endpoint security this client requires on its next attempt. */
912
+ get relaySecurityFloor() {
913
+ return this.relaySecurityFloorValue;
914
+ }
761
915
  get lastError() {
762
916
  return this.lastErrorValue;
763
917
  }
@@ -833,7 +987,7 @@ export class DaemonClient {
833
987
  if (!trace?.isEnabled()) {
834
988
  return false;
835
989
  }
836
- trace.beginSection(name, args);
990
+ trace.beginSection(name, { ...args, connectionAttempt: String(this.traceConnectionAttempt) });
837
991
  return true;
838
992
  }
839
993
  endTraceSection(isOpen) {
@@ -845,24 +999,50 @@ export class DaemonClient {
845
999
  const isOpen = this.beginTraceSection(name, args);
846
1000
  this.endTraceSection(isOpen);
847
1001
  }
848
- sendJsonMessage(envelopeType, messageType, message) {
849
- this.traceInstant("paseo.ws.message.outbound", {
850
- envelopeType,
851
- messageType,
1002
+ beginSessionDispatchTrace(message) {
1003
+ if (!this.config.trace?.isEnabled())
1004
+ return false;
1005
+ const fields = { messageType: message.type, ...describeRequestIdentity(message) };
1006
+ this.traceInstant("paseo.ws.message.inbound", { envelopeType: "session", ...fields });
1007
+ return this.beginTraceSection("paseo.ws.dispatch", fields);
1008
+ }
1009
+ sendJsonMessage(envelopeType, messageType, message, submitted) {
1010
+ const isOpen = this.config.trace?.isEnabled() === true &&
1011
+ this.beginTraceSection("paseo.ws.message.outbound", {
1012
+ envelopeType,
1013
+ messageType,
1014
+ ...describeRequestIdentity(message),
1015
+ });
1016
+ try {
1017
+ this.sendTransportFrame(JSON.stringify(message), submitted);
1018
+ }
1019
+ finally {
1020
+ this.endTraceSection(isOpen);
1021
+ }
1022
+ }
1023
+ sendSessionPayload(payload) {
1024
+ this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => {
1025
+ if (!("requestId" in payload) || typeof payload.requestId !== "string")
1026
+ return;
1027
+ for (const waiter of this.waiters) {
1028
+ if (waiter.requestId === payload.requestId)
1029
+ waiter.submitted = true;
1030
+ }
852
1031
  });
853
- this.sendTransportFrame(JSON.stringify(message));
854
1032
  }
855
- sendTransportFrame(frame) {
1033
+ sendTransportFrame(frame, submitted) {
856
1034
  if (!this.transport) {
857
- throw new DaemonConnectionError("Transport not connected");
1035
+ throw this.unavailableError();
858
1036
  }
859
1037
  // The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
860
1038
  assertTransportFrameFits(frame);
861
1039
  const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
862
1040
  kind: typeof frame === "string" ? "text" : "binary",
863
1041
  size: String(getTransportFrameSize(frame)),
1042
+ sizeUnit: typeof frame === "string" ? "utf16-code-units" : "bytes",
864
1043
  });
865
1044
  try {
1045
+ submitted?.();
866
1046
  this.transport.send(frame);
867
1047
  }
868
1048
  finally {
@@ -883,7 +1063,7 @@ export class DaemonClient {
883
1063
  }
884
1064
  const payload = SessionInboundMessageSchema.parse(message);
885
1065
  try {
886
- this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
1066
+ this.sendSessionPayload(payload);
887
1067
  }
888
1068
  catch (error) {
889
1069
  if (this.config.suppressSendErrors) {
@@ -922,7 +1102,7 @@ export class DaemonClient {
922
1102
  sendSessionMessageOrThrow(message) {
923
1103
  return this.sendWhenConnected(() => {
924
1104
  const payload = SessionInboundMessageSchema.parse(message);
925
- this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
1105
+ this.sendSessionPayload(payload);
926
1106
  });
927
1107
  }
928
1108
  /** Resolves once connected, waiting out a connection that is still being established. */
@@ -951,7 +1131,7 @@ export class DaemonClient {
951
1131
  });
952
1132
  }
953
1133
  // Not connected and not connecting - fail immediately
954
- return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`));
1134
+ return Promise.reject(this.unavailableError());
955
1135
  }
956
1136
  /**
957
1137
  * Flush pending send queue - called when connection is established.
@@ -995,6 +1175,7 @@ export class DaemonClient {
995
1175
  kind: "error",
996
1176
  error: new DaemonRpcError({
997
1177
  requestId: msg.payload.requestId,
1178
+ serverId: this.destinationServerId,
998
1179
  error: msg.payload.error,
999
1180
  requestType: msg.payload.requestType,
1000
1181
  code: msg.payload.code,
@@ -1070,13 +1251,19 @@ export class DaemonClient {
1070
1251
  responseType,
1071
1252
  });
1072
1253
  }
1254
+ unavailableError() {
1255
+ return new DaemonConnectionError("Transport not connected", "DAEMON_CONNECTION_LOST", {
1256
+ serverId: this.destinationServerId,
1257
+ cause: this.connectionFailure,
1258
+ });
1259
+ }
1073
1260
  sendSessionMessageStrict(message) {
1074
1261
  if (!this.transport || this.connectionState.status !== "connected") {
1075
- throw new DaemonConnectionError("Transport not connected");
1262
+ throw this.unavailableError();
1076
1263
  }
1077
1264
  const payload = SessionInboundMessageSchema.parse(message);
1078
1265
  try {
1079
- this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
1266
+ this.sendSessionPayload(payload);
1080
1267
  }
1081
1268
  catch (error) {
1082
1269
  throw error instanceof Error ? error : new Error(String(error));
@@ -1290,6 +1477,8 @@ export class DaemonClient {
1290
1477
  let resetSource = false;
1291
1478
  return this.owned.observeRequest(message, async (query, accept) => {
1292
1479
  resetSource = false;
1480
+ if (query.type === "workspace.setup.subscribe.request")
1481
+ this.requireWorkspaceSetupObservation();
1293
1482
  const requestId = this.createRequestId(options?.requestId);
1294
1483
  const prepared = options?.prepare ? options.prepare({ ...query }) : query;
1295
1484
  const request = SessionInboundMessageSchema.parse({ ...prepared, requestId });
@@ -1636,6 +1825,16 @@ export class DaemonClient {
1636
1825
  responseType: "archive_workspace_response",
1637
1826
  });
1638
1827
  }
1828
+ observeWorkspaceSetup(workspaceId, options) {
1829
+ return this.observe("workspace.setup.subscribe.response", { type: "workspace.setup.subscribe.request", workspaceId }, options);
1830
+ }
1831
+ requireWorkspaceSetupObservation() {
1832
+ if (this.lastServerInfoMessage?.features?.workspaceSetupObservation !== true ||
1833
+ this.lastServerInfoMessage.features.ownedSubscriptions !== true ||
1834
+ this.config.capabilities?.[CLIENT_CAPS.workspaceSetupObservation] === false ||
1835
+ this.config.capabilities?.[CLIENT_CAPS.ownedSubscriptions] === false)
1836
+ throw new Error("Update the host to view workspace setup details");
1837
+ }
1639
1838
  async fetchWorkspaceSetupStatus(workspaceId, requestId) {
1640
1839
  return this.sendCorrelatedSessionRequest({
1641
1840
  requestId,
@@ -1693,6 +1892,9 @@ export class DaemonClient {
1693
1892
  return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true;
1694
1893
  }
1695
1894
  async createAgent(options) {
1895
+ if (options.git !== undefined || options.worktreeName !== undefined) {
1896
+ throw new Error("Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.");
1897
+ }
1696
1898
  if (options.background !== undefined)
1697
1899
  this.requireBackgroundWorkspaces();
1698
1900
  const result = await this.creations.createAgent({
@@ -2280,23 +2482,29 @@ export class DaemonClient {
2280
2482
  // ============================================================================
2281
2483
  // Agent Interaction
2282
2484
  // ============================================================================
2283
- async sendAgentMessage(agentId, text, options) {
2284
- if (options?.sourceAgentId &&
2485
+ async sendAgentMessage(agentId, text, options = {}) {
2486
+ if (options.sourceAgentId &&
2285
2487
  this.lastServerInfoMessage?.features?.agentMessageProvenance !== true) {
2286
2488
  throw new Error("Update the Paseo host to send messages with agent provenance.");
2287
2489
  }
2490
+ if (options.sourceAgentServerId &&
2491
+ this.lastServerInfoMessage?.features?.agentMessageBatching !== true) {
2492
+ throw new Error("Update the Paseo host to send messages with a qualified agent address.");
2493
+ }
2288
2494
  const requestId = this.createRequestId();
2289
- const messageId = options?.messageId ?? crypto.randomUUID();
2495
+ const messageId = options.messageId ?? crypto.randomUUID();
2290
2496
  const message = SessionInboundMessageSchema.parse({
2291
2497
  type: "send_agent_message_request",
2292
2498
  requestId,
2293
2499
  agentId,
2294
2500
  text,
2295
- sourceAgentId: options?.sourceAgentId,
2501
+ sourceAgentId: options.sourceAgentId,
2502
+ sourceAgentServerId: options.sourceAgentServerId,
2503
+ sessionMode: options.sessionMode,
2296
2504
  ...(messageId ? { messageId } : {}),
2297
- ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}),
2298
- ...(options?.images ? { images: options.images } : {}),
2299
- ...(options?.attachments ? { attachments: options.attachments } : {}),
2505
+ activeTurnBehavior: options.activeTurnBehavior,
2506
+ images: options.images,
2507
+ attachments: options.attachments,
2300
2508
  });
2301
2509
  const payload = await this.sendRequest({
2302
2510
  requestId,
@@ -2315,9 +2523,10 @@ export class DaemonClient {
2315
2523
  if (!payload.accepted) {
2316
2524
  throw new Error(payload.error ?? "sendAgentMessage rejected");
2317
2525
  }
2526
+ return payload.deliveryStatus ? { deliveryStatus: payload.deliveryStatus } : {};
2318
2527
  }
2319
2528
  async sendMessage(agentId, text, options) {
2320
- await this.sendAgentMessage(agentId, text, options);
2529
+ return this.sendAgentMessage(agentId, text, options);
2321
2530
  }
2322
2531
  // ==========================================================================
2323
2532
  // Durable agent message queue — see docs/queue-mirroring.md.
@@ -2958,12 +3167,12 @@ export class DaemonClient {
2958
3167
  const trimmedBaseRef = compare.baseRef?.trim();
2959
3168
  if (!trimmedBaseRef) {
2960
3169
  return compare.ignoreWhitespace === true
2961
- ? { mode: "base", ignoreWhitespace: true }
2962
- : { mode: "base" };
3170
+ ? { mode: compare.mode, ignoreWhitespace: true }
3171
+ : { mode: compare.mode };
2963
3172
  }
2964
3173
  return compare.ignoreWhitespace === true
2965
- ? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace: true }
2966
- : { mode: "base", baseRef: trimmedBaseRef };
3174
+ ? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace: true }
3175
+ : { mode: compare.mode, baseRef: trimmedBaseRef };
2967
3176
  }
2968
3177
  async getCheckoutDiff(cwd, compare, requestId) {
2969
3178
  return this.sendCorrelatedSessionRequest({
@@ -4126,6 +4335,10 @@ export class DaemonClient {
4126
4335
  requestId,
4127
4336
  message: { type: `plugin.${action}.request`, requestId, pluginId },
4128
4337
  responseType: `plugin.${action}.response`,
4338
+ // Reload tears the plugin down and starts it again before the daemon answers, and it queues
4339
+ // behind other plugin lifecycle work. Wait for that answer instead of failing the reload at
4340
+ // the request deadline while the daemon keeps loading it.
4341
+ ...(action === "reload" ? { timeout: 0 } : {}),
4129
4342
  });
4130
4343
  return payload.plugin;
4131
4344
  }
@@ -4625,6 +4838,9 @@ export class DaemonClient {
4625
4838
  const auth = this.helloAuth;
4626
4839
  this.sendJsonMessage("hello", "hello", {
4627
4840
  type: "hello",
4841
+ ...(this.config.sessionPermissions
4842
+ ? { sessionPermissions: this.config.sessionPermissions }
4843
+ : {}),
4628
4844
  clientId: this.config.clientId,
4629
4845
  clientType: this.config.clientType ?? "cli",
4630
4846
  protocolVersion: 1,
@@ -4704,9 +4920,10 @@ export class DaemonClient {
4704
4920
  });
4705
4921
  return;
4706
4922
  }
4707
- const rawBytes = asUint8Array(rawData);
4708
- const isOpen = this.beginTraceSection("paseo.ws.frame.inbound", describeInboundTransportFrame(rawData, rawBytes));
4923
+ const isOpen = this.beginTraceSection("paseo.ws.frame.inbound", describeInboundTransportFrame(rawData));
4709
4924
  try {
4925
+ // Record arrival before conversion: encoding a large text frame is JS work too.
4926
+ const rawBytes = asUint8Array(rawData);
4710
4927
  if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
4711
4928
  return;
4712
4929
  }
@@ -4726,18 +4943,28 @@ export class DaemonClient {
4726
4943
  let parsedJson;
4727
4944
  const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", {
4728
4945
  size: String(bytes),
4946
+ sizeUnit: rawBytesLength === undefined ? "utf16-code-units" : "bytes",
4729
4947
  });
4730
4948
  try {
4731
4949
  parsedJson = JSON.parse(payload);
4732
4950
  }
4733
4951
  catch {
4952
+ this.traceInstant("paseo.ws.json.invalid");
4734
4953
  return;
4735
4954
  }
4736
4955
  finally {
4737
4956
  this.endTraceSection(parseTraceOpen);
4738
4957
  }
4739
- const parsed = validateWSOutboundMessage(parsedJson);
4958
+ const validationTrace = this.beginTraceSection("paseo.ws.validate");
4959
+ let parsed;
4960
+ try {
4961
+ parsed = validateWSOutboundMessage(parsedJson);
4962
+ }
4963
+ finally {
4964
+ this.endTraceSection(validationTrace);
4965
+ }
4740
4966
  if (!parsed.success) {
4967
+ this.traceInstant("paseo.ws.validation.failed");
4741
4968
  const responseIdentity = extractCorrelatedResponseIdentity(parsedJson);
4742
4969
  const envelopeType = parsedJson != null &&
4743
4970
  typeof parsedJson === "object" &&
@@ -4763,6 +4990,7 @@ export class DaemonClient {
4763
4990
  return;
4764
4991
  }
4765
4992
  if (parsed.data.type === "hello.rejected") {
4993
+ this.traceInstant("paseo.connection.rejected", { reason: parsed.data.reason });
4766
4994
  const reasonMessage = {
4767
4995
  password_required: "Password required",
4768
4996
  incorrect_password: "Incorrect password",
@@ -4773,11 +5001,13 @@ export class DaemonClient {
4773
5001
  parsed.data.reason === "incompatible_protocol" ? null : parsed.data.reason;
4774
5002
  return;
4775
5003
  }
4776
- this.traceInstant("paseo.ws.message.inbound", {
4777
- envelopeType: "session",
4778
- messageType: parsed.data.message.type,
4779
- });
4780
- this.handleSessionMessage(parsed.data.message);
5004
+ const dispatchTrace = this.beginSessionDispatchTrace(parsed.data.message);
5005
+ try {
5006
+ this.handleSessionMessage(parsed.data.message);
5007
+ }
5008
+ finally {
5009
+ this.endTraceSection(dispatchTrace);
5010
+ }
4781
5011
  const msgType = parsed.data.message.type;
4782
5012
  this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
4783
5013
  if (parsed.data.message.type === "agent_stream") {
@@ -4785,12 +5015,24 @@ export class DaemonClient {
4785
5015
  }
4786
5016
  }
4787
5017
  tryHandleBinaryFrame(rawBytes) {
5018
+ const hostFrame = decodeHostRouteFrame(rawBytes);
5019
+ if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
5020
+ this.disposeTransport(1008, "Invalid host routing frame");
5021
+ this.scheduleReconnect({ reason: "Invalid host routing frame", event: "INVALID_HOST_FRAME" });
5022
+ return true;
5023
+ }
5024
+ if (hostFrame) {
5025
+ for (const handler of this.hostFrameListeners)
5026
+ handler(hostFrame);
5027
+ return true;
5028
+ }
4788
5029
  const fileFrame = decodeFileTransferFrame(rawBytes);
4789
5030
  if (fileFrame) {
4790
5031
  this.traceInstant("paseo.ws.message.inbound", {
4791
5032
  envelopeType: "binary",
4792
5033
  messageType: "file",
4793
5034
  opcode: String(fileFrame.opcode),
5035
+ requestId: fileFrame.requestId,
4794
5036
  });
4795
5037
  this.consecutiveLivenessFailures = 0;
4796
5038
  this.handleFileTransferFrame(fileFrame);
@@ -4805,6 +5047,7 @@ export class DaemonClient {
4805
5047
  envelopeType: "binary",
4806
5048
  messageType: "terminal",
4807
5049
  opcode: String(frame.opcode),
5050
+ slot: String(frame.slot),
4808
5051
  });
4809
5052
  this.consecutiveLivenessFailures = 0;
4810
5053
  const binaryStartMs = perfNow();
@@ -4905,6 +5148,12 @@ export class DaemonClient {
4905
5148
  const reasonFromNext = next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null;
4906
5149
  const reason = metadata?.reason ?? reasonFromNext;
4907
5150
  const reasonCode = metadata?.reasonCode ?? toReasonCode(reason);
5151
+ this.traceInstant("paseo.connection.state", {
5152
+ from: previous.status,
5153
+ to: next.status,
5154
+ event: metadata?.event ?? "STATE_UPDATE",
5155
+ reasonCode: reasonCode ?? "none",
5156
+ });
4908
5157
  this.logger.debug({
4909
5158
  serverId: this.logServerId,
4910
5159
  clientIdHash: this.logClientIdHash,
@@ -4946,9 +5195,14 @@ export class DaemonClient {
4946
5195
  this.providerSnapshotUpdates.clear();
4947
5196
  // Clear all pending waiters and queued sends since the connection was lost
4948
5197
  // and responses from the previous connection will never arrive.
4949
- this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
4950
- this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
4951
- this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost"));
5198
+ const connectionError = new DaemonConnectionError(reason ?? "Connection lost", "DAEMON_CONNECTION_LOST", {
5199
+ serverId: this.destinationServerId,
5200
+ cause: input?.failure,
5201
+ });
5202
+ this.connectionFailure = input?.failure;
5203
+ this.clearWaiters(connectionError, true);
5204
+ this.rejectPendingSendQueue(connectionError);
5205
+ this.rejectPingProbe(connectionError);
4952
5206
  this.terminalStreams.clearSlots();
4953
5207
  this.lastServerInfoMessage = null;
4954
5208
  if (this.authFailureReasonValue)
@@ -4961,7 +5215,7 @@ export class DaemonClient {
4961
5215
  if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
4962
5216
  this.rejectConnect(this.authFailureReasonValue
4963
5217
  ? new DaemonAuthenticationError(this.authFailureReasonValue)
4964
- : new Error(reason ?? "Transport disconnected before connect"));
5218
+ : (input?.failure ?? new Error(reason ?? "Transport disconnected before connect")));
4965
5219
  return;
4966
5220
  }
4967
5221
  this.armReconnectTimer();
@@ -4969,6 +5223,16 @@ export class DaemonClient {
4969
5223
  emitDisconnectedStateForReconnect(reason, input) {
4970
5224
  this.updateConnectionState({
4971
5225
  status: "disconnected",
5226
+ ...(input?.failure && "code" in input.failure && typeof input.failure.code === "string"
5227
+ ? { failureCode: input.failure.code }
5228
+ : {}),
5229
+ ...(this.shouldReconnect && this.config.reconnect?.enabled !== false
5230
+ ? {
5231
+ nextRetryAt: new Date(Date.now() +
5232
+ Math.min((this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
5233
+ 2 ** this.reconnectAttempt, this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS)).toISOString(),
5234
+ }
5235
+ : {}),
4972
5236
  ...(reason ? { reason } : {}),
4973
5237
  }, {
4974
5238
  event: input?.event ?? "TRANSPORT_CLOSE",
@@ -4981,6 +5245,10 @@ export class DaemonClient {
4981
5245
  const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
4982
5246
  const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
4983
5247
  const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
5248
+ this.traceInstant("paseo.connection.reconnect", {
5249
+ attempt: String(attempt),
5250
+ delayMs: String(delay),
5251
+ });
4984
5252
  this.reconnectAttempt = attempt + 1;
4985
5253
  this.reconnectTimeout = setTimeout(() => {
4986
5254
  this.reconnectTimeout = null;
@@ -5048,6 +5316,8 @@ export class DaemonClient {
5048
5316
  const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
5049
5317
  if (serverInfo) {
5050
5318
  this.lastServerInfoMessage = serverInfo;
5319
+ this.destinationServerId = serverInfo.serverId;
5320
+ this.connectionFailure = undefined;
5051
5321
  if (this.connectionState.status === "connecting") {
5052
5322
  this.resetConnectTimeout();
5053
5323
  this.reconnectAttempt = 0;
@@ -5129,12 +5399,18 @@ export class DaemonClient {
5129
5399
  waiter.reject(error);
5130
5400
  }
5131
5401
  }
5132
- clearWaiters(error) {
5402
+ clearWaiters(error, connectionLost = false) {
5133
5403
  for (const waiter of Array.from(this.waiters)) {
5134
5404
  if (waiter.timeoutHandle) {
5135
5405
  clearTimeout(waiter.timeoutHandle);
5136
5406
  }
5137
- waiter.reject(error);
5407
+ waiter.reject(connectionLost && waiter.submitted && error instanceof DaemonConnectionError
5408
+ ? new DaemonConnectionError(error.message, error.code, {
5409
+ serverId: error.serverId,
5410
+ outcome: "unknown",
5411
+ cause: error.cause instanceof Error ? error.cause : undefined,
5412
+ })
5413
+ : error);
5138
5414
  }
5139
5415
  this.waiters.clear();
5140
5416
  }
@@ -5229,6 +5505,7 @@ export class DaemonClient {
5229
5505
  reject: wrappedReject,
5230
5506
  timeoutHandle,
5231
5507
  requestId: options?.requestId,
5508
+ submitted: false,
5232
5509
  };
5233
5510
  this.waiters.add(waiter);
5234
5511
  });