@camerontaylor/paseo-client 0.8.0-fork.1 → 0.9.0-beta.2.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,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
4
  import { CreationClient } from "./creation/index.js";
4
5
  import { CLIENT_CAPS } from "@camerontaylor/paseo-protocol/client-capabilities";
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();
@@ -271,6 +289,7 @@ export class DaemonClient {
271
289
  this.runtimeMetricsInterval = null;
272
290
  this.runtimeMetrics = null;
273
291
  this.pingProbe = null;
292
+ this.connectionVerification = null;
274
293
  this.livenessHeartbeatTimer = null;
275
294
  this.lastLivenessRttMs = null;
276
295
  this.consecutiveLivenessFailures = 0;
@@ -278,8 +297,7 @@ export class DaemonClient {
278
297
  // Agent Lifecycle
279
298
  // ============================================================================
280
299
  this.creations = new CreationClient({
281
- supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true,
282
- connected: () => this.connectionState.status === "connected",
300
+ supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true,
283
301
  requestId: () => this.createRequestId(),
284
302
  request: (kind, input) => kind === "workspace"
285
303
  ? this.sendCorrelatedSessionRequest({
@@ -294,18 +312,26 @@ export class DaemonClient {
294
312
  responseType: "agent.create.response",
295
313
  timeout: 0,
296
314
  }),
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",
315
+ observe: (kind, idempotencyKey, next, error) => {
316
+ const observation = this.observe("creation.subscribe.response", {
317
+ type: "creation.subscribe.request",
318
+ kind,
319
+ idempotencyKey,
301
320
  });
302
- if (result.error)
303
- throw new Error(result.error);
304
- return result.snapshot;
321
+ observation.subscribe({
322
+ snapshot: (result) => next(result.snapshot),
323
+ update: (message) => {
324
+ if (message.type === "workspace.create.update" || message.type === "agent.create.update")
325
+ next(message.payload);
326
+ },
327
+ error,
328
+ });
329
+ return () => {
330
+ void observation.release().catch(error);
331
+ };
305
332
  },
306
333
  legacyAgent: (input) => this.createLegacyAgent(input),
307
334
  legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
308
- sendMessage: (id, text, options) => this.sendMessage(id, text, options),
309
335
  });
310
336
  this.logger = config.logger ?? consoleLogger;
311
337
  this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct";
@@ -379,6 +405,12 @@ export class DaemonClient {
379
405
  if (this.connectionState.status === "connecting") {
380
406
  return;
381
407
  }
408
+ // This attempt supersedes any retry the last disconnect scheduled. Left
409
+ // armed, that retry would tear down the connection this attempt opens.
410
+ if (this.reconnectTimeout) {
411
+ clearTimeout(this.reconnectTimeout);
412
+ this.reconnectTimeout = null;
413
+ }
382
414
  const headers = {};
383
415
  const password = normalizePassword(this.config.password);
384
416
  if (password) {
@@ -494,7 +526,10 @@ export class DaemonClient {
494
526
  reasonCode: "transport_error",
495
527
  });
496
528
  }),
497
- transport.onMessage((data) => this.handleTransportMessage(data)),
529
+ transport.onMessage((data) => {
530
+ if (this.transport === transport)
531
+ this.handleTransportMessage(data);
532
+ }),
498
533
  ];
499
534
  }
500
535
  catch (error) {
@@ -530,7 +565,6 @@ export class DaemonClient {
530
565
  return;
531
566
  }
532
567
  this.shouldReconnect = false;
533
- this.subscriptions.close();
534
568
  this.connectPromise = null;
535
569
  this.connectResolve = null;
536
570
  this.connectReject = null;
@@ -542,11 +576,11 @@ export class DaemonClient {
542
576
  this.disposeTransport(1000, "Client closed");
543
577
  this.providerSnapshotUpdates.clear();
544
578
  this.clearWaiters(new Error("Daemon client closed"));
579
+ await this.owned.close();
545
580
  this.creations.close();
546
581
  this.rejectPendingSendQueue(new Error("Daemon client closed"));
547
582
  this.rejectPingProbe(new Error("Daemon client closed"));
548
583
  this.terminalStreams.clearSlots();
549
- this.fileSubscriptions.clear();
550
584
  this.lastServerInfoMessage = null;
551
585
  if (this.runtimeMetricsInterval) {
552
586
  clearInterval(this.runtimeMetricsInterval);
@@ -556,27 +590,50 @@ export class DaemonClient {
556
590
  }
557
591
  this.updateConnectionState({ status: "disposed" }, { event: "DISPOSE", reason: "Client closed", reasonCode: "disposed" });
558
592
  }
559
- ensureConnected() {
593
+ ensureConnected(options) {
560
594
  if (this.connectionState.status === "disposed") {
561
595
  return;
562
596
  }
563
597
  if (!this.shouldReconnect) {
564
598
  this.shouldReconnect = true;
565
599
  }
566
- if (this.connectionState.status === "connected" ||
567
- this.connectionState.status === "connecting") {
600
+ if (this.connectionState.status === "connected") {
601
+ if (options?.verify)
602
+ this.verifyConnection();
568
603
  return;
569
604
  }
570
- if (this.reconnectTimeout) {
571
- clearTimeout(this.reconnectTimeout);
572
- this.reconnectTimeout = null;
573
- }
605
+ if (this.connectionState.status === "connecting")
606
+ return;
574
607
  if (this.connectPromise) {
575
608
  this.attemptConnect();
576
609
  return;
577
610
  }
578
611
  void this.connect();
579
612
  }
613
+ verifyConnection() {
614
+ const transport = this.transport;
615
+ if (!transport || this.connectionVerification === transport)
616
+ return;
617
+ this.connectionVerification = transport;
618
+ // A session probe has its own deadline, independent of a heartbeat that the OS
619
+ // may have suspended. A successful response also proves the session can serve RPCs.
620
+ void this.ping({ timeoutMs: 3000 })
621
+ .catch((error) => {
622
+ if (this.transport !== transport || this.connectionState.status !== "connected")
623
+ return;
624
+ this.disposeTransport(1001, "Connection verification failed");
625
+ this.scheduleReconnect({
626
+ reason: error instanceof Error ? error.message : String(error),
627
+ event: "CONNECTION_VERIFICATION_FAILED",
628
+ reasonCode: "liveness_timeout",
629
+ });
630
+ this.ensureConnected();
631
+ })
632
+ .finally(() => {
633
+ if (this.connectionVerification === transport)
634
+ this.connectionVerification = null;
635
+ });
636
+ }
580
637
  getConnectionState() {
581
638
  return this.connectionState;
582
639
  }
@@ -604,10 +661,8 @@ export class DaemonClient {
604
661
  // ============================================================================
605
662
  subscribe(handler) {
606
663
  this.eventListeners.add(handler);
607
- this.updateEventSubscriptions();
608
664
  return () => {
609
665
  this.eventListeners.delete(handler);
610
- this.updateEventSubscriptions();
611
666
  };
612
667
  }
613
668
  subscribeRawMessages(handler) {
@@ -626,7 +681,6 @@ export class DaemonClient {
626
681
  this.messageHandlers.set(type, new Set());
627
682
  }
628
683
  this.messageHandlers.get(type).add(handler);
629
- this.updateEventSubscriptions();
630
684
  return () => {
631
685
  const handlers = this.messageHandlers.get(type);
632
686
  if (!handlers) {
@@ -636,7 +690,6 @@ export class DaemonClient {
636
690
  if (handlers.size === 0) {
637
691
  this.messageHandlers.delete(type);
638
692
  }
639
- this.updateEventSubscriptions();
640
693
  };
641
694
  }
642
695
  onAgentAttentionRequired(handler) {
@@ -690,7 +743,7 @@ export class DaemonClient {
690
743
  }
691
744
  sendTransportFrame(frame) {
692
745
  if (!this.transport) {
693
- throw new Error("Transport not connected");
746
+ throw new DaemonConnectionError("Transport not connected");
694
747
  }
695
748
  const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
696
749
  kind: typeof frame === "string" ? "text" : "binary",
@@ -770,13 +823,13 @@ export class DaemonClient {
770
823
  if (idx !== -1) {
771
824
  this.pendingSendQueue.splice(idx, 1);
772
825
  }
773
- reject(new Error(`Timed out waiting for connection to send message`));
826
+ reject(new DaemonConnectionError("Timed out waiting for connection to send message", "DAEMON_REQUEST_TIMEOUT"));
774
827
  }, DEFAULT_SEND_QUEUE_TIMEOUT_MS);
775
828
  this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle });
776
829
  });
777
830
  }
778
831
  // Not connected and not connecting - fail immediately
779
- return Promise.reject(new Error(`Transport not connected (status: ${status})`));
832
+ return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`));
780
833
  }
781
834
  /**
782
835
  * Flush pending send queue - called when connection is established.
@@ -793,7 +846,7 @@ export class DaemonClient {
793
846
  pending.resolve();
794
847
  }
795
848
  else {
796
- pending.reject(new Error("Connection lost before message could be sent"));
849
+ pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
797
850
  }
798
851
  }
799
852
  catch (error) {
@@ -813,6 +866,7 @@ export class DaemonClient {
813
866
  }
814
867
  }
815
868
  async sendRequest(params) {
869
+ const wire = this.owned.prepareRequest(params.message);
816
870
  const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
817
871
  const { promise, cancel } = this.waitForWithCancel((msg) => {
818
872
  if (msg.type === "rpc_error" && msg.payload.requestId === params.requestId) {
@@ -826,14 +880,14 @@ export class DaemonClient {
826
880
  }),
827
881
  };
828
882
  }
829
- const value = params.select(msg);
883
+ const value = params.select(wire.receive(msg));
830
884
  if (value === null) {
831
885
  return null;
832
886
  }
833
887
  return { kind: "ok", value };
834
888
  }, timeout, { ...params.options, requestId: params.requestId });
835
889
  try {
836
- await this.sendSessionMessageOrThrow(params.message);
890
+ await this.sendSessionMessageOrThrow(wire.message);
837
891
  }
838
892
  catch (error) {
839
893
  const err = error instanceof Error ? error : new Error(String(error));
@@ -841,11 +895,15 @@ export class DaemonClient {
841
895
  void promise.catch(() => undefined);
842
896
  throw err;
843
897
  }
844
- const result = await promise;
845
- if (result.kind === "error") {
846
- throw result.error;
898
+ try {
899
+ const result = await promise;
900
+ if (result.kind === "error")
901
+ throw result.error;
902
+ return result.value;
903
+ }
904
+ finally {
905
+ await wire.finish();
847
906
  }
848
- return result.value;
849
907
  }
850
908
  async sendCorrelatedRequest(params) {
851
909
  return this.sendRequest({
@@ -893,7 +951,7 @@ export class DaemonClient {
893
951
  }
894
952
  sendSessionMessageStrict(message) {
895
953
  if (!this.transport || this.connectionState.status !== "connected") {
896
- throw new Error("Transport not connected");
954
+ throw new DaemonConnectionError("Transport not connected");
897
955
  }
898
956
  const payload = SessionInboundMessageSchema.parse(message);
899
957
  try {
@@ -1107,7 +1165,69 @@ export class DaemonClient {
1107
1165
  // ============================================================================
1108
1166
  // Agent RPCs (requestId-correlated)
1109
1167
  // ============================================================================
1168
+ observe(responseType, message, options) {
1169
+ let resetSource = false;
1170
+ return this.owned.observeRequest(message, async (query, accept) => {
1171
+ resetSource = false;
1172
+ const requestId = this.createRequestId(options?.requestId);
1173
+ const request = SessionInboundMessageSchema.parse({ ...query, requestId });
1174
+ try {
1175
+ return await this.sendCorrelatedRequest({
1176
+ message: request,
1177
+ responseType,
1178
+ requestId,
1179
+ timeout: options?.timeout,
1180
+ options: { skipQueue: true },
1181
+ selectPayload: (payload) => {
1182
+ if ("error" in payload && typeof payload.error === "string")
1183
+ throw new DaemonRpcError({
1184
+ requestId: payload.requestId,
1185
+ requestType: message.type,
1186
+ error: payload.error,
1187
+ });
1188
+ accept(payload);
1189
+ return payload;
1190
+ },
1191
+ });
1192
+ }
1193
+ catch (error) {
1194
+ resetSource = !(error instanceof DaemonRpcError) && this.isConnected;
1195
+ throw error;
1196
+ }
1197
+ }, options?.signal, () => {
1198
+ // Reject and release this handle before closing a source with an unknown bootstrap outcome.
1199
+ // Closing first would classify the failure as a reconnect and replay the failed request.
1200
+ if (resetSource) {
1201
+ this.disposeTransport(1001, "Subscription request failed");
1202
+ this.scheduleReconnect({ reason: "Subscription request failed" });
1203
+ }
1204
+ });
1205
+ }
1206
+ registerBrowserHost(registration, options) {
1207
+ return this.observe("browser.host.register.response", { type: "browser.host.register.request", ...registration }, options);
1208
+ }
1209
+ observeEvents(events, options) {
1210
+ return this.observe("session.events.set_subscription.response", {
1211
+ type: "session.events.set_subscription.request",
1212
+ events,
1213
+ notifications: options?.notifications,
1214
+ }, options);
1215
+ }
1216
+ observeAgents(options = {}) {
1217
+ const { signal, requestId, timeout, ...query } = options;
1218
+ return this.observe("fetch_agents_response", { ...query, type: "fetch_agents_request", subscribe: {} }, { signal, requestId, timeout });
1219
+ }
1220
+ observeWorkspaces(options = {}) {
1221
+ const { signal, requestId, ...query } = options;
1222
+ return this.observe("fetch_workspaces_response", { ...query, type: "fetch_workspaces_request", subscribe: {} }, { signal, requestId });
1223
+ }
1110
1224
  async fetchAgents(options) {
1225
+ if (options?.subscribe) {
1226
+ if (options.subscribe.subscriptionId !== undefined)
1227
+ throw new Error("Subscription IDs are assigned by the host");
1228
+ const subscription = this.observeAgents(options);
1229
+ return { ...(await subscription.ready), subscription };
1230
+ }
1111
1231
  const resolvedRequestId = this.createRequestId(options?.requestId);
1112
1232
  const message = SessionInboundMessageSchema.parse({
1113
1233
  type: "fetch_agents_request",
@@ -1187,6 +1307,12 @@ export class DaemonClient {
1187
1307
  });
1188
1308
  }
1189
1309
  async fetchWorkspaces(options) {
1310
+ if (options?.subscribe) {
1311
+ if (options.subscribe.subscriptionId !== undefined)
1312
+ throw new Error("Subscription IDs are assigned by the host");
1313
+ const subscription = this.observeWorkspaces(options);
1314
+ return { ...(await subscription.ready), subscription };
1315
+ }
1190
1316
  const resolvedRequestId = this.createRequestId(options?.requestId);
1191
1317
  const message = SessionInboundMessageSchema.parse({
1192
1318
  type: "fetch_workspaces_request",
@@ -1212,16 +1338,18 @@ export class DaemonClient {
1212
1338
  },
1213
1339
  });
1214
1340
  }
1215
- listWorkspaceLabels(options) {
1341
+ listWorkspaceLabels(options = {}) {
1216
1342
  return this.sendNamespacedCorrelatedSessionRequest({
1217
1343
  requestId: options.requestId,
1218
1344
  message: {
1219
1345
  type: "workspace.label.list.request",
1220
- subscribe: { subscriptionId: options.subscriptionId },
1221
1346
  ...(options.sync ? { sync: options.sync } : {}),
1222
1347
  },
1223
1348
  });
1224
1349
  }
1350
+ observeWorkspaceLabels(options) {
1351
+ return this.observe("workspace.label.list.response", { type: "workspace.label.list.request", subscribe: {} }, options);
1352
+ }
1225
1353
  setWorkspaceLabel(options) {
1226
1354
  return this.sendNamespacedCorrelatedSessionRequest({
1227
1355
  requestId: options.requestId,
@@ -1422,50 +1550,6 @@ export class DaemonClient {
1422
1550
  }
1423
1551
  return { agent: payload.agent, project: payload.project ?? null };
1424
1552
  }
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
- }
1469
1553
  async createAgent(options) {
1470
1554
  const result = await this.creations.createAgent({
1471
1555
  ...options,
@@ -1476,8 +1560,6 @@ export class DaemonClient {
1476
1560
  return result.agent;
1477
1561
  }
1478
1562
  async createLegacyAgent(options) {
1479
- if (options.idempotencyKey !== undefined)
1480
- this.requireAgentRequestReceipts();
1481
1563
  const requestId = this.createRequestId(options.requestId);
1482
1564
  const config = resolveAgentConfig(options);
1483
1565
  const message = SessionInboundMessageSchema.parse({
@@ -1527,12 +1609,6 @@ export class DaemonClient {
1527
1609
  }
1528
1610
  return status.agent;
1529
1611
  }
1530
- requireAgentRequestReceipts() {
1531
- // COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05.
1532
- if (this.lastServerInfoMessage?.features?.agentRequestReceipts !== true) {
1533
- throw new Error("Update the host to use retry-safe agent creation.");
1534
- }
1535
- }
1536
1612
  async deleteAgent(agentId) {
1537
1613
  const requestId = this.createRequestId();
1538
1614
  const message = SessionInboundMessageSchema.parse({
@@ -1832,6 +1908,17 @@ export class DaemonClient {
1832
1908
  });
1833
1909
  return { seq: payload.seq, epoch: payload.epoch };
1834
1910
  }
1911
+ async searchAgentTimeline({ agentId, query, cursor, }) {
1912
+ const requestId = this.createRequestId();
1913
+ const payload = await this.sendCorrelatedSessionRequest({
1914
+ requestId,
1915
+ message: { type: "agent.timeline.search.request", requestId, agentId, query, cursor },
1916
+ responseType: "agent.timeline.search.response",
1917
+ });
1918
+ if (payload.error)
1919
+ throw new Error(payload.error);
1920
+ return payload;
1921
+ }
1835
1922
  async listAgentTimelinePrompts(agentId, options = {}) {
1836
1923
  const requestId = this.createRequestId(options.requestId);
1837
1924
  const message = SessionInboundMessageSchema.parse({
@@ -1877,6 +1964,10 @@ export class DaemonClient {
1877
1964
  return payload;
1878
1965
  }
1879
1966
  async fetchProviderSubagentTimeline(parentAgentId, subagentId, options = {}) {
1967
+ // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14.
1968
+ if (this.lastServerInfoMessage?.features?.projectedSubagentTimeline !== true) {
1969
+ throw new Error("Update the host to view subagent conversations.");
1970
+ }
1880
1971
  const requestId = this.createRequestId(options.requestId);
1881
1972
  const message = SessionInboundMessageSchema.parse({
1882
1973
  type: "agent.provider_subagents.timeline.get.request",
@@ -1902,6 +1993,9 @@ export class DaemonClient {
1902
1993
  }
1903
1994
  return payload;
1904
1995
  }
1996
+ observeTimeline(agentIds, options) {
1997
+ return this.observe("agent.timeline.set_subscription.response", { type: "agent.timeline.set_subscription.request", agentIds }, options);
1998
+ }
1905
1999
  async askSideConversation(parentAgentId, threadId, question, options = {}) {
1906
2000
  this.requireSideConversationSupport();
1907
2001
  const requestId = this.createRequestId(options.requestId);
@@ -1971,77 +2065,8 @@ export class DaemonClient {
1971
2065
  throw new Error(payload.error);
1972
2066
  return payload;
1973
2067
  }
1974
- setAgentTimelineSubscription(agentIds) {
1975
- return this.subscriptions.setViewed(agentIds);
1976
- }
1977
2068
  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
- });
2069
+ return subscribeTimeline(agentId, this.observeTimeline([agentId]), handler, (error) => this.logger.error({ err: error }, "Timeline observation failed"));
2045
2070
  }
2046
2071
  async buildAgentForkContext(agentId, options = {}) {
2047
2072
  const resolvedRequestId = this.createRequestId(options.requestId);
@@ -2303,7 +2328,7 @@ export class DaemonClient {
2303
2328
  }
2304
2329
  return payload.notice ?? null;
2305
2330
  }
2306
- async restartServer(reason, requestId) {
2331
+ async restartServer(reason, requestId, options) {
2307
2332
  const resolvedRequestId = this.createRequestId(requestId);
2308
2333
  const message = SessionInboundMessageSchema.parse({
2309
2334
  type: "restart_server_request",
@@ -2313,6 +2338,7 @@ export class DaemonClient {
2313
2338
  return this.sendRequest({
2314
2339
  requestId: resolvedRequestId,
2315
2340
  message,
2341
+ timeout: options?.timeout,
2316
2342
  options: { skipQueue: true },
2317
2343
  select: (msg) => {
2318
2344
  if (msg.type !== "status") {
@@ -2638,75 +2664,22 @@ export class DaemonClient {
2638
2664
  : { mode: "base", baseRef: trimmedBaseRef };
2639
2665
  }
2640
2666
  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,
2667
+ return this.sendCorrelatedSessionRequest({
2668
+ message: {
2669
+ type: "checkout.diff.get.request",
2670
+ cwd,
2671
+ compare: this.normalizeCheckoutDiffCompare(compare),
2672
+ },
2673
+ responseType: "checkout.diff.get.response",
2674
+ requestId,
2671
2675
  });
2672
- const resolvedRequestId = this.createRequestId(options?.requestId);
2673
- const message = SessionInboundMessageSchema.parse({
2676
+ }
2677
+ observeCheckoutDiff(cwd, compare, options) {
2678
+ return this.observe("subscribe_checkout_diff_response", {
2674
2679
  type: "subscribe_checkout_diff_request",
2675
- subscriptionId,
2676
2680
  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
- });
2681
+ compare: this.normalizeCheckoutDiffCompare(compare),
2682
+ }, options);
2710
2683
  }
2711
2684
  async checkoutCommit(cwd, input, requestId) {
2712
2685
  return this.sendCorrelatedSessionRequest({
@@ -3020,17 +2993,16 @@ export class DaemonClient {
3020
2993
  };
3021
2994
  }
3022
2995
  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
2996
  return this.sendCorrelatedSessionRequest({
3029
2997
  requestId,
3030
2998
  message: {
3031
2999
  type: "workspace.create.request",
3032
3000
  source: input.source,
3033
- ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
3001
+ // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove after 2027-03-15 once the daemon floor supports workspace receipts.
3002
+ ...(this.lastServerInfoMessage?.features?.workspaceRequestReceipts &&
3003
+ input.idempotencyKey !== undefined
3004
+ ? { idempotencyKey: input.idempotencyKey }
3005
+ : {}),
3034
3006
  ...(input.title !== undefined ? { title: input.title } : {}),
3035
3007
  ...(input.firstAgentContext !== undefined
3036
3008
  ? { firstAgentContext: input.firstAgentContext }
@@ -3156,35 +3128,26 @@ export class DaemonClient {
3156
3128
  this.activeBinaryFileTransfers.delete(resolvedRequestId);
3157
3129
  }
3158
3130
  }
3131
+ observeFile(input) {
3132
+ return this.observe("fs.file.subscribe.response", { type: "fs.file.subscribe.request", cwd: input.cwd, path: input.path }, { signal: input.signal });
3133
+ }
3159
3134
  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
- }
3135
+ const subscription = this.observeFile(input);
3136
+ let initial = true;
3137
+ subscription.subscribe({
3138
+ snapshot: (snapshot) => {
3139
+ // The initial version is returned below. Subsequent snapshots repair reconnects.
3140
+ if (!initial)
3141
+ onUpdate(snapshot.initial);
3142
+ initial = false;
3143
+ },
3144
+ update: (message) => {
3145
+ if (message.type === "fs.file.update")
3146
+ onUpdate(message.payload.version);
3147
+ },
3148
+ });
3149
+ const snapshot = await subscription.ready;
3150
+ return { initial: snapshot.initial, subscription, unsubscribe: subscription.release };
3188
3151
  }
3189
3152
  async writeFile(input) {
3190
3153
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3223,6 +3186,7 @@ export class DaemonClient {
3223
3186
  if (!bytes) {
3224
3187
  throw new Error("File bytes are required.");
3225
3188
  }
3189
+ const uploadTransport = this.transport;
3226
3190
  const resolvedRequestId = this.createRequestId(input.requestId);
3227
3191
  const modifiedAt = input.modifiedAt ?? new Date().toISOString();
3228
3192
  const responsePromise = this.sendCorrelatedRequest({
@@ -3238,29 +3202,51 @@ export class DaemonClient {
3238
3202
  responseType: "file.upload.response",
3239
3203
  options: { skipQueue: true },
3240
3204
  });
3241
- this.sendBinaryFrame(encodeFileTransferFrame({
3242
- opcode: FileTransferOpcode.FileBegin,
3243
- requestId: resolvedRequestId,
3244
- metadata: {
3245
- mime: input.mimeType,
3246
- size: bytes.byteLength,
3247
- encoding: "binary",
3248
- modifiedAt,
3249
- fileName: input.fileName,
3250
- },
3251
- }));
3252
- const chunkSize = input.chunkSize ?? 1024 * 1024;
3253
- for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
3205
+ let settled = false;
3206
+ void responsePromise.then(() => {
3207
+ settled = true;
3208
+ return undefined;
3209
+ }, () => {
3210
+ settled = true;
3211
+ return undefined;
3212
+ });
3213
+ try {
3214
+ this.sendBinaryFrame(encodeFileTransferFrame({
3215
+ opcode: FileTransferOpcode.FileBegin,
3216
+ requestId: resolvedRequestId,
3217
+ metadata: {
3218
+ mime: input.mimeType,
3219
+ size: bytes.byteLength,
3220
+ encoding: "binary",
3221
+ modifiedAt,
3222
+ fileName: input.fileName,
3223
+ },
3224
+ }));
3225
+ const chunkSize = input.chunkSize ?? 128 * 1024;
3226
+ for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
3227
+ // Native WebSocket.send encodes binary synchronously. Let rendering and
3228
+ // incoming messages run between bounded pieces on every platform.
3229
+ await new Promise((resolve) => setTimeout(resolve, 0));
3230
+ if (settled)
3231
+ return await responsePromise;
3232
+ if (this.transport !== uploadTransport || this.connectionState.status !== "connected") {
3233
+ throw new DaemonConnectionError("Connection changed during file upload");
3234
+ }
3235
+ this.sendBinaryFrame(encodeFileTransferFrame({
3236
+ opcode: FileTransferOpcode.FileChunk,
3237
+ requestId: resolvedRequestId,
3238
+ payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)),
3239
+ }));
3240
+ }
3254
3241
  this.sendBinaryFrame(encodeFileTransferFrame({
3255
- opcode: FileTransferOpcode.FileChunk,
3242
+ opcode: FileTransferOpcode.FileEnd,
3256
3243
  requestId: resolvedRequestId,
3257
- payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)),
3258
3244
  }));
3259
3245
  }
3260
- this.sendBinaryFrame(encodeFileTransferFrame({
3261
- opcode: FileTransferOpcode.FileEnd,
3262
- requestId: resolvedRequestId,
3263
- }));
3246
+ catch (error) {
3247
+ this.rejectWaitersForRequestId(resolvedRequestId, error instanceof Error ? error : new Error(String(error)));
3248
+ throw error;
3249
+ }
3264
3250
  return responsePromise;
3265
3251
  }
3266
3252
  async requestDownloadToken(cwd, path, requestId) {
@@ -3364,6 +3350,11 @@ export class DaemonClient {
3364
3350
  });
3365
3351
  }
3366
3352
  async getDaemonStatus(options) {
3353
+ if (!this.lastServerInfoMessage)
3354
+ throw new DaemonConnectionError("Transport not connected");
3355
+ if (this.lastServerInfoMessage?.features?.daemonStatusRpc !== true) {
3356
+ throw new Error("Update the host to read daemon status.");
3357
+ }
3367
3358
  return this.sendCorrelatedSessionRequest({
3368
3359
  requestId: options?.requestId,
3369
3360
  message: {
@@ -3605,18 +3596,21 @@ export class DaemonClient {
3605
3596
  }
3606
3597
  async installPluginSource(input) {
3607
3598
  const requestId = this.createRequestId();
3608
- const reference = parsePluginSourceReference(input.source);
3599
+ // COMPAT(pluginSourceInstallation): added in v0.8.0; remove after 2027-03-16 once daemon floor supports source identifiers.
3600
+ if (this.getLastServerInfoMessage()?.features?.pluginSourceInstallation !== true) {
3601
+ throw new Error("Update the host to install plugin sources.");
3602
+ }
3609
3603
  const payload = await this.sendCorrelatedSessionRequest({
3610
3604
  requestId,
3611
3605
  message: {
3612
3606
  type: "plugin.source.install.request",
3613
3607
  requestId,
3614
- source: reference.source,
3615
- ...(reference.pluginPath ? { pluginPath: reference.pluginPath } : {}),
3608
+ source: input.source,
3616
3609
  ...(input.id ? { id: input.id } : {}),
3617
3610
  ...(input.ref ? { ref: input.ref } : {}),
3618
3611
  },
3619
3612
  responseType: "plugin.source.install.response",
3613
+ timeout: 5 * 60 * 1000,
3620
3614
  });
3621
3615
  return payload.plugin;
3622
3616
  }
@@ -3633,6 +3627,33 @@ export class DaemonClient {
3633
3627
  });
3634
3628
  return payload.plugins;
3635
3629
  }
3630
+ requirePluginUpdates() {
3631
+ // COMPAT(pluginSourceUpdates): added in v0.8.0; remove after 2027-03-16 once daemon floor supports reviewed updates.
3632
+ if (this.getLastServerInfoMessage()?.features?.pluginSourceUpdates !== true)
3633
+ throw new Error("Update the host to review plugin updates.");
3634
+ }
3635
+ async previewPluginUpdates(input = {}) {
3636
+ this.requirePluginUpdates();
3637
+ const requestId = this.createRequestId();
3638
+ const payload = await this.sendCorrelatedSessionRequest({
3639
+ requestId,
3640
+ message: { type: "plugin.source.update.preview.request", requestId, ...input },
3641
+ responseType: "plugin.source.update.preview.response",
3642
+ timeout: 300000,
3643
+ });
3644
+ return payload.plugins;
3645
+ }
3646
+ async applyPluginUpdates(proposals) {
3647
+ this.requirePluginUpdates();
3648
+ const requestId = this.createRequestId();
3649
+ const payload = await this.sendCorrelatedSessionRequest({
3650
+ requestId,
3651
+ message: { type: "plugin.source.update.apply.request", requestId, proposals },
3652
+ responseType: "plugin.source.update.apply.response",
3653
+ timeout: 300000,
3654
+ });
3655
+ return payload.plugins;
3656
+ }
3636
3657
  async updatePluginSources(pluginId) {
3637
3658
  const requestId = this.createRequestId();
3638
3659
  const payload = await this.sendCorrelatedSessionRequest({
@@ -3840,30 +3861,9 @@ export class DaemonClient {
3840
3861
  // ============================================================================
3841
3862
  // Terminals
3842
3863
  // ============================================================================
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
- });
3864
+ observeTerminals(input) {
3865
+ const { signal, ...query } = input;
3866
+ return this.observe("terminals_changed", { type: "subscribe_terminals_request", ...query }, { signal });
3867
3867
  }
3868
3868
  async listTerminals(cwd, requestId, options) {
3869
3869
  const resolvedRequestId = this.createRequestId(requestId);
@@ -3911,45 +3911,72 @@ export class DaemonClient {
3911
3911
  responseType: "terminal.rename.response",
3912
3912
  });
3913
3913
  }
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({
3914
+ /**
3915
+ * Snapshot/restore and output arrive in receive. Subscribe to the returned handle
3916
+ * for terminal_stream_exit: payload.error means observation failure, not PTY exit.
3917
+ * Either outcome detaches this slot and releases the handle automatically.
3918
+ */
3919
+ observeTerminal(terminalId, receive, options) {
3920
+ options?.signal?.throwIfAborted();
3921
+ const observation = this.observe("subscribe_terminal_response", {
3919
3922
  type: "subscribe_terminal_request",
3920
3923
  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 },
3924
+ ...(options?.restore ? { restore: options.restore } : {}),
3925
+ }, options);
3926
+ let detach = () => { };
3927
+ let released = false;
3928
+ const release = () => {
3929
+ released = true;
3930
+ detach();
3931
+ options?.signal?.removeEventListener("abort", abort);
3932
+ return observation.release();
3933
+ };
3934
+ const abort = () => {
3935
+ void release().catch((error) => this.logger.error({ err: error }, "Terminal release failed"));
3936
+ };
3937
+ options?.signal?.addEventListener("abort", abort, { once: true });
3938
+ observation.subscribe({
3939
+ snapshot: (payload) => {
3940
+ detach();
3941
+ if (released || options?.signal?.aborted || payload.error !== null)
3942
+ return;
3943
+ const subscriptionId = payload.subscriptionId;
3944
+ detach = this.terminalStreams.attach(subscriptionId, terminalId, payload.slot, (event) => {
3945
+ if (released || observation.subscriptionId !== subscriptionId)
3946
+ return;
3947
+ try {
3948
+ receive(event);
3949
+ }
3950
+ catch (error) {
3951
+ this.logger.error({ err: error }, "Terminal observer failed");
3952
+ }
3953
+ });
3954
+ },
3955
+ update: (message) => {
3956
+ if (message.type === "terminal_stream_exit") {
3957
+ detach();
3958
+ queueMicrotask(abort);
3959
+ }
3960
+ },
3961
+ error: () => {
3962
+ detach();
3963
+ },
3929
3964
  });
3930
- if (payload.error === null) {
3931
- this.terminalStreams.setSlot(terminalId, payload.slot);
3932
- }
3933
- return payload;
3965
+ return {
3966
+ get subscriptionId() {
3967
+ return observation.subscriptionId;
3968
+ },
3969
+ ready: observation.ready,
3970
+ subscribe: observation.subscribe,
3971
+ release,
3972
+ };
3934
3973
  }
3935
- unsubscribeTerminal(terminalId) {
3936
- this.terminalStreams.removeTerminal(terminalId);
3937
- this.sendSessionMessage({
3938
- type: "unsubscribe_terminal_request",
3939
- terminalId,
3940
- });
3974
+ async subscribeTerminal(terminalId, options) {
3975
+ const subscription = this.observeTerminal(terminalId, () => { }, options);
3976
+ return { ...(await subscription.ready), subscription };
3941
3977
  }
3942
3978
  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
- });
3979
+ this.sendSessionMessage({ type: "terminal_input", terminalId, message });
3953
3980
  }
3954
3981
  async killTerminal(terminalId, requestId) {
3955
3982
  const resolvedRequestId = this.createRequestId(requestId);
@@ -4182,7 +4209,8 @@ export class DaemonClient {
4182
4209
  }
4183
4210
  }
4184
4211
  disposeTransport(code = 1001, reason = "Reconnecting") {
4185
- this.providerSnapshotUpdates.pause();
4212
+ this.owned.disconnected();
4213
+ this.providerSnapshotUpdates.clear();
4186
4214
  this.stopLivenessHeartbeat();
4187
4215
  this.cleanupTransport();
4188
4216
  if (this.transport) {
@@ -4223,10 +4251,12 @@ export class DaemonClient {
4223
4251
  if (typeof Blob !== "undefined" &&
4224
4252
  rawData instanceof Blob &&
4225
4253
  typeof rawData.arrayBuffer === "function") {
4254
+ const transport = this.transport;
4226
4255
  void rawData
4227
4256
  .arrayBuffer()
4228
4257
  .then((buffer) => {
4229
- this.handleTransportMessage(buffer);
4258
+ if (this.transport === transport)
4259
+ this.handleTransportMessage(buffer);
4230
4260
  return;
4231
4261
  })
4232
4262
  .catch(() => {
@@ -4455,12 +4485,13 @@ export class DaemonClient {
4455
4485
  if (typeof reason === "string" && reason.trim().length > 0) {
4456
4486
  this.lastErrorValue = reason.trim();
4457
4487
  }
4458
- this.providerSnapshotUpdates.pause();
4488
+ this.owned.disconnected();
4489
+ this.providerSnapshotUpdates.clear();
4459
4490
  // Clear all pending waiters and queued sends since the connection was lost
4460
4491
  // 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"));
4492
+ this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
4493
+ this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
4494
+ this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost"));
4464
4495
  this.terminalStreams.clearSlots();
4465
4496
  this.lastServerInfoMessage = null;
4466
4497
  if (wasDisposed) {
@@ -4539,13 +4570,13 @@ export class DaemonClient {
4539
4570
  });
4540
4571
  }
4541
4572
  handleSessionMessage(msg) {
4573
+ msg = this.owned.normalize(msg);
4542
4574
  if (msg.type === "providers_snapshot_update" &&
4543
4575
  this.config.providerSnapshots !== "wire" &&
4544
4576
  msg.payload.snapshotHash &&
4545
4577
  !msg.payload.compactSnapshot) {
4546
- if (this.messageHandlers.has(msg.type) || this.eventListeners.size > 0) {
4578
+ if (this.owned.owns(msg))
4547
4579
  this.providerSnapshotUpdates.receive(msg);
4548
- }
4549
4580
  return;
4550
4581
  }
4551
4582
  this.deliverSessionMessage(msg);
@@ -4561,25 +4592,16 @@ export class DaemonClient {
4561
4592
  this.reconnectAttempt = 0;
4562
4593
  this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
4563
4594
  this.startLivenessHeartbeat();
4564
- this.subscriptions.restore();
4565
- this.providerSnapshotUpdates.resume();
4566
- this.resubscribeCheckoutDiffSubscriptions();
4567
- this.resubscribeTerminalDirectorySubscriptions();
4568
- this.resubscribeFileSubscriptions();
4595
+ this.owned.restore(serverInfo, {
4596
+ ...DEFAULT_CLIENT_CAPABILITIES,
4597
+ ...this.config.capabilities,
4598
+ });
4569
4599
  this.creations.reconnect();
4570
4600
  this.flushPendingSendQueue();
4571
4601
  this.resolveConnect();
4572
4602
  }
4573
4603
  }
4574
4604
  }
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
4605
  if (this.rawMessageListeners.size > 0) {
4584
4606
  for (const handler of this.rawMessageListeners) {
4585
4607
  try {
@@ -4608,20 +4630,30 @@ export class DaemonClient {
4608
4630
  }
4609
4631
  }
4610
4632
  if (consumerMessage.type === "workspace.create.update" ||
4611
- consumerMessage.type === "agent.create.update")
4612
- this.creations.receive(consumerMessage.payload);
4633
+ consumerMessage.type === "agent.create.update") {
4634
+ if (!consumerMessage.payload.subscriptionId)
4635
+ this.creations.receive(consumerMessage.payload);
4636
+ }
4613
4637
  this.resolveWaiters(consumerMessage);
4638
+ this.owned.receive(consumerMessage);
4614
4639
  }
4615
4640
  resolveWaiters(msg) {
4616
4641
  for (const waiter of Array.from(this.waiters)) {
4617
- const result = waiter.predicate(msg);
4618
- if (result !== null) {
4642
+ try {
4643
+ const result = waiter.predicate(msg);
4644
+ if (result === null)
4645
+ continue;
4619
4646
  this.waiters.delete(waiter);
4620
- if (waiter.timeoutHandle) {
4647
+ if (waiter.timeoutHandle)
4621
4648
  clearTimeout(waiter.timeoutHandle);
4622
- }
4623
4649
  waiter.resolve(result);
4624
4650
  }
4651
+ catch (error) {
4652
+ this.waiters.delete(waiter);
4653
+ if (waiter.timeoutHandle)
4654
+ clearTimeout(waiter.timeoutHandle);
4655
+ waiter.reject(error instanceof Error ? error : new Error(String(error)));
4656
+ }
4625
4657
  }
4626
4658
  }
4627
4659
  rejectWaitersForRequestId(requestId, error) {
@@ -4704,7 +4736,7 @@ export class DaemonClient {
4704
4736
  }
4705
4737
  waitForWithCancel(predicate, timeout = 30000, options) {
4706
4738
  // Capture stack trace at call site, not inside setTimeout
4707
- const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`);
4739
+ const timeoutError = new DaemonConnectionError(`Timeout waiting for message (${timeout}ms)`, "DAEMON_REQUEST_TIMEOUT");
4708
4740
  let waiter = null;
4709
4741
  let settled = false;
4710
4742
  let rejectFn = null;