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

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({
@@ -4629,6 +4838,9 @@ export class DaemonClient {
4629
4838
  const auth = this.helloAuth;
4630
4839
  this.sendJsonMessage("hello", "hello", {
4631
4840
  type: "hello",
4841
+ ...(this.config.sessionPermissions
4842
+ ? { sessionPermissions: this.config.sessionPermissions }
4843
+ : {}),
4632
4844
  clientId: this.config.clientId,
4633
4845
  clientType: this.config.clientType ?? "cli",
4634
4846
  protocolVersion: 1,
@@ -4708,9 +4920,10 @@ export class DaemonClient {
4708
4920
  });
4709
4921
  return;
4710
4922
  }
4711
- const rawBytes = asUint8Array(rawData);
4712
- const isOpen = this.beginTraceSection("paseo.ws.frame.inbound", describeInboundTransportFrame(rawData, rawBytes));
4923
+ const isOpen = this.beginTraceSection("paseo.ws.frame.inbound", describeInboundTransportFrame(rawData));
4713
4924
  try {
4925
+ // Record arrival before conversion: encoding a large text frame is JS work too.
4926
+ const rawBytes = asUint8Array(rawData);
4714
4927
  if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
4715
4928
  return;
4716
4929
  }
@@ -4730,18 +4943,28 @@ export class DaemonClient {
4730
4943
  let parsedJson;
4731
4944
  const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", {
4732
4945
  size: String(bytes),
4946
+ sizeUnit: rawBytesLength === undefined ? "utf16-code-units" : "bytes",
4733
4947
  });
4734
4948
  try {
4735
4949
  parsedJson = JSON.parse(payload);
4736
4950
  }
4737
4951
  catch {
4952
+ this.traceInstant("paseo.ws.json.invalid");
4738
4953
  return;
4739
4954
  }
4740
4955
  finally {
4741
4956
  this.endTraceSection(parseTraceOpen);
4742
4957
  }
4743
- 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
+ }
4744
4966
  if (!parsed.success) {
4967
+ this.traceInstant("paseo.ws.validation.failed");
4745
4968
  const responseIdentity = extractCorrelatedResponseIdentity(parsedJson);
4746
4969
  const envelopeType = parsedJson != null &&
4747
4970
  typeof parsedJson === "object" &&
@@ -4767,6 +4990,7 @@ export class DaemonClient {
4767
4990
  return;
4768
4991
  }
4769
4992
  if (parsed.data.type === "hello.rejected") {
4993
+ this.traceInstant("paseo.connection.rejected", { reason: parsed.data.reason });
4770
4994
  const reasonMessage = {
4771
4995
  password_required: "Password required",
4772
4996
  incorrect_password: "Incorrect password",
@@ -4777,11 +5001,13 @@ export class DaemonClient {
4777
5001
  parsed.data.reason === "incompatible_protocol" ? null : parsed.data.reason;
4778
5002
  return;
4779
5003
  }
4780
- this.traceInstant("paseo.ws.message.inbound", {
4781
- envelopeType: "session",
4782
- messageType: parsed.data.message.type,
4783
- });
4784
- 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
+ }
4785
5011
  const msgType = parsed.data.message.type;
4786
5012
  this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
4787
5013
  if (parsed.data.message.type === "agent_stream") {
@@ -4789,12 +5015,24 @@ export class DaemonClient {
4789
5015
  }
4790
5016
  }
4791
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
+ }
4792
5029
  const fileFrame = decodeFileTransferFrame(rawBytes);
4793
5030
  if (fileFrame) {
4794
5031
  this.traceInstant("paseo.ws.message.inbound", {
4795
5032
  envelopeType: "binary",
4796
5033
  messageType: "file",
4797
5034
  opcode: String(fileFrame.opcode),
5035
+ requestId: fileFrame.requestId,
4798
5036
  });
4799
5037
  this.consecutiveLivenessFailures = 0;
4800
5038
  this.handleFileTransferFrame(fileFrame);
@@ -4809,6 +5047,7 @@ export class DaemonClient {
4809
5047
  envelopeType: "binary",
4810
5048
  messageType: "terminal",
4811
5049
  opcode: String(frame.opcode),
5050
+ slot: String(frame.slot),
4812
5051
  });
4813
5052
  this.consecutiveLivenessFailures = 0;
4814
5053
  const binaryStartMs = perfNow();
@@ -4909,6 +5148,12 @@ export class DaemonClient {
4909
5148
  const reasonFromNext = next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null;
4910
5149
  const reason = metadata?.reason ?? reasonFromNext;
4911
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
+ });
4912
5157
  this.logger.debug({
4913
5158
  serverId: this.logServerId,
4914
5159
  clientIdHash: this.logClientIdHash,
@@ -4950,9 +5195,14 @@ export class DaemonClient {
4950
5195
  this.providerSnapshotUpdates.clear();
4951
5196
  // Clear all pending waiters and queued sends since the connection was lost
4952
5197
  // and responses from the previous connection will never arrive.
4953
- this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
4954
- this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
4955
- 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);
4956
5206
  this.terminalStreams.clearSlots();
4957
5207
  this.lastServerInfoMessage = null;
4958
5208
  if (this.authFailureReasonValue)
@@ -4965,7 +5215,7 @@ export class DaemonClient {
4965
5215
  if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
4966
5216
  this.rejectConnect(this.authFailureReasonValue
4967
5217
  ? new DaemonAuthenticationError(this.authFailureReasonValue)
4968
- : new Error(reason ?? "Transport disconnected before connect"));
5218
+ : (input?.failure ?? new Error(reason ?? "Transport disconnected before connect")));
4969
5219
  return;
4970
5220
  }
4971
5221
  this.armReconnectTimer();
@@ -4973,6 +5223,16 @@ export class DaemonClient {
4973
5223
  emitDisconnectedStateForReconnect(reason, input) {
4974
5224
  this.updateConnectionState({
4975
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
+ : {}),
4976
5236
  ...(reason ? { reason } : {}),
4977
5237
  }, {
4978
5238
  event: input?.event ?? "TRANSPORT_CLOSE",
@@ -4985,6 +5245,10 @@ export class DaemonClient {
4985
5245
  const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
4986
5246
  const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
4987
5247
  const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
5248
+ this.traceInstant("paseo.connection.reconnect", {
5249
+ attempt: String(attempt),
5250
+ delayMs: String(delay),
5251
+ });
4988
5252
  this.reconnectAttempt = attempt + 1;
4989
5253
  this.reconnectTimeout = setTimeout(() => {
4990
5254
  this.reconnectTimeout = null;
@@ -5052,6 +5316,8 @@ export class DaemonClient {
5052
5316
  const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
5053
5317
  if (serverInfo) {
5054
5318
  this.lastServerInfoMessage = serverInfo;
5319
+ this.destinationServerId = serverInfo.serverId;
5320
+ this.connectionFailure = undefined;
5055
5321
  if (this.connectionState.status === "connecting") {
5056
5322
  this.resetConnectTimeout();
5057
5323
  this.reconnectAttempt = 0;
@@ -5133,12 +5399,18 @@ export class DaemonClient {
5133
5399
  waiter.reject(error);
5134
5400
  }
5135
5401
  }
5136
- clearWaiters(error) {
5402
+ clearWaiters(error, connectionLost = false) {
5137
5403
  for (const waiter of Array.from(this.waiters)) {
5138
5404
  if (waiter.timeoutHandle) {
5139
5405
  clearTimeout(waiter.timeoutHandle);
5140
5406
  }
5141
- 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);
5142
5414
  }
5143
5415
  this.waiters.clear();
5144
5416
  }
@@ -5233,6 +5505,7 @@ export class DaemonClient {
5233
5505
  reject: wrappedReject,
5234
5506
  timeoutHandle,
5235
5507
  requestId: options?.requestId,
5508
+ submitted: false,
5236
5509
  };
5237
5510
  this.waiters.add(waiter);
5238
5511
  });