@camerontaylor/paseo-client 0.8.0-fork.1 → 0.8.0-fork.3

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,12 +1,11 @@
1
+ import { subscribeTimeline } from "./timeline-subscription/index.js";
1
2
  import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
2
3
  import { ConnectionSubscriptions, DEFAULT_CLIENT_CAPABILITIES, } from "./connection/index.js";
3
- import { CreationClient } from "./creation/index.js";
4
4
  import { CLIENT_CAPS } from "@camerontaylor/paseo-protocol/client-capabilities";
5
5
  import { parsePluginSourceReference } from "@camerontaylor/paseo-protocol/plugin-source-reference";
6
6
  import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, AgentResumedStatusPayloadSchema, parseServerInfoStatusPayload, RestartRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, DaemonUpdateResponseSchema, SessionInboundMessageSchema, } from "@camerontaylor/paseo-protocol/messages";
7
7
  import { validateWSOutboundMessage } from "@camerontaylor/paseo-protocol/validation/ws-outbound";
8
8
  import { isRelayClientWebSocketUrl } from "@camerontaylor/paseo-protocol/daemon-endpoints";
9
- import { terminalSubscriptionKey } from "@camerontaylor/paseo-protocol/terminal-subscription-key";
10
9
  import { asUint8Array, decodeFileTransferFrame, encodeFileTransferFrame, decodeTerminalStreamFrame, FileTransferOpcode, TerminalStreamOpcode, } from "@camerontaylor/paseo-protocol/binary-frames/index";
11
10
  import { createRelayE2eeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, describeTransportClose, describeTransportError, } from "./daemon-client-transport.js";
12
11
  import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
@@ -76,6 +75,13 @@ function normalizeListCommandsOptions(input, legacyOptions) {
76
75
  }
77
76
  return { agentId: input, ...legacyOptions };
78
77
  }
78
+ export class DaemonConnectionError extends Error {
79
+ constructor(message, code = "DAEMON_CONNECTION_LOST") {
80
+ super(message);
81
+ this.code = code;
82
+ this.name = "DaemonConnectionError";
83
+ }
84
+ }
79
85
  class DaemonRpcError extends Error {
80
86
  constructor(params) {
81
87
  const parts = [params.error];
@@ -232,14 +238,29 @@ export class DaemonClient {
232
238
  constructor(config) {
233
239
  this.config = config;
234
240
  this.providerSnapshotUpdates = new ProviderSnapshotUpdates({
241
+ active: (message) => this.owned.owns(message),
235
242
  fetch: (cwd) => this.requestProvidersSnapshot({ cwd }),
236
243
  emit: (message) => this.deliverSessionMessage(message),
237
244
  failed: (error) => this.logger.error({ err: error }, "Failed to resolve provider snapshot"),
238
245
  });
239
- this.subscriptions = new ConnectionSubscriptions({
240
- timelines: (agentIds) => (this.isConnected ? this.sendTimelineSubscription(agentIds) : null),
241
- events: (events) => this.sendEventSubscription(events),
242
- failed: (error) => this.logger.error({ err: error }, "Failed to update connection subscriptions"),
246
+ this.owned = new ConnectionSubscriptions({
247
+ send: (message) => this.sendSessionMessageOrThrow(SessionInboundMessageSchema.parse({ ...message, requestId: this.createRequestId() })),
248
+ release: async (subscriptionId) => {
249
+ if (!this.isConnected)
250
+ return;
251
+ try {
252
+ await this.sendCorrelatedSessionRequest({
253
+ message: { type: "subscription.release.request", subscriptionId },
254
+ responseType: "subscription.release.response",
255
+ });
256
+ }
257
+ catch (error) {
258
+ this.disposeTransport(1001, "Subscription release failed");
259
+ this.scheduleReconnect({ reason: "Subscription release failed" });
260
+ throw error;
261
+ }
262
+ },
263
+ failed: (error) => this.logger.error({ err: error }, "Subscription failed"),
243
264
  });
244
265
  this.transport = null;
245
266
  this.transportCleanup = [];
@@ -259,9 +280,6 @@ export class DaemonClient {
259
280
  this.connectReject = null;
260
281
  this.lastErrorValue = null;
261
282
  this.connectionState = { status: "idle" };
262
- this.checkoutDiffSubscriptions = new Map();
263
- this.terminalDirectorySubscriptions = new Map();
264
- this.fileSubscriptions = new Map();
265
283
  this.terminalStreams = new TerminalStreamRouter();
266
284
  this.pendingBinaryFileReads = new Map();
267
285
  this.activeBinaryFileTransfers = new Map();
@@ -274,39 +292,6 @@ export class DaemonClient {
274
292
  this.livenessHeartbeatTimer = null;
275
293
  this.lastLivenessRttMs = null;
276
294
  this.consecutiveLivenessFailures = 0;
277
- // ============================================================================
278
- // Agent Lifecycle
279
- // ============================================================================
280
- this.creations = new CreationClient({
281
- supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true,
282
- connected: () => this.connectionState.status === "connected",
283
- requestId: () => this.createRequestId(),
284
- request: (kind, input) => kind === "workspace"
285
- ? this.sendCorrelatedSessionRequest({
286
- requestId: input.requestId,
287
- message: { ...input, type: "workspace.create.request" },
288
- responseType: "workspace.create.response",
289
- timeout: 0,
290
- })
291
- : this.sendCorrelatedSessionRequest({
292
- requestId: input.requestId,
293
- message: { ...input, type: "agent.create.request" },
294
- responseType: "agent.create.response",
295
- timeout: 0,
296
- }),
297
- subscribe: async (kind, idempotencyKey, subscribe = true) => {
298
- const result = await this.sendCorrelatedSessionRequest({
299
- message: { type: "creation.subscribe.request", kind, idempotencyKey, subscribe },
300
- responseType: "creation.subscribe.response",
301
- });
302
- if (result.error)
303
- throw new Error(result.error);
304
- return result.snapshot;
305
- },
306
- legacyAgent: (input) => this.createLegacyAgent(input),
307
- legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
308
- sendMessage: (id, text, options) => this.sendMessage(id, text, options),
309
- });
310
295
  this.logger = config.logger ?? consoleLogger;
311
296
  this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct";
312
297
  let parsedUrlForLog = null;
@@ -494,7 +479,10 @@ export class DaemonClient {
494
479
  reasonCode: "transport_error",
495
480
  });
496
481
  }),
497
- transport.onMessage((data) => this.handleTransportMessage(data)),
482
+ transport.onMessage((data) => {
483
+ if (this.transport === transport)
484
+ this.handleTransportMessage(data);
485
+ }),
498
486
  ];
499
487
  }
500
488
  catch (error) {
@@ -530,7 +518,6 @@ export class DaemonClient {
530
518
  return;
531
519
  }
532
520
  this.shouldReconnect = false;
533
- this.subscriptions.close();
534
521
  this.connectPromise = null;
535
522
  this.connectResolve = null;
536
523
  this.connectReject = null;
@@ -542,11 +529,10 @@ export class DaemonClient {
542
529
  this.disposeTransport(1000, "Client closed");
543
530
  this.providerSnapshotUpdates.clear();
544
531
  this.clearWaiters(new Error("Daemon client closed"));
545
- this.creations.close();
532
+ await this.owned.close();
546
533
  this.rejectPendingSendQueue(new Error("Daemon client closed"));
547
534
  this.rejectPingProbe(new Error("Daemon client closed"));
548
535
  this.terminalStreams.clearSlots();
549
- this.fileSubscriptions.clear();
550
536
  this.lastServerInfoMessage = null;
551
537
  if (this.runtimeMetricsInterval) {
552
538
  clearInterval(this.runtimeMetricsInterval);
@@ -604,10 +590,8 @@ export class DaemonClient {
604
590
  // ============================================================================
605
591
  subscribe(handler) {
606
592
  this.eventListeners.add(handler);
607
- this.updateEventSubscriptions();
608
593
  return () => {
609
594
  this.eventListeners.delete(handler);
610
- this.updateEventSubscriptions();
611
595
  };
612
596
  }
613
597
  subscribeRawMessages(handler) {
@@ -626,7 +610,6 @@ export class DaemonClient {
626
610
  this.messageHandlers.set(type, new Set());
627
611
  }
628
612
  this.messageHandlers.get(type).add(handler);
629
- this.updateEventSubscriptions();
630
613
  return () => {
631
614
  const handlers = this.messageHandlers.get(type);
632
615
  if (!handlers) {
@@ -636,7 +619,6 @@ export class DaemonClient {
636
619
  if (handlers.size === 0) {
637
620
  this.messageHandlers.delete(type);
638
621
  }
639
- this.updateEventSubscriptions();
640
622
  };
641
623
  }
642
624
  onAgentAttentionRequired(handler) {
@@ -690,7 +672,7 @@ export class DaemonClient {
690
672
  }
691
673
  sendTransportFrame(frame) {
692
674
  if (!this.transport) {
693
- throw new Error("Transport not connected");
675
+ throw new DaemonConnectionError("Transport not connected");
694
676
  }
695
677
  const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
696
678
  kind: typeof frame === "string" ? "text" : "binary",
@@ -770,13 +752,13 @@ export class DaemonClient {
770
752
  if (idx !== -1) {
771
753
  this.pendingSendQueue.splice(idx, 1);
772
754
  }
773
- reject(new Error(`Timed out waiting for connection to send message`));
755
+ reject(new DaemonConnectionError("Timed out waiting for connection to send message", "DAEMON_REQUEST_TIMEOUT"));
774
756
  }, DEFAULT_SEND_QUEUE_TIMEOUT_MS);
775
757
  this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
776
758
  });
777
759
  }
778
760
  // Not connected and not connecting - fail immediately
779
- return Promise.reject(new Error(`Transport not connected (status: ${status})`));
761
+ return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`));
780
762
  }
781
763
  /**
782
764
  * Flush pending send queue - called when connection is established.
@@ -793,7 +775,7 @@ export class DaemonClient {
793
775
  pending.resolve();
794
776
  }
795
777
  else {
796
- pending.reject(new Error("Connection lost before message could be sent"));
778
+ pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
797
779
  }
798
780
  }
799
781
  catch (error) {
@@ -813,6 +795,7 @@ export class DaemonClient {
813
795
  }
814
796
  }
815
797
  async sendRequest(params) {
798
+ const wire = this.owned.prepareRequest(params.message);
816
799
  const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
817
800
  const { promise, cancel } = this.waitForWithCancel((msg) => {
818
801
  if (msg.type === "rpc_error" && msg.payload.requestId === params.requestId) {
@@ -826,14 +809,14 @@ export class DaemonClient {
826
809
  }),
827
810
  };
828
811
  }
829
- const value = params.select(msg);
812
+ const value = params.select(wire.receive(msg));
830
813
  if (value === null) {
831
814
  return null;
832
815
  }
833
816
  return { kind: "ok", value };
834
817
  }, timeout, { ...params.options, requestId: params.requestId });
835
818
  try {
836
- await this.sendSessionMessageOrThrow(params.message);
819
+ await this.sendSessionMessageOrThrow(wire.message);
837
820
  }
838
821
  catch (error) {
839
822
  const err = error instanceof Error ? error : new Error(String(error));
@@ -841,11 +824,15 @@ export class DaemonClient {
841
824
  void promise.catch(() => undefined);
842
825
  throw err;
843
826
  }
844
- const result = await promise;
845
- if (result.kind === "error") {
846
- throw result.error;
827
+ try {
828
+ const result = await promise;
829
+ if (result.kind === "error")
830
+ throw result.error;
831
+ return result.value;
832
+ }
833
+ finally {
834
+ await wire.finish();
847
835
  }
848
- return result.value;
849
836
  }
850
837
  async sendCorrelatedRequest(params) {
851
838
  return this.sendRequest({
@@ -893,7 +880,7 @@ export class DaemonClient {
893
880
  }
894
881
  sendSessionMessageStrict(message) {
895
882
  if (!this.transport || this.connectionState.status !== "connected") {
896
- throw new Error("Transport not connected");
883
+ throw new DaemonConnectionError("Transport not connected");
897
884
  }
898
885
  const payload = SessionInboundMessageSchema.parse(message);
899
886
  try {
@@ -1107,7 +1094,69 @@ export class DaemonClient {
1107
1094
  // ============================================================================
1108
1095
  // Agent RPCs (requestId-correlated)
1109
1096
  // ============================================================================
1097
+ observe(responseType, message, options) {
1098
+ let resetSource = false;
1099
+ return this.owned.observeRequest(message, async (query, accept) => {
1100
+ resetSource = false;
1101
+ const requestId = this.createRequestId(options?.requestId);
1102
+ const request = SessionInboundMessageSchema.parse({ ...query, requestId });
1103
+ try {
1104
+ return await this.sendCorrelatedRequest({
1105
+ message: request,
1106
+ responseType,
1107
+ requestId,
1108
+ timeout: options?.timeout,
1109
+ options: { skipQueue: true },
1110
+ selectPayload: (payload) => {
1111
+ if ("error" in payload && typeof payload.error === "string")
1112
+ throw new DaemonRpcError({
1113
+ requestId: payload.requestId,
1114
+ requestType: message.type,
1115
+ error: payload.error,
1116
+ });
1117
+ accept(payload);
1118
+ return payload;
1119
+ },
1120
+ });
1121
+ }
1122
+ catch (error) {
1123
+ resetSource = !(error instanceof DaemonRpcError) && this.isConnected;
1124
+ throw error;
1125
+ }
1126
+ }, options?.signal, () => {
1127
+ // Reject and release this handle before closing a source with an unknown bootstrap outcome.
1128
+ // Closing first would classify the failure as a reconnect and replay the failed request.
1129
+ if (resetSource) {
1130
+ this.disposeTransport(1001, "Subscription request failed");
1131
+ this.scheduleReconnect({ reason: "Subscription request failed" });
1132
+ }
1133
+ });
1134
+ }
1135
+ registerBrowserHost(registration, options) {
1136
+ return this.observe("browser.host.register.response", { type: "browser.host.register.request", ...registration }, options);
1137
+ }
1138
+ observeEvents(events, options) {
1139
+ return this.observe("session.events.set_subscription.response", {
1140
+ type: "session.events.set_subscription.request",
1141
+ events,
1142
+ notifications: options?.notifications,
1143
+ }, options);
1144
+ }
1145
+ observeAgents(options = {}) {
1146
+ const { signal, requestId, timeout, ...query } = options;
1147
+ return this.observe("fetch_agents_response", { ...query, type: "fetch_agents_request", subscribe: {} }, { signal, requestId, timeout });
1148
+ }
1149
+ observeWorkspaces(options = {}) {
1150
+ const { signal, requestId, ...query } = options;
1151
+ return this.observe("fetch_workspaces_response", { ...query, type: "fetch_workspaces_request", subscribe: {} }, { signal, requestId });
1152
+ }
1110
1153
  async fetchAgents(options) {
1154
+ if (options?.subscribe) {
1155
+ if (options.subscribe.subscriptionId !== undefined)
1156
+ throw new Error("Subscription IDs are assigned by the host");
1157
+ const subscription = this.observeAgents(options);
1158
+ return { ...(await subscription.ready), subscription };
1159
+ }
1111
1160
  const resolvedRequestId = this.createRequestId(options?.requestId);
1112
1161
  const message = SessionInboundMessageSchema.parse({
1113
1162
  type: "fetch_agents_request",
@@ -1187,6 +1236,12 @@ export class DaemonClient {
1187
1236
  });
1188
1237
  }
1189
1238
  async fetchWorkspaces(options) {
1239
+ if (options?.subscribe) {
1240
+ if (options.subscribe.subscriptionId !== undefined)
1241
+ throw new Error("Subscription IDs are assigned by the host");
1242
+ const subscription = this.observeWorkspaces(options);
1243
+ return { ...(await subscription.ready), subscription };
1244
+ }
1190
1245
  const resolvedRequestId = this.createRequestId(options?.requestId);
1191
1246
  const message = SessionInboundMessageSchema.parse({
1192
1247
  type: "fetch_workspaces_request",
@@ -1212,16 +1267,18 @@ export class DaemonClient {
1212
1267
  },
1213
1268
  });
1214
1269
  }
1215
- listWorkspaceLabels(options) {
1270
+ listWorkspaceLabels(options = {}) {
1216
1271
  return this.sendNamespacedCorrelatedSessionRequest({
1217
1272
  requestId: options.requestId,
1218
1273
  message: {
1219
1274
  type: "workspace.label.list.request",
1220
- subscribe: { subscriptionId: options.subscriptionId },
1221
1275
  ...(options.sync ? { sync: options.sync } : {}),
1222
1276
  },
1223
1277
  });
1224
1278
  }
1279
+ observeWorkspaceLabels(options) {
1280
+ return this.observe("workspace.label.list.response", { type: "workspace.label.list.request", subscribe: {} }, options);
1281
+ }
1225
1282
  setWorkspaceLabel(options) {
1226
1283
  return this.sendNamespacedCorrelatedSessionRequest({
1227
1284
  requestId: options.requestId,
@@ -1422,60 +1479,10 @@ export class DaemonClient {
1422
1479
  }
1423
1480
  return { agent: payload.agent, project: payload.project ?? null };
1424
1481
  }
1425
- resubscribeCheckoutDiffSubscriptions() {
1426
- if (this.checkoutDiffSubscriptions.size === 0) {
1427
- return;
1428
- }
1429
- for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) {
1430
- const message = SessionInboundMessageSchema.parse({
1431
- type: "subscribe_checkout_diff_request",
1432
- subscriptionId,
1433
- cwd: subscription.cwd,
1434
- compare: subscription.compare,
1435
- requestId: this.createRequestId(),
1436
- });
1437
- this.sendSessionMessage(message);
1438
- }
1439
- }
1440
- resubscribeTerminalDirectorySubscriptions() {
1441
- if (this.terminalDirectorySubscriptions.size === 0) {
1442
- return;
1443
- }
1444
- for (const subscription of this.terminalDirectorySubscriptions.values()) {
1445
- this.sendSessionMessage({
1446
- type: "subscribe_terminals_request",
1447
- cwd: subscription.cwd,
1448
- ...(subscription.workspaceId !== undefined
1449
- ? { workspaceId: subscription.workspaceId }
1450
- : {}),
1451
- });
1452
- }
1453
- }
1454
- resubscribeFileSubscriptions() {
1455
- for (const [subscriptionId, subscription] of this.fileSubscriptions) {
1456
- void this.sendCorrelatedSessionRequest({
1457
- message: {
1458
- type: "fs.file.subscribe.request",
1459
- cwd: subscription.cwd,
1460
- path: subscription.path,
1461
- subscriptionId,
1462
- },
1463
- responseType: "fs.file.subscribe.response",
1464
- })
1465
- .then((payload) => subscription.onUpdate(payload.initial))
1466
- .catch(() => undefined);
1467
- }
1468
- }
1482
+ // ============================================================================
1483
+ // Agent Lifecycle
1484
+ // ============================================================================
1469
1485
  async createAgent(options) {
1470
- const result = await this.creations.createAgent({
1471
- ...options,
1472
- config: resolveAgentConfig(options),
1473
- });
1474
- if (result.error || !result.agent)
1475
- throw new Error(result.error ?? "Agent creation failed");
1476
- return result.agent;
1477
- }
1478
- async createLegacyAgent(options) {
1479
1486
  if (options.idempotencyKey !== undefined)
1480
1487
  this.requireAgentRequestReceipts();
1481
1488
  const requestId = this.createRequestId(options.requestId);
@@ -1902,6 +1909,9 @@ export class DaemonClient {
1902
1909
  }
1903
1910
  return payload;
1904
1911
  }
1912
+ observeTimeline(agentIds, options) {
1913
+ return this.observe("agent.timeline.set_subscription.response", { type: "agent.timeline.set_subscription.request", agentIds }, options);
1914
+ }
1905
1915
  async askSideConversation(parentAgentId, threadId, question, options = {}) {
1906
1916
  this.requireSideConversationSupport();
1907
1917
  const requestId = this.createRequestId(options.requestId);
@@ -1971,77 +1981,8 @@ export class DaemonClient {
1971
1981
  throw new Error(payload.error);
1972
1982
  return payload;
1973
1983
  }
1974
- setAgentTimelineSubscription(agentIds) {
1975
- return this.subscriptions.setViewed(agentIds);
1976
- }
1977
1984
  subscribeAgentTimeline(agentId, handler) {
1978
- const stream = this.on("agent_stream", (message) => {
1979
- if (message.payload.agentId === agentId)
1980
- handler(message);
1981
- });
1982
- const replacement = this.on("agent.timeline.replacement", (message) => {
1983
- if (message.payload.agentId === agentId)
1984
- handler(message);
1985
- });
1986
- const release = this.subscriptions.observeTimeline(agentId);
1987
- return Object.assign(() => {
1988
- stream();
1989
- replacement();
1990
- release();
1991
- }, { ready: release.ready });
1992
- }
1993
- updateEventSubscriptions() {
1994
- const events = [
1995
- "project.update",
1996
- "providers_snapshot_update",
1997
- "agent_attention_required",
1998
- "agent_permission_request",
1999
- "agent_permission_resolved",
2000
- ];
2001
- if (this.eventListeners.size === 0 && !this.messageHandlers.has("providers_snapshot_update")) {
2002
- this.providerSnapshotUpdates.clear();
2003
- }
2004
- this.subscriptions.setEvents(events.filter((type) => this.eventListeners.size > 0 || (this.messageHandlers.get(type)?.size ?? 0) > 0));
2005
- }
2006
- async sendEventSubscription(events) {
2007
- if (this.connectionState.status !== "connected")
2008
- return;
2009
- // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcast handling after 2027-03-08.
2010
- if (!this.lastServerInfoMessage?.features?.explicitEventSubscriptions ||
2011
- this.config.capabilities?.[CLIENT_CAPS.explicitEventSubscriptions] === false)
2012
- return;
2013
- await this.sendCorrelatedSessionRequest({
2014
- message: { type: "session.events.set_subscription.request", events },
2015
- responseType: "session.events.set_subscription.response",
2016
- });
2017
- }
2018
- async sendTimelineSubscription(agentIds) {
2019
- if (this.connectionState.status !== "connected")
2020
- return;
2021
- // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their
2022
- // legacy global stream and do not understand this RPC. Remove after
2023
- // 2027-01-12 once the supported daemon floor is >= v0.1.106.
2024
- if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) {
2025
- return;
2026
- }
2027
- const requestId = this.createRequestId();
2028
- const normalizedAgentIds = [...new Set(agentIds)].sort();
2029
- const message = SessionInboundMessageSchema.parse({
2030
- type: "agent.timeline.set_subscription.request",
2031
- agentIds: normalizedAgentIds,
2032
- requestId,
2033
- });
2034
- await this.sendRequest({
2035
- requestId,
2036
- message,
2037
- options: { skipQueue: true },
2038
- select: (response) => {
2039
- if (response.type !== "agent.timeline.set_subscription.response") {
2040
- return null;
2041
- }
2042
- return response.payload.requestId === requestId ? response.payload : null;
2043
- },
2044
- });
1985
+ return subscribeTimeline(agentId, this.observeTimeline([agentId]), handler, (error) => this.logger.error({ err: error }, "Timeline observation failed"));
2045
1986
  }
2046
1987
  async buildAgentForkContext(agentId, options = {}) {
2047
1988
  const resolvedRequestId = this.createRequestId(options.requestId);
@@ -2303,7 +2244,7 @@ export class DaemonClient {
2303
2244
  }
2304
2245
  return payload.notice ?? null;
2305
2246
  }
2306
- async restartServer(reason, requestId) {
2247
+ async restartServer(reason, requestId, options) {
2307
2248
  const resolvedRequestId = this.createRequestId(requestId);
2308
2249
  const message = SessionInboundMessageSchema.parse({
2309
2250
  type: "restart_server_request",
@@ -2313,6 +2254,7 @@ export class DaemonClient {
2313
2254
  return this.sendRequest({
2314
2255
  requestId: resolvedRequestId,
2315
2256
  message,
2257
+ timeout: options?.timeout,
2316
2258
  options: { skipQueue: true },
2317
2259
  select: (msg) => {
2318
2260
  if (msg.type !== "status") {
@@ -2638,75 +2580,22 @@ export class DaemonClient {
2638
2580
  : { mode: "base", baseRef: trimmedBaseRef };
2639
2581
  }
2640
2582
  async getCheckoutDiff(cwd, compare, requestId) {
2641
- const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
2642
- try {
2643
- const payload = await this.subscribeCheckoutDiff(cwd, compare, {
2644
- subscriptionId: oneShotSubscriptionId,
2645
- requestId,
2646
- });
2647
- return {
2648
- cwd: payload.cwd,
2649
- files: payload.files,
2650
- error: payload.error,
2651
- diffTooLarge: payload.diffTooLarge,
2652
- requestId: payload.requestId,
2653
- };
2654
- }
2655
- finally {
2656
- try {
2657
- this.unsubscribeCheckoutDiff(oneShotSubscriptionId);
2658
- }
2659
- catch {
2660
- // Ignore disconnect races during one-shot cleanup.
2661
- }
2662
- }
2663
- }
2664
- async subscribeCheckoutDiff(cwd, compare, options) {
2665
- const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
2666
- const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
2667
- const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null;
2668
- this.checkoutDiffSubscriptions.set(subscriptionId, {
2669
- cwd,
2670
- compare: normalizedCompare,
2583
+ return this.sendCorrelatedSessionRequest({
2584
+ message: {
2585
+ type: "checkout.diff.get.request",
2586
+ cwd,
2587
+ compare: this.normalizeCheckoutDiffCompare(compare),
2588
+ },
2589
+ responseType: "checkout.diff.get.response",
2590
+ requestId,
2671
2591
  });
2672
- const resolvedRequestId = this.createRequestId(options?.requestId);
2673
- const message = SessionInboundMessageSchema.parse({
2592
+ }
2593
+ observeCheckoutDiff(cwd, compare, options) {
2594
+ return this.observe("subscribe_checkout_diff_response", {
2674
2595
  type: "subscribe_checkout_diff_request",
2675
- subscriptionId,
2676
2596
  cwd,
2677
- compare: normalizedCompare,
2678
- requestId: resolvedRequestId,
2679
- });
2680
- try {
2681
- return await this.sendCorrelatedRequest({
2682
- requestId: resolvedRequestId,
2683
- message,
2684
- responseType: "subscribe_checkout_diff_response",
2685
- options: { skipQueue: true },
2686
- selectPayload: (payload) => {
2687
- if (payload.subscriptionId !== subscriptionId) {
2688
- return null;
2689
- }
2690
- return payload;
2691
- },
2692
- });
2693
- }
2694
- catch (error) {
2695
- if (previousSubscription) {
2696
- this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription);
2697
- }
2698
- else {
2699
- this.checkoutDiffSubscriptions.delete(subscriptionId);
2700
- }
2701
- throw error;
2702
- }
2703
- }
2704
- unsubscribeCheckoutDiff(subscriptionId) {
2705
- this.checkoutDiffSubscriptions.delete(subscriptionId);
2706
- this.sendSessionMessage({
2707
- type: "unsubscribe_checkout_diff_request",
2708
- subscriptionId,
2709
- });
2597
+ compare: this.normalizeCheckoutDiffCompare(compare),
2598
+ }, options);
2710
2599
  }
2711
2600
  async checkoutCommit(cwd, input, requestId) {
2712
2601
  return this.sendCorrelatedSessionRequest({
@@ -3003,34 +2892,11 @@ export class DaemonClient {
3003
2892
  });
3004
2893
  }
3005
2894
  async createWorkspace(input, requestId) {
3006
- const resolvedRequestId = this.createRequestId(requestId ?? input.requestId);
3007
- const result = await this.creations.createWorkspace({
3008
- ...input,
3009
- requestId: resolvedRequestId,
3010
- ...(input.agent
3011
- ? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } }
3012
- : {}),
3013
- });
3014
- return {
3015
- ...result,
3016
- workspace: result.workspace ?? null,
3017
- agent: result.agent ?? undefined,
3018
- setupTerminalId: result.setupTerminalId ?? null,
3019
- requestId: result.requestId ?? resolvedRequestId,
3020
- };
3021
- }
3022
- async createLegacyWorkspace(input, requestId) {
3023
- // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07.
3024
- if (input.idempotencyKey !== undefined &&
3025
- !this.lastServerInfoMessage?.features?.workspaceRequestReceipts) {
3026
- throw new Error("Update the host to use retry-safe workspace creation.");
3027
- }
3028
2895
  return this.sendCorrelatedSessionRequest({
3029
2896
  requestId,
3030
2897
  message: {
3031
2898
  type: "workspace.create.request",
3032
2899
  source: input.source,
3033
- ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
3034
2900
  ...(input.title !== undefined ? { title: input.title } : {}),
3035
2901
  ...(input.firstAgentContext !== undefined
3036
2902
  ? { firstAgentContext: input.firstAgentContext }
@@ -3156,35 +3022,26 @@ export class DaemonClient {
3156
3022
  this.activeBinaryFileTransfers.delete(resolvedRequestId);
3157
3023
  }
3158
3024
  }
3025
+ observeFile(input) {
3026
+ return this.observe("fs.file.subscribe.response", { type: "fs.file.subscribe.request", cwd: input.cwd, path: input.path }, { signal: input.signal });
3027
+ }
3159
3028
  async subscribeFile(input, onUpdate) {
3160
- const subscriptionId = this.createRequestId();
3161
- this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate });
3162
- try {
3163
- const payload = await this.sendCorrelatedSessionRequest({
3164
- message: {
3165
- type: "fs.file.subscribe.request",
3166
- cwd: input.cwd,
3167
- path: input.path,
3168
- subscriptionId,
3169
- },
3170
- responseType: "fs.file.subscribe.response",
3171
- });
3172
- return {
3173
- initial: payload.initial,
3174
- unsubscribe: () => {
3175
- if (!this.fileSubscriptions.delete(subscriptionId))
3176
- return;
3177
- void this.sendCorrelatedSessionRequest({
3178
- message: { type: "fs.file.unsubscribe.request", subscriptionId },
3179
- responseType: "fs.file.unsubscribe.response",
3180
- }).catch(() => undefined);
3181
- },
3182
- };
3183
- }
3184
- catch (error) {
3185
- this.fileSubscriptions.delete(subscriptionId);
3186
- throw error;
3187
- }
3029
+ const subscription = this.observeFile(input);
3030
+ let initial = true;
3031
+ subscription.subscribe({
3032
+ snapshot: (snapshot) => {
3033
+ // The initial version is returned below. Subsequent snapshots repair reconnects.
3034
+ if (!initial)
3035
+ onUpdate(snapshot.initial);
3036
+ initial = false;
3037
+ },
3038
+ update: (message) => {
3039
+ if (message.type === "fs.file.update")
3040
+ onUpdate(message.payload.version);
3041
+ },
3042
+ });
3043
+ const snapshot = await subscription.ready;
3044
+ return { initial: snapshot.initial, subscription, unsubscribe: subscription.release };
3188
3045
  }
3189
3046
  async writeFile(input) {
3190
3047
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3364,6 +3221,11 @@ export class DaemonClient {
3364
3221
  });
3365
3222
  }
3366
3223
  async getDaemonStatus(options) {
3224
+ if (!this.lastServerInfoMessage)
3225
+ throw new DaemonConnectionError("Transport not connected");
3226
+ if (this.lastServerInfoMessage?.features?.daemonStatusRpc !== true) {
3227
+ throw new Error("Update the host to read daemon status.");
3228
+ }
3367
3229
  return this.sendCorrelatedSessionRequest({
3368
3230
  requestId: options?.requestId,
3369
3231
  message: {
@@ -3840,30 +3702,9 @@ export class DaemonClient {
3840
3702
  // ============================================================================
3841
3703
  // Terminals
3842
3704
  // ============================================================================
3843
- subscribeTerminals(input) {
3844
- this.terminalDirectorySubscriptions.set(terminalSubscriptionKey(input.cwd, input.workspaceId), {
3845
- cwd: input.cwd,
3846
- workspaceId: input.workspaceId,
3847
- });
3848
- if (!this.transport || this.connectionState.status !== "connected") {
3849
- return;
3850
- }
3851
- this.sendSessionMessage({
3852
- type: "subscribe_terminals_request",
3853
- cwd: input.cwd,
3854
- ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
3855
- });
3856
- }
3857
- unsubscribeTerminals(input) {
3858
- this.terminalDirectorySubscriptions.delete(terminalSubscriptionKey(input.cwd, input.workspaceId));
3859
- if (!this.transport || this.connectionState.status !== "connected") {
3860
- return;
3861
- }
3862
- this.sendSessionMessage({
3863
- type: "unsubscribe_terminals_request",
3864
- cwd: input.cwd,
3865
- ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
3866
- });
3705
+ observeTerminals(input) {
3706
+ const { signal, ...query } = input;
3707
+ return this.observe("terminals_changed", { type: "subscribe_terminals_request", ...query }, { signal });
3867
3708
  }
3868
3709
  async listTerminals(cwd, requestId, options) {
3869
3710
  const resolvedRequestId = this.createRequestId(requestId);
@@ -3911,45 +3752,72 @@ export class DaemonClient {
3911
3752
  responseType: "terminal.rename.response",
3912
3753
  });
3913
3754
  }
3914
- async subscribeTerminal(terminalId, optionsOrRequestId) {
3915
- const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined;
3916
- const requestId = typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId;
3917
- const resolvedRequestId = this.createRequestId(requestId);
3918
- const message = SessionInboundMessageSchema.parse({
3755
+ /**
3756
+ * Snapshot/restore and output arrive in receive. Subscribe to the returned handle
3757
+ * for terminal_stream_exit: payload.error means observation failure, not PTY exit.
3758
+ * Either outcome detaches this slot and releases the handle automatically.
3759
+ */
3760
+ observeTerminal(terminalId, receive, options) {
3761
+ options?.signal?.throwIfAborted();
3762
+ const observation = this.observe("subscribe_terminal_response", {
3919
3763
  type: "subscribe_terminal_request",
3920
3764
  terminalId,
3921
- requestId: resolvedRequestId,
3922
- ...(restore ? { restore } : {}),
3923
- });
3924
- const payload = await this.sendCorrelatedRequest({
3925
- requestId: resolvedRequestId,
3926
- message,
3927
- responseType: "subscribe_terminal_response",
3928
- options: { skipQueue: true },
3765
+ ...(options?.restore ? { restore: options.restore } : {}),
3766
+ }, options);
3767
+ let detach = () => { };
3768
+ let released = false;
3769
+ const release = () => {
3770
+ released = true;
3771
+ detach();
3772
+ options?.signal?.removeEventListener("abort", abort);
3773
+ return observation.release();
3774
+ };
3775
+ const abort = () => {
3776
+ void release().catch((error) => this.logger.error({ err: error }, "Terminal release failed"));
3777
+ };
3778
+ options?.signal?.addEventListener("abort", abort, { once: true });
3779
+ observation.subscribe({
3780
+ snapshot: (payload) => {
3781
+ detach();
3782
+ if (released || options?.signal?.aborted || payload.error !== null)
3783
+ return;
3784
+ const subscriptionId = payload.subscriptionId;
3785
+ detach = this.terminalStreams.attach(subscriptionId, terminalId, payload.slot, (event) => {
3786
+ if (released || observation.subscriptionId !== subscriptionId)
3787
+ return;
3788
+ try {
3789
+ receive(event);
3790
+ }
3791
+ catch (error) {
3792
+ this.logger.error({ err: error }, "Terminal observer failed");
3793
+ }
3794
+ });
3795
+ },
3796
+ update: (message) => {
3797
+ if (message.type === "terminal_stream_exit") {
3798
+ detach();
3799
+ queueMicrotask(abort);
3800
+ }
3801
+ },
3802
+ error: () => {
3803
+ detach();
3804
+ },
3929
3805
  });
3930
- if (payload.error === null) {
3931
- this.terminalStreams.setSlot(terminalId, payload.slot);
3932
- }
3933
- return payload;
3806
+ return {
3807
+ get subscriptionId() {
3808
+ return observation.subscriptionId;
3809
+ },
3810
+ ready: observation.ready,
3811
+ subscribe: observation.subscribe,
3812
+ release,
3813
+ };
3934
3814
  }
3935
- unsubscribeTerminal(terminalId) {
3936
- this.terminalStreams.removeTerminal(terminalId);
3937
- this.sendSessionMessage({
3938
- type: "unsubscribe_terminal_request",
3939
- terminalId,
3940
- });
3815
+ async subscribeTerminal(terminalId, options) {
3816
+ const subscription = this.observeTerminal(terminalId, () => { }, options);
3817
+ return { ...(await subscription.ready), subscription };
3941
3818
  }
3942
3819
  sendTerminalInput(terminalId, message) {
3943
- const frame = this.terminalStreams.encodeInput(terminalId, message);
3944
- if (frame) {
3945
- this.sendBinaryFrame(frame);
3946
- return;
3947
- }
3948
- this.sendSessionMessage({
3949
- type: "terminal_input",
3950
- terminalId,
3951
- message,
3952
- });
3820
+ this.sendSessionMessage({ type: "terminal_input", terminalId, message });
3953
3821
  }
3954
3822
  async killTerminal(terminalId, requestId) {
3955
3823
  const resolvedRequestId = this.createRequestId(requestId);
@@ -4182,7 +4050,8 @@ export class DaemonClient {
4182
4050
  }
4183
4051
  }
4184
4052
  disposeTransport(code = 1001, reason = "Reconnecting") {
4185
- this.providerSnapshotUpdates.pause();
4053
+ this.owned.disconnected();
4054
+ this.providerSnapshotUpdates.clear();
4186
4055
  this.stopLivenessHeartbeat();
4187
4056
  this.cleanupTransport();
4188
4057
  if (this.transport) {
@@ -4223,10 +4092,12 @@ export class DaemonClient {
4223
4092
  if (typeof Blob !== "undefined" &&
4224
4093
  rawData instanceof Blob &&
4225
4094
  typeof rawData.arrayBuffer === "function") {
4095
+ const transport = this.transport;
4226
4096
  void rawData
4227
4097
  .arrayBuffer()
4228
4098
  .then((buffer) => {
4229
- this.handleTransportMessage(buffer);
4099
+ if (this.transport === transport)
4100
+ this.handleTransportMessage(buffer);
4230
4101
  return;
4231
4102
  })
4232
4103
  .catch(() => {
@@ -4455,12 +4326,13 @@ export class DaemonClient {
4455
4326
  if (typeof reason === "string" && reason.trim().length > 0) {
4456
4327
  this.lastErrorValue = reason.trim();
4457
4328
  }
4458
- this.providerSnapshotUpdates.pause();
4329
+ this.owned.disconnected();
4330
+ this.providerSnapshotUpdates.clear();
4459
4331
  // Clear all pending waiters and queued sends since the connection was lost
4460
4332
  // and responses from the previous connection will never arrive.
4461
- this.clearWaiters(new Error(reason ?? "Connection lost"));
4462
- this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
4463
- this.rejectPingProbe(new Error(reason ?? "Connection lost"));
4333
+ this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
4334
+ this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
4335
+ this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost"));
4464
4336
  this.terminalStreams.clearSlots();
4465
4337
  this.lastServerInfoMessage = null;
4466
4338
  if (wasDisposed) {
@@ -4539,13 +4411,13 @@ export class DaemonClient {
4539
4411
  });
4540
4412
  }
4541
4413
  handleSessionMessage(msg) {
4414
+ msg = this.owned.normalize(msg);
4542
4415
  if (msg.type === "providers_snapshot_update" &&
4543
4416
  this.config.providerSnapshots !== "wire" &&
4544
4417
  msg.payload.snapshotHash &&
4545
4418
  !msg.payload.compactSnapshot) {
4546
- if (this.messageHandlers.has(msg.type) || this.eventListeners.size > 0) {
4419
+ if (this.owned.owns(msg))
4547
4420
  this.providerSnapshotUpdates.receive(msg);
4548
- }
4549
4421
  return;
4550
4422
  }
4551
4423
  this.deliverSessionMessage(msg);
@@ -4561,25 +4433,15 @@ export class DaemonClient {
4561
4433
  this.reconnectAttempt = 0;
4562
4434
  this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
4563
4435
  this.startLivenessHeartbeat();
4564
- this.subscriptions.restore();
4565
- this.providerSnapshotUpdates.resume();
4566
- this.resubscribeCheckoutDiffSubscriptions();
4567
- this.resubscribeTerminalDirectorySubscriptions();
4568
- this.resubscribeFileSubscriptions();
4569
- this.creations.reconnect();
4436
+ this.owned.restore(serverInfo, {
4437
+ ...DEFAULT_CLIENT_CAPABILITIES,
4438
+ ...this.config.capabilities,
4439
+ });
4570
4440
  this.flushPendingSendQueue();
4571
4441
  this.resolveConnect();
4572
4442
  }
4573
4443
  }
4574
4444
  }
4575
- if (consumerMessage.type === "terminal_stream_exit") {
4576
- this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
4577
- }
4578
- if (consumerMessage.type === "fs.file.update") {
4579
- this.fileSubscriptions
4580
- .get(consumerMessage.payload.subscriptionId)
4581
- ?.onUpdate(consumerMessage.payload.version);
4582
- }
4583
4445
  if (this.rawMessageListeners.size > 0) {
4584
4446
  for (const handler of this.rawMessageListeners) {
4585
4447
  try {
@@ -4607,21 +4469,26 @@ export class DaemonClient {
4607
4469
  handler(event);
4608
4470
  }
4609
4471
  }
4610
- if (consumerMessage.type === "workspace.create.update" ||
4611
- consumerMessage.type === "agent.create.update")
4612
- this.creations.receive(consumerMessage.payload);
4613
4472
  this.resolveWaiters(consumerMessage);
4473
+ this.owned.receive(consumerMessage);
4614
4474
  }
4615
4475
  resolveWaiters(msg) {
4616
4476
  for (const waiter of Array.from(this.waiters)) {
4617
- const result = waiter.predicate(msg);
4618
- if (result !== null) {
4477
+ try {
4478
+ const result = waiter.predicate(msg);
4479
+ if (result === null)
4480
+ continue;
4619
4481
  this.waiters.delete(waiter);
4620
- if (waiter.timeoutHandle) {
4482
+ if (waiter.timeoutHandle)
4621
4483
  clearTimeout(waiter.timeoutHandle);
4622
- }
4623
4484
  waiter.resolve(result);
4624
4485
  }
4486
+ catch (error) {
4487
+ this.waiters.delete(waiter);
4488
+ if (waiter.timeoutHandle)
4489
+ clearTimeout(waiter.timeoutHandle);
4490
+ waiter.reject(error instanceof Error ? error : new Error(String(error)));
4491
+ }
4625
4492
  }
4626
4493
  }
4627
4494
  rejectWaitersForRequestId(requestId, error) {
@@ -4704,7 +4571,7 @@ export class DaemonClient {
4704
4571
  }
4705
4572
  waitForWithCancel(predicate, timeout = 30000, options) {
4706
4573
  // Capture stack trace at call site, not inside setTimeout
4707
- const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
4574
+ const timeoutError = new DaemonConnectionError(`Timeout waiting for message (${timeout}ms)`, "DAEMON_REQUEST_TIMEOUT");
4708
4575
  let waiter = null;
4709
4576
  let settled = false;
4710
4577
  let rejectFn = null;
@@ -4764,7 +4631,7 @@ export class DaemonClient {
4764
4631
  }
4765
4632
  }
4766
4633
  function resolveAgentConfig(options) {
4767
- const { config, provider, cwd, agentId: _agentId, onEvent: _onEvent, idempotencyKey: _idempotencyKey, clientMessageId: _clientMessageId, callerAgentId: _callerAgentId, outputSchema: _outputSchema, attachments: _attachments, worktree: _worktree, autoArchive: _autoArchive, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, git: _git, worktreeName: _worktreeName, requestId: _requestId, labels: _labels, ...overrides } = options;
4634
+ const { config, provider, cwd, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, git: _git, worktreeName: _worktreeName, requestId: _requestId, labels: _labels, ...overrides } = options;
4768
4635
  const baseConfig = {
4769
4636
  ...(provider ? { provider } : {}),
4770
4637
  ...(cwd ? { cwd } : {}),