@xmanrui/dsh-im 3.0.7 → 3.0.8

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.
@@ -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}`),
@@ -876,16 +930,22 @@ export class HarnessClient {
876
930
  const signal = options.signal
877
931
  ? AbortSignal.any([options.signal, timeoutSignal])
878
932
  : 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}`);
933
+ const envelope = { type: 'client-response', rpcId, result };
934
+ let receipt;
935
+ if (this.#apiProxy) {
936
+ receipt = await callWithSignal(() => this.#apiProxy.respond(envelope), signal);
937
+ } else {
938
+ const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), {
939
+ method: 'POST',
940
+ headers: { 'content-type': 'application/json' },
941
+ body: JSON.stringify(envelope),
942
+ signal,
943
+ });
944
+ if (!response.ok) {
945
+ throw new Error(`Harness transport respond failed: HTTP ${response.status}`);
946
+ }
947
+ receipt = await response.json();
887
948
  }
888
- const receipt = await response.json();
889
949
  if (receipt?.accepted === true) return receipt;
890
950
  if (receipt?.accepted !== false
891
951
  || (receipt.reason !== 'bad-response' && receipt.reason !== 'not-pending')) {
@@ -921,7 +981,7 @@ export class HarnessClient {
921
981
 
922
982
  while (!signal.aborted) {
923
983
  try {
924
- await this.#watchInteractionSocket(sessionId, {
984
+ await this.#watchInteractionStream(sessionId, {
925
985
  signal,
926
986
  onInteraction,
927
987
  onResolved,
@@ -1360,191 +1420,135 @@ export class HarnessClient {
1360
1420
  }
1361
1421
  }
1362
1422
 
1363
- #watchInteractionSocket(sessionId, {
1423
+ async #watchInteractionStream(sessionId, {
1364
1424
  signal,
1365
1425
  onInteraction,
1366
1426
  onResolved,
1367
1427
  onOpen,
1368
1428
  ownership,
1369
1429
  }) {
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;
1430
+ let settled = false;
1431
+ let callbackFailure = null;
1432
+ let callbackTail = Promise.resolve();
1433
+ let ownershipReady = ownership === undefined || ownership === null;
1434
+ const bufferedEnvelopes = [];
1435
+ let close = () => {};
1436
+ const handleOpen = (closeStream) => {
1437
+ close = closeStream;
1438
+ if (ownership) ownership.reconnect = close;
1375
1439
  try {
1376
- socket = this.#createWebSocket(url.toString());
1440
+ onOpen?.();
1377
1441
  } catch (error) {
1378
- reject(error);
1442
+ console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message);
1443
+ }
1444
+ if (ownership) {
1445
+ void this.#refreshInteractionOwnerships(sessionId, signal).then(() => {
1446
+ if (settled) return;
1447
+ ownershipReady = true;
1448
+ for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope);
1449
+ }).catch((error) => {
1450
+ callbackFailure ??= error;
1451
+ close();
1452
+ });
1453
+ }
1454
+ };
1455
+ const dispatch = (callback, value) => {
1456
+ if (!callback) return;
1457
+ callbackTail = callbackTail
1458
+ .then(() => callback(value))
1459
+ .catch((error) => {
1460
+ callbackFailure ??= error;
1461
+ close();
1462
+ });
1463
+ };
1464
+ const processEnvelope = (envelope) => {
1465
+ const payload = envelope.payload;
1466
+ if (ownership && payload.type === 'session/event') {
1467
+ this.#consumeInteractionOwnerships(sessionId, [payload.event]);
1379
1468
  return;
1380
1469
  }
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
- }
1470
+ if (payload.type === 'question/requested' || payload.type === 'approval/requested') {
1471
+ const kind = payload.type === 'question/requested' ? 'question' : 'approval';
1472
+ const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId;
1473
+ const claimKey = `${kind}:${interactionId}`;
1421
1474
  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
- });
1475
+ const claim = this.#interactionOwner(sessionId, claimKey, kind);
1476
+ if (claim?.ownership !== ownership) return;
1477
+ this.#interactionClaims.set(claimKey, claim);
1431
1478
  }
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;
1479
- }
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
- }));
1479
+ const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
1480
+ ? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
1481
+ : undefined;
1482
+ dispatch(onInteraction, Object.freeze({
1483
+ kind,
1484
+ interactionId,
1485
+ rpcId: envelope.rpcId,
1486
+ sessionId,
1487
+ payload,
1488
+ recovered: ownership
1489
+ ? this.#interactionClaims.get(claimKey)?.recovered === true
1490
+ : false,
1491
+ ...(toolCall ? { toolCall } : {}),
1492
+ reconnect: close,
1493
+ respond: (result, options = {}) => this.respondInteraction(
1494
+ envelope.rpcId,
1495
+ result,
1496
+ { ...options, signal: options.signal ?? signal },
1497
+ ),
1498
+ }));
1499
+ return;
1500
+ }
1501
+ if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') {
1502
+ const kind = payload.type === 'question/resolved' ? 'question' : 'approval';
1503
+ const interactionId = kind === 'question'
1504
+ ? payload.questionRpcId
1505
+ : payload.approvalId;
1506
+ const claimKey = `${kind}:${interactionId}`;
1507
+ if (ownership) {
1508
+ const claim = this.#interactionClaims.get(claimKey);
1509
+ if (claim?.ownership !== ownership) return;
1510
+ this.#interactionClaims.delete(claimKey);
1498
1511
  }
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);
1512
+ dispatch(onResolved, Object.freeze({
1513
+ kind,
1514
+ interactionId,
1515
+ sessionId,
1516
+ outcome: payload.outcome,
1517
+ payload,
1518
+ }));
1519
+ }
1520
+ };
1521
+ const handleEnvelope = (envelope) => {
1522
+ try {
1523
+ const payload = envelope?.payload;
1524
+ if (envelope?.type !== 'server-request'
1525
+ || typeof envelope.rpcId !== 'string'
1526
+ || !payload || typeof payload !== 'object'
1527
+ || envelope.method !== payload.type) {
1528
+ throw new Error('invalid server-request envelope');
1516
1529
  }
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
- });
1530
+ if (payload.sessionId !== sessionId) return;
1531
+ if (!ownershipReady) bufferedEnvelopes.push(envelope);
1532
+ else processEnvelope(envelope);
1533
+ } catch (error) {
1534
+ console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message);
1535
+ }
1536
+ };
1537
+ try {
1538
+ await this.#watchMux({ signal, onOpen: handleOpen, onEnvelope: handleEnvelope });
1539
+ } finally {
1540
+ settled = true;
1541
+ if (ownership?.reconnect === close) ownership.reconnect = null;
1542
+ if (!signal.aborted) await callbackTail;
1543
+ }
1544
+ if (!signal.aborted && callbackFailure) throw callbackFailure;
1539
1545
  }
1540
1546
 
1541
1547
  /**
1542
1548
  * 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.
1549
+ * aborts, reconnecting on drop. Both transports deliver the same envelopes;
1550
+ * only session/event payloads are forwarded. onReconnect fires after every
1551
+ * (re)connection so callers can compensate for events missed while offline.
1548
1552
  */
1549
1553
  async watchHarnessEvents({ signal, onSessionEvent, onReconnect } = {}) {
1550
1554
  if (typeof onSessionEvent !== 'function') {
@@ -1556,14 +1560,33 @@ export class HarnessClient {
1556
1560
  if (onReconnect !== undefined && typeof onReconnect !== 'function') {
1557
1561
  throw new TypeError('onReconnect must be a function');
1558
1562
  }
1559
- const url = new URL('/api/events.mux', this.#baseUrl);
1560
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1561
1563
  while (!signal.aborted) {
1562
1564
  try {
1563
- await this.#watchHarnessEventSocket(url.toString(), {
1565
+ await this.#watchMux({
1564
1566
  signal,
1565
- onSessionEvent,
1566
- onReconnect,
1567
+ onOpen: () => {
1568
+ try {
1569
+ onReconnect?.();
1570
+ } catch (error) {
1571
+ console.warn(`[${this.#logPrefix}] mux reconnect hook failed:`, error.message);
1572
+ }
1573
+ },
1574
+ onEnvelope: (envelope) => {
1575
+ try {
1576
+ const payload = envelope?.payload;
1577
+ if (envelope?.type !== 'server-request'
1578
+ || !payload
1579
+ || typeof payload !== 'object'
1580
+ || envelope.method !== payload.type
1581
+ || payload.type !== 'session/event'
1582
+ || typeof payload.sessionId !== 'string'
1583
+ || !payload.event
1584
+ || typeof payload.event !== 'object') return;
1585
+ onSessionEvent({ sessionId: payload.sessionId, event: payload.event });
1586
+ } catch (error) {
1587
+ console.warn(`[${this.#logPrefix}] ignored a malformed global mux frame:`, error.message);
1588
+ }
1589
+ },
1567
1590
  });
1568
1591
  } catch (error) {
1569
1592
  if (signal.aborted) return;
@@ -1579,81 +1602,16 @@ export class HarnessClient {
1579
1602
  }
1580
1603
  }
1581
1604
 
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();
1605
+ #watchMux(options) {
1606
+ return watchHarnessMux({
1607
+ apiProxy: this.#apiProxy,
1608
+ baseUrl: this.#baseUrl,
1609
+ createWebSocket: this.#createWebSocket,
1610
+ rpcId: `${this.#rpcIdPrefix}-${randomUUID()}`,
1611
+ ...options,
1612
+ onMalformed: (error) => {
1613
+ console.warn(`[${this.#logPrefix}] ignored a malformed Harness mux frame:`, error.message);
1614
+ },
1657
1615
  });
1658
1616
  }
1659
1617