@xmanrui/dsh-im 3.0.7 → 3.1.0

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.
Files changed (48) hide show
  1. package/README.en.md +25 -2
  2. package/README.md +25 -2
  3. package/THIRD_PARTY_NOTICES.md +20 -0
  4. package/lib/client.js +542 -47
  5. package/lib/index.js +232 -225
  6. package/package.json +3 -2
  7. package/plugin-src/client/i18n.js +56 -0
  8. package/plugin-src/client/index.js +19 -2
  9. package/plugin-src/client/styles.js +34 -1
  10. package/plugin-src/client/update-panel.js +367 -0
  11. package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
  12. package/plugin-src/host/channels/dingtalk/production.mjs +3 -11
  13. package/plugin-src/host/channels/discord/index.mjs +1 -1
  14. package/plugin-src/host/channels/feishu/index.mjs +1 -1
  15. package/plugin-src/host/channels/feishu/production.mjs +3 -13
  16. package/plugin-src/host/channels/office/production.mjs +3 -3
  17. package/plugin-src/host/channels/qq/index.mjs +1 -1
  18. package/plugin-src/host/channels/qq/production.mjs +3 -11
  19. package/plugin-src/host/channels/shared/production.mjs +3 -11
  20. package/plugin-src/host/channels/slack/index.mjs +1 -1
  21. package/plugin-src/host/channels/slack/production.mjs +4 -3
  22. package/plugin-src/host/channels/telegram/index.mjs +1 -1
  23. package/plugin-src/host/channels/wecom/index.mjs +1 -1
  24. package/plugin-src/host/channels/wecom/production.mjs +3 -11
  25. package/plugin-src/host/channels/weixin/index.mjs +1 -1
  26. package/plugin-src/host/channels/weixin/production.mjs +3 -11
  27. package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
  28. package/plugin-src/host/channels/whatsapp/production.mjs +3 -11
  29. package/plugin-src/host/harness-connection.mjs +15 -0
  30. package/plugin-src/host/index.mjs +10 -1
  31. package/plugin-src/host/update-rpc.mjs +50 -0
  32. package/plugin-src/host/update-runtime.mjs +338 -0
  33. package/plugin-src/host/update-service.mjs +385 -0
  34. package/scripts/verify-package.mjs +12 -0
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +5 -1
  36. package/src/channels/feishu/bridge.mjs +11 -3
  37. package/src/channels/feishu/feishu-cards.mjs +2 -0
  38. package/src/channels/qq/qq-bridge.mjs +5 -1
  39. package/src/channels/shared/bot-workspace-store.mjs +3 -0
  40. package/src/channels/shared/harness-client.mjs +252 -281
  41. package/src/channels/shared/harness-mux.mjs +123 -0
  42. package/src/channels/shared/history-command.mjs +193 -0
  43. package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
  44. package/src/channels/shared/i18n-en/shared-c.mjs +40 -0
  45. package/src/channels/shared/text-harness-bridge.mjs +5 -1
  46. package/src/channels/shared/workspace-command.mjs +1 -0
  47. package/src/channels/wecom/wecom-bridge.mjs +5 -1
  48. package/src/channels/weixin/weixin-bridge.mjs +5 -1
@@ -9,11 +9,14 @@ import {
9
9
  } from './inbound-file.mjs';
10
10
  import { outboundArtifactRegistry } from './semantic/artifact.mjs';
11
11
  import { t } from './i18n.mjs';
12
+ import { watchHarnessMux } from './harness-mux.mjs';
12
13
 
13
14
  // Every channel plugin runs in the same Host process. Sharing ownership by
14
- // Harness origin prevents two channel-specific clients bound to one Session
15
+ // Host identity (or an explicitly configured HTTP origin) prevents clients
16
+ // bound to one Session
15
17
  // from claiming or cancelling each other's interactions.
16
18
  const interactionRegistries = new Map();
19
+ const hostInteractionRegistries = new WeakMap();
17
20
  const MAX_ERROR_CLASSIFICATION_BYTES = 64;
18
21
 
19
22
  async function smallResponseText(response) {
@@ -74,8 +77,9 @@ async function harnessHttpErrorCode(response, hostname) {
74
77
  return 'harness-http-failed';
75
78
  }
76
79
 
77
- function interactionRegistry(origin) {
78
- let registry = interactionRegistries.get(origin);
80
+ function interactionRegistry(scope) {
81
+ const registries = typeof scope === 'string' ? interactionRegistries : hostInteractionRegistries;
82
+ let registry = registries.get(scope);
79
83
  if (!registry) {
80
84
  registry = {
81
85
  ownerships: new Map(),
@@ -83,11 +87,38 @@ function interactionRegistry(origin) {
83
87
  controls: new WeakMap(),
84
88
  nextOrder: 0,
85
89
  };
86
- interactionRegistries.set(origin, registry);
90
+ registries.set(scope, registry);
87
91
  }
88
92
  return registry;
89
93
  }
90
94
 
95
+ // A Host RPC may not accept cancellation itself. Bound the caller's wait
96
+ // without retrying an operation that the Host may already have accepted.
97
+ function callWithSignal(call, signal) {
98
+ return new Promise((resolve, reject) => {
99
+ const cleanup = () => signal.removeEventListener('abort', handleAbort);
100
+ const handleAbort = () => {
101
+ cleanup();
102
+ reject(signal.reason);
103
+ };
104
+ if (signal.aborted) {
105
+ handleAbort();
106
+ return;
107
+ }
108
+ signal.addEventListener('abort', handleAbort, { once: true });
109
+ Promise.resolve().then(() => {
110
+ signal.throwIfAborted();
111
+ return call();
112
+ }).then((value) => {
113
+ cleanup();
114
+ resolve(value);
115
+ }, (error) => {
116
+ cleanup();
117
+ reject(error);
118
+ });
119
+ });
120
+ }
121
+
91
122
  function normalizeControl(control) {
92
123
  const ownerType = typeof control?.owner;
93
124
  if ((ownerType !== 'object' && ownerType !== 'function')
@@ -569,6 +600,7 @@ function harnessTurnSucceeded(reason) {
569
600
 
570
601
  export class HarnessClient {
571
602
  #baseUrl;
603
+ #apiProxy;
572
604
  #workspace;
573
605
  #agentPreset;
574
606
  #autostart;
@@ -590,6 +622,8 @@ export class HarnessClient {
590
622
 
591
623
  constructor({
592
624
  baseUrl,
625
+ apiProxy,
626
+ interactionScope = apiProxy,
593
627
  workspace,
594
628
  agentPreset,
595
629
  autostart = false,
@@ -629,11 +663,19 @@ export class HarnessClient {
629
663
  if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
630
664
  throw new TypeError('fileIngressExecutor must be a function');
631
665
  }
632
- this.#baseUrl = new URL(baseUrl);
666
+ this.#baseUrl = baseUrl === undefined ? null : new URL(baseUrl);
667
+ this.#apiProxy = this.#baseUrl ? null : apiProxy;
668
+ if (!this.#baseUrl && (!this.#apiProxy || typeof this.#apiProxy !== 'object')) {
669
+ throw new TypeError('HarnessClient requires the current Host apiProxy or an explicit baseUrl');
670
+ }
671
+ if (this.#apiProxy && (!interactionScope
672
+ || !['object', 'function'].includes(typeof interactionScope))) {
673
+ throw new TypeError('interactionScope must identify the current Host');
674
+ }
633
675
  this.#workspace = workspace;
634
676
  // Keep an omitted preset absent so session.create resolves the Host's current default.
635
677
  this.#agentPreset = agentPreset ?? undefined;
636
- this.#autostart = autostart;
678
+ this.#autostart = Boolean(this.#baseUrl && autostart);
637
679
  this.#dshBin = dshBin;
638
680
  this.#fetch = fetchImpl;
639
681
  this.#createWebSocket = createWebSocket;
@@ -644,7 +686,7 @@ export class HarnessClient {
644
686
  this.#controlExecutor = controlExecutor;
645
687
  this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
646
688
  this.#fileIngressExecutor = fileIngressExecutor;
647
- this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
689
+ this.#interactionRegistry = interactionRegistry(this.#baseUrl?.origin ?? interactionScope);
648
690
  this.#interactionOwnerships = this.#interactionRegistry.ownerships;
649
691
  this.#interactionClaims = this.#interactionRegistry.claims;
650
692
  this.#controlOwnerships = this.#interactionRegistry.controls;
@@ -656,34 +698,46 @@ export class HarnessClient {
656
698
  const signal = options.signal
657
699
  ? AbortSignal.any([options.signal, timeoutSignal])
658
700
  : timeoutSignal;
659
- let response;
701
+ let body;
660
702
  try {
661
- response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
662
- method: 'POST',
663
- headers: { 'content-type': 'application/json' },
664
- body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
665
- signal,
666
- });
703
+ if (this.#apiProxy) {
704
+ const [domain, action, extra] = method.split('.');
705
+ const namespace = { host: 'host', workspace: 'workspace', session: 'sessions', llm: 'llm' }[domain];
706
+ const api = namespace && this.#apiProxy[namespace];
707
+ if (extra !== undefined || !Object.hasOwn(api ?? {}, action)
708
+ || typeof api[action] !== 'function') {
709
+ throw new HarnessTransportError('harness-api-not-found', method);
710
+ }
711
+ const response = await callWithSignal(() => api[action]({ rpcId, payload }, signal), signal);
712
+ body = { type: 'server-response', ...response };
713
+ } else {
714
+ const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
715
+ method: 'POST',
716
+ headers: { 'content-type': 'application/json' },
717
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
718
+ signal,
719
+ });
720
+ if (!response.ok) {
721
+ const code = await harnessHttpErrorCode(response, this.#baseUrl.hostname);
722
+ throw new HarnessTransportError(code, method, { status: response.status });
723
+ }
724
+ try {
725
+ body = await response.json();
726
+ } catch (error) {
727
+ throw new HarnessTransportError('harness-response-invalid', method, { cause: error });
728
+ }
729
+ }
667
730
  } catch (error) {
668
731
  // Preserve an explicit caller cancellation; it is control flow, not a
669
732
  // Harness availability diagnosis.
670
733
  if (options.signal?.aborted) throw error;
734
+ if (error instanceof HarnessTransportError) throw error;
671
735
  throw new HarnessTransportError(
672
736
  timeoutSignal.aborted ? 'harness-timeout' : 'harness-connect-failed',
673
737
  method,
674
738
  { cause: error },
675
739
  );
676
740
  }
677
- if (!response.ok) {
678
- const code = await harnessHttpErrorCode(response, this.#baseUrl.hostname);
679
- throw new HarnessTransportError(code, method, { status: response.status });
680
- }
681
- let body;
682
- try {
683
- body = await response.json();
684
- } catch (error) {
685
- throw new HarnessTransportError('harness-response-invalid', method, { cause: error });
686
- }
687
741
  if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
688
742
  throw new HarnessTransportError('harness-response-invalid', method, {
689
743
  cause: new Error(`Harness returned an invalid response for ${method}`),
@@ -857,6 +911,19 @@ export class HarnessClient {
857
911
  }
858
912
  }
859
913
 
914
+ async readSessionHistory(sessionId, { maxMessages = 50, beforeSeq, timeoutMs = 10_000, ...options } = {}) {
915
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
916
+ if (!Number.isSafeInteger(maxMessages) || maxMessages < 1
917
+ || (beforeSeq !== undefined && (!Number.isSafeInteger(beforeSeq) || beforeSeq < 0))) {
918
+ throw new TypeError('Invalid history pagination');
919
+ }
920
+ return this.rpc('session.history', {
921
+ sessionId,
922
+ maxMessages,
923
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
924
+ }, timeoutMs, options);
925
+ }
926
+
860
927
  async sessionExists(sessionId, options = {}) {
861
928
  try {
862
929
  await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
@@ -876,16 +943,22 @@ export class HarnessClient {
876
943
  const signal = options.signal
877
944
  ? AbortSignal.any([options.signal, timeoutSignal])
878
945
  : timeoutSignal;
879
- const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), {
880
- method: 'POST',
881
- headers: { 'content-type': 'application/json' },
882
- body: JSON.stringify({ type: 'client-response', rpcId, result }),
883
- signal,
884
- });
885
- if (!response.ok) {
886
- throw new Error(`Harness transport respond failed: HTTP ${response.status}`);
946
+ const envelope = { type: 'client-response', rpcId, result };
947
+ let receipt;
948
+ if (this.#apiProxy) {
949
+ receipt = await callWithSignal(() => this.#apiProxy.respond(envelope), signal);
950
+ } else {
951
+ const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), {
952
+ method: 'POST',
953
+ headers: { 'content-type': 'application/json' },
954
+ body: JSON.stringify(envelope),
955
+ signal,
956
+ });
957
+ if (!response.ok) {
958
+ throw new Error(`Harness transport respond failed: HTTP ${response.status}`);
959
+ }
960
+ receipt = await response.json();
887
961
  }
888
- const receipt = await response.json();
889
962
  if (receipt?.accepted === true) return receipt;
890
963
  if (receipt?.accepted !== false
891
964
  || (receipt.reason !== 'bad-response' && receipt.reason !== 'not-pending')) {
@@ -921,7 +994,7 @@ export class HarnessClient {
921
994
 
922
995
  while (!signal.aborted) {
923
996
  try {
924
- await this.#watchInteractionSocket(sessionId, {
997
+ await this.#watchInteractionStream(sessionId, {
925
998
  signal,
926
999
  onInteraction,
927
1000
  onResolved,
@@ -1360,191 +1433,135 @@ export class HarnessClient {
1360
1433
  }
1361
1434
  }
1362
1435
 
1363
- #watchInteractionSocket(sessionId, {
1436
+ async #watchInteractionStream(sessionId, {
1364
1437
  signal,
1365
1438
  onInteraction,
1366
1439
  onResolved,
1367
1440
  onOpen,
1368
1441
  ownership,
1369
1442
  }) {
1370
- const url = new URL('/api/events.mux', this.#baseUrl);
1371
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1372
-
1373
- return new Promise((resolve, reject) => {
1374
- let socket;
1443
+ let settled = false;
1444
+ let callbackFailure = null;
1445
+ let callbackTail = Promise.resolve();
1446
+ let ownershipReady = ownership === undefined || ownership === null;
1447
+ const bufferedEnvelopes = [];
1448
+ let close = () => {};
1449
+ const handleOpen = (closeStream) => {
1450
+ close = closeStream;
1451
+ if (ownership) ownership.reconnect = close;
1375
1452
  try {
1376
- socket = this.#createWebSocket(url.toString());
1453
+ onOpen?.();
1377
1454
  } catch (error) {
1378
- reject(error);
1455
+ console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message);
1456
+ }
1457
+ if (ownership) {
1458
+ void this.#refreshInteractionOwnerships(sessionId, signal).then(() => {
1459
+ if (settled) return;
1460
+ ownershipReady = true;
1461
+ for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope);
1462
+ }).catch((error) => {
1463
+ callbackFailure ??= error;
1464
+ close();
1465
+ });
1466
+ }
1467
+ };
1468
+ const dispatch = (callback, value) => {
1469
+ if (!callback) return;
1470
+ callbackTail = callbackTail
1471
+ .then(() => callback(value))
1472
+ .catch((error) => {
1473
+ callbackFailure ??= error;
1474
+ close();
1475
+ });
1476
+ };
1477
+ const processEnvelope = (envelope) => {
1478
+ const payload = envelope.payload;
1479
+ if (ownership && payload.type === 'session/event') {
1480
+ this.#consumeInteractionOwnerships(sessionId, [payload.event]);
1379
1481
  return;
1380
1482
  }
1381
- let opened = false;
1382
- let settled = false;
1383
- let callbackFailure = null;
1384
- let callbackTail = Promise.resolve();
1385
- let ownershipReady = ownership === undefined || ownership === null;
1386
- const bufferedEnvelopes = [];
1387
- const finish = (error) => {
1388
- if (settled) return;
1389
- settled = true;
1390
- socket.removeEventListener('open', handleOpen);
1391
- socket.removeEventListener('message', handleMessage);
1392
- socket.removeEventListener('close', handleClose);
1393
- socket.removeEventListener('error', handleError);
1394
- signal.removeEventListener('abort', handleAbort);
1395
- if (ownership?.reconnect === close) ownership.reconnect = null;
1396
- if (signal.aborted) {
1397
- resolve();
1398
- return;
1399
- }
1400
- void callbackTail.then(() => {
1401
- const failure = error ?? callbackFailure;
1402
- if (failure) reject(failure);
1403
- else resolve();
1404
- }, reject);
1405
- };
1406
- const close = () => {
1407
- try {
1408
- if (socket.readyState === 0 || socket.readyState === 1) socket.close();
1409
- } catch {
1410
- // Cleanup must still settle the watcher if a WebSocket rejects close while connecting.
1411
- }
1412
- };
1413
- const handleOpen = () => {
1414
- opened = true;
1415
- if (ownership) ownership.reconnect = close;
1416
- try {
1417
- onOpen?.();
1418
- } catch (error) {
1419
- console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message);
1420
- }
1483
+ if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
1484
+ const kind = payload.type === 'question/requested' ? 'question' : 'approval';
1485
+ const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId;
1486
+ const claimKey = `${kind}:${interactionId}`;
1421
1487
  if (ownership) {
1422
- void this.#refreshInteractionOwnerships(sessionId, signal).then(() => {
1423
- if (settled) return;
1424
- ownershipReady = true;
1425
- for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope);
1426
- }).catch((error) => {
1427
- callbackFailure ??= error;
1428
- close();
1429
- finish(error);
1430
- });
1431
- }
1432
- };
1433
- const dispatch = (callback, value) => {
1434
- if (!callback) return;
1435
- callbackTail = callbackTail
1436
- .then(() => callback(value))
1437
- .catch((error) => {
1438
- callbackFailure ??= error;
1439
- close();
1440
- finish(callbackFailure);
1441
- });
1442
- };
1443
- const processEnvelope = (envelope) => {
1444
- const payload = envelope.payload;
1445
- if (ownership && payload.type === 'session/event') {
1446
- this.#consumeInteractionOwnerships(sessionId, [payload.event]);
1447
- return;
1448
- }
1449
- if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
1450
- const kind = payload.type === 'question/requested' ? 'question' : 'approval';
1451
- const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId;
1452
- const claimKey = `${kind}:${interactionId}`;
1453
- if (ownership) {
1454
- const claim = this.#interactionOwner(sessionId, claimKey, kind);
1455
- if (claim?.ownership !== ownership) return;
1456
- this.#interactionClaims.set(claimKey, claim);
1457
- }
1458
- const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
1459
- ? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
1460
- : undefined;
1461
- dispatch(onInteraction, Object.freeze({
1462
- kind,
1463
- interactionId,
1464
- rpcId: envelope.rpcId,
1465
- sessionId,
1466
- payload,
1467
- recovered: ownership
1468
- ? this.#interactionClaims.get(claimKey)?.recovered === true
1469
- : false,
1470
- ...(toolCall ? { toolCall } : {}),
1471
- reconnect: close,
1472
- respond: (result, options = {}) => this.respondInteraction(
1473
- envelope.rpcId,
1474
- result,
1475
- { ...options, signal: options.signal ?? signal },
1476
- ),
1477
- }));
1478
- return;
1488
+ const claim = this.#interactionOwner(sessionId, claimKey, kind);
1489
+ if (claim?.ownership !== ownership) return;
1490
+ this.#interactionClaims.set(claimKey, claim);
1479
1491
  }
1480
- if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') {
1481
- const kind = payload.type === 'question/resolved' ? 'question' : 'approval';
1482
- const interactionId = kind === 'question'
1483
- ? payload.questionRpcId
1484
- : payload.approvalId;
1485
- const claimKey = `${kind}:${interactionId}`;
1486
- if (ownership) {
1487
- const claim = this.#interactionClaims.get(claimKey);
1488
- if (claim?.ownership !== ownership) return;
1489
- this.#interactionClaims.delete(claimKey);
1490
- }
1491
- dispatch(onResolved, Object.freeze({
1492
- kind,
1493
- interactionId,
1494
- sessionId,
1495
- outcome: payload.outcome,
1496
- payload,
1497
- }));
1492
+ const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
1493
+ ? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
1494
+ : undefined;
1495
+ dispatch(onInteraction, Object.freeze({
1496
+ kind,
1497
+ interactionId,
1498
+ rpcId: envelope.rpcId,
1499
+ sessionId,
1500
+ payload,
1501
+ recovered: ownership
1502
+ ? this.#interactionClaims.get(claimKey)?.recovered === true
1503
+ : false,
1504
+ ...(toolCall ? { toolCall } : {}),
1505
+ reconnect: close,
1506
+ respond: (result, options = {}) => this.respondInteraction(
1507
+ envelope.rpcId,
1508
+ result,
1509
+ { ...options, signal: options.signal ?? signal },
1510
+ ),
1511
+ }));
1512
+ return;
1513
+ }
1514
+ if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') {
1515
+ const kind = payload.type === 'question/resolved' ? 'question' : 'approval';
1516
+ const interactionId = kind === 'question'
1517
+ ? payload.questionRpcId
1518
+ : payload.approvalId;
1519
+ const claimKey = `${kind}:${interactionId}`;
1520
+ if (ownership) {
1521
+ const claim = this.#interactionClaims.get(claimKey);
1522
+ if (claim?.ownership !== ownership) return;
1523
+ this.#interactionClaims.delete(claimKey);
1498
1524
  }
1499
- };
1500
- const handleMessage = (event) => {
1501
- try {
1502
- if (typeof event.data !== 'string') throw new Error('binary WebSocket frame');
1503
- const envelope = JSON.parse(event.data);
1504
- const payload = envelope?.payload;
1505
- if (envelope?.type !== 'server-request'
1506
- || typeof envelope.rpcId !== 'string'
1507
- || !payload || typeof payload !== 'object'
1508
- || envelope.method !== payload.type) {
1509
- throw new Error('invalid server-request envelope');
1510
- }
1511
- if (payload.sessionId !== sessionId) return;
1512
- if (!ownershipReady) bufferedEnvelopes.push(envelope);
1513
- else processEnvelope(envelope);
1514
- } catch (error) {
1515
- console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message);
1525
+ dispatch(onResolved, Object.freeze({
1526
+ kind,
1527
+ interactionId,
1528
+ sessionId,
1529
+ outcome: payload.outcome,
1530
+ payload,
1531
+ }));
1532
+ }
1533
+ };
1534
+ const handleEnvelope = (envelope) => {
1535
+ try {
1536
+ const payload = envelope?.payload;
1537
+ if (envelope?.type !== 'server-request'
1538
+ || typeof envelope.rpcId !== 'string'
1539
+ || !payload || typeof payload !== 'object'
1540
+ || envelope.method !== payload.type) {
1541
+ throw new Error('invalid server-request envelope');
1516
1542
  }
1517
- };
1518
- const handleClose = () => finish(opened ? null : new Error(
1519
- 'Harness interaction WebSocket closed before opening',
1520
- ));
1521
- const handleError = () => {
1522
- finish(new Error(opened
1523
- ? 'Harness interaction WebSocket failed'
1524
- : 'Harness interaction WebSocket failed before opening'));
1525
- close();
1526
- };
1527
- const handleAbort = () => {
1528
- close();
1529
- finish();
1530
- };
1531
-
1532
- socket.addEventListener('open', handleOpen);
1533
- socket.addEventListener('message', handleMessage);
1534
- socket.addEventListener('close', handleClose, { once: true });
1535
- socket.addEventListener('error', handleError, { once: true });
1536
- signal.addEventListener('abort', handleAbort, { once: true });
1537
- if (signal.aborted) handleAbort();
1538
- });
1543
+ if (payload.sessionId !== sessionId) return;
1544
+ if (!ownershipReady) bufferedEnvelopes.push(envelope);
1545
+ else processEnvelope(envelope);
1546
+ } catch (error) {
1547
+ console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message);
1548
+ }
1549
+ };
1550
+ try {
1551
+ await this.#watchMux({ signal, onOpen: handleOpen, onEnvelope: handleEnvelope });
1552
+ } finally {
1553
+ settled = true;
1554
+ if (ownership?.reconnect === close) ownership.reconnect = null;
1555
+ if (!signal.aborted) await callbackTail;
1556
+ }
1557
+ if (!signal.aborted && callbackFailure) throw callbackFailure;
1539
1558
  }
1540
1559
 
1541
1560
  /**
1542
1561
  * Watch the global Harness event mux (all sessions) until `signal`
1543
- * aborts, reconnecting on drop. The Desktop host serves the mux as a
1544
- * WebSocket downlink; frames are `server-request` envelopes whose payload
1545
- * is a `session/event` only those are forwarded. `onReconnect` (when
1546
- * provided) fires after every (re)connection so callers can compensate
1547
- * for events missed while offline.
1562
+ * aborts, reconnecting on drop. Both transports deliver the same envelopes;
1563
+ * only session/event payloads are forwarded. onReconnect fires after every
1564
+ * (re)connection so callers can compensate for events missed while offline.
1548
1565
  */
1549
1566
  async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
1550
1567
  if (typeof onSessionEvent !== 'function') {
@@ -1556,14 +1573,33 @@ export class HarnessClient {
1556
1573
  if (onReconnect !== undefined && typeof onReconnect !== 'function') {
1557
1574
  throw new TypeError('onReconnect must be a function');
1558
1575
  }
1559
- const url = new URL('/api/events.mux', this.#baseUrl);
1560
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1561
1576
  while (!signal.aborted) {
1562
1577
  try {
1563
- await this.#watchHarnessEventSocket(url.toString(), {
1578
+ await this.#watchMux({
1564
1579
  signal,
1565
- onSessionEvent,
1566
- onReconnect,
1580
+ onOpen: () => {
1581
+ try {
1582
+ onReconnect?.();
1583
+ } catch (error) {
1584
+ console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
1585
+ }
1586
+ },
1587
+ onEnvelope: (envelope) => {
1588
+ try {
1589
+ const payload = envelope?.payload;
1590
+ if (envelope?.type !== 'server-request'
1591
+ || !payload
1592
+ || typeof payload !== 'object'
1593
+ || envelope.method !== payload.type
1594
+ || payload.type !== 'session/event'
1595
+ || typeof payload.sessionId !== 'string'
1596
+ || !payload.event
1597
+ || typeof payload.event !== 'object') return;
1598
+ onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
1599
+ } catch (error) {
1600
+ console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
1601
+ }
1602
+ },
1567
1603
  });
1568
1604
  } catch (error) {
1569
1605
  if (signal.aborted) return;
@@ -1579,81 +1615,16 @@ export class HarnessClient {
1579
1615
  }
1580
1616
  }
1581
1617
 
1582
- #watchHarnessEventSocket(url, { signal, onSessionEvent, onReconnect }) {
1583
- return new Promise((resolve, reject) => {
1584
- let socket;
1585
- try {
1586
- socket = this.#createWebSocket(url);
1587
- } catch (error) {
1588
- reject(error);
1589
- return;
1590
- }
1591
- let opened = false;
1592
- let finished = false;
1593
- const close = () => {
1594
- try {
1595
- socket.close();
1596
- } catch {
1597
- // Already closed.
1598
- }
1599
- };
1600
- const finish = (error) => {
1601
- if (finished) return;
1602
- finished = true;
1603
- socket.removeEventListener('open', handleOpen);
1604
- socket.removeEventListener('message', handleMessage);
1605
- socket.removeEventListener('close', handleClose);
1606
- socket.removeEventListener('error', handleError);
1607
- signal.removeEventListener('abort', handleAbort);
1608
- if (error) reject(error);
1609
- else resolve();
1610
- };
1611
- const handleOpen = () => {
1612
- opened = true;
1613
- try {
1614
- onReconnect?.();
1615
- } catch (error) {
1616
- console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
1617
- }
1618
- };
1619
- const handleMessage = (event) => {
1620
- try {
1621
- if (typeof event.data !== 'string') return;
1622
- const envelope = JSON.parse(event.data);
1623
- const payload = envelope?.payload;
1624
- if (envelope?.type !== 'server-request'
1625
- || !payload
1626
- || typeof payload !== 'object'
1627
- || envelope.method !== payload.type
1628
- || payload.type !== 'session/event'
1629
- || typeof payload.sessionId !== 'string'
1630
- || !payload.event
1631
- || typeof payload.event !== 'object') return;
1632
- onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
1633
- } catch (error) {
1634
- console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
1635
- }
1636
- };
1637
- const handleClose = () => finish(opened ? null : new Error(
1638
- 'Harness event mux WebSocket closed before opening',
1639
- ));
1640
- const handleError = () => {
1641
- finish(new Error(opened
1642
- ? 'Harness event mux WebSocket failed'
1643
- : 'Harness event mux WebSocket failed before opening'));
1644
- close();
1645
- };
1646
- const handleAbort = () => {
1647
- close();
1648
- finish();
1649
- };
1650
-
1651
- socket.addEventListener('open', handleOpen);
1652
- socket.addEventListener('message', handleMessage);
1653
- socket.addEventListener('close', handleClose, { once: true });
1654
- socket.addEventListener('error', handleError, { once: true });
1655
- signal.addEventListener('abort', handleAbort, { once: true });
1656
- if (signal.aborted) handleAbort();
1618
+ #watchMux(options) {
1619
+ return watchHarnessMux({
1620
+ apiProxy: this.#apiProxy,
1621
+ baseUrl: this.#baseUrl,
1622
+ createWebSocket: this.#createWebSocket,
1623
+ rpcId: `${this.#rpcIdPrefix}-${randomUUID()}`,
1624
+ ...options,
1625
+ onMalformed: (error) => {
1626
+ console.warn(`[${this.#logPrefix}] ignored a malformed Harness mux frame:`, error.message);
1627
+ },
1657
1628
  });
1658
1629
  }
1659
1630