@xmanrui/dsh-im 0.11.0 → 0.12.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.
@@ -12,12 +12,77 @@ const interactionRegistries = new Map();
12
12
  function interactionRegistry(origin) {
13
13
  let registry = interactionRegistries.get(origin);
14
14
  if (!registry) {
15
- registry = { ownerships: new Map(), claims: new Map(), nextOrder: 0 };
15
+ registry = {
16
+ ownerships: new Map(),
17
+ claims: new Map(),
18
+ controls: new WeakMap(),
19
+ nextOrder: 0,
20
+ };
16
21
  interactionRegistries.set(origin, registry);
17
22
  }
18
23
  return registry;
19
24
  }
20
25
 
26
+ function normalizeControl(control) {
27
+ const ownerType = typeof control?.owner;
28
+ if ((ownerType !== 'object' && ownerType !== 'function')
29
+ || control.owner === null
30
+ || typeof control.key !== 'string'
31
+ || !control.key) return null;
32
+ return { owner: control.owner, key: control.key };
33
+ }
34
+
35
+ function validModelSelection(value) {
36
+ return value !== null
37
+ && typeof value === 'object'
38
+ && typeof value.provider === 'string'
39
+ && Boolean(value.provider)
40
+ && typeof value.model === 'string'
41
+ && Boolean(value.model)
42
+ && (value.reasoningEffort === undefined || typeof value.reasoningEffort === 'string');
43
+ }
44
+
45
+ function validateModelCatalog(value, method, { session = false } = {}) {
46
+ if (!value || typeof value !== 'object'
47
+ || !Array.isArray(value.groups)
48
+ || !Array.isArray(value.failures)) {
49
+ throw new Error(`Harness returned an invalid response for ${method}`);
50
+ }
51
+ for (const group of value.groups) {
52
+ if (!group || typeof group !== 'object'
53
+ || typeof group.id !== 'string' || !group.id
54
+ || typeof group.name !== 'string' || !group.name
55
+ || !Array.isArray(group.models)) {
56
+ throw new Error(`Harness returned an invalid response for ${method}`);
57
+ }
58
+ for (const model of group.models) {
59
+ if (!model || typeof model !== 'object'
60
+ || typeof model.id !== 'string' || !model.id
61
+ || typeof model.name !== 'string' || !model.name) {
62
+ throw new Error(`Harness returned an invalid response for ${method}`);
63
+ }
64
+ }
65
+ }
66
+ for (const failure of value.failures) {
67
+ if (!failure || typeof failure !== 'object'
68
+ || typeof failure.id !== 'string' || !failure.id
69
+ || typeof failure.name !== 'string' || !failure.name
70
+ || (failure.message !== undefined && typeof failure.message !== 'string')) {
71
+ throw new Error(`Harness returned an invalid response for ${method}`);
72
+ }
73
+ }
74
+ if (session && (!validModelSelection(value.current) || typeof value.routable !== 'boolean')) {
75
+ throw new Error(`Harness returned an invalid response for ${method}`);
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function turnStoppedError() {
81
+ const error = new Error('Harness turn was stopped before producing a text reply');
82
+ error.code = 'turn-stopped';
83
+ return error;
84
+ }
85
+
21
86
  function workspacePaths(value) {
22
87
  if (!Array.isArray(value?.items)) return [];
23
88
  return value.items.flatMap((item) => (
@@ -319,10 +384,13 @@ export class HarnessClient {
319
384
  #rpcIdPrefix;
320
385
  #logPrefix;
321
386
  #commandExecutor;
387
+ #controlExecutor;
388
+ #sessionMaintenanceExecutor;
322
389
  #managedProcess = null;
323
390
  #interactionRegistry;
324
391
  #interactionOwnerships;
325
392
  #interactionClaims;
393
+ #controlOwnerships;
326
394
 
327
395
  constructor({
328
396
  baseUrl,
@@ -336,6 +404,8 @@ export class HarnessClient {
336
404
  rpcIdPrefix = 'im',
337
405
  logPrefix = 'dsh-im',
338
406
  commandExecutor,
407
+ controlExecutor,
408
+ sessionMaintenanceExecutor,
339
409
  }) {
340
410
  if (typeof createWebSocket !== 'function') {
341
411
  throw new TypeError('createWebSocket must be a function');
@@ -352,6 +422,13 @@ export class HarnessClient {
352
422
  if (commandExecutor !== undefined && typeof commandExecutor !== 'function') {
353
423
  throw new TypeError('commandExecutor must be a function');
354
424
  }
425
+ if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
426
+ throw new TypeError('controlExecutor must be a function');
427
+ }
428
+ if (sessionMaintenanceExecutor !== undefined
429
+ && typeof sessionMaintenanceExecutor !== 'function') {
430
+ throw new TypeError('sessionMaintenanceExecutor must be a function');
431
+ }
355
432
  this.#baseUrl = new URL(baseUrl);
356
433
  this.#workspace = workspace;
357
434
  // Keep an omitted preset absent so session.create resolves the Host's current default.
@@ -364,9 +441,12 @@ export class HarnessClient {
364
441
  this.#rpcIdPrefix = rpcIdPrefix.trim();
365
442
  this.#logPrefix = logPrefix.trim();
366
443
  this.#commandExecutor = commandExecutor;
444
+ this.#controlExecutor = controlExecutor;
445
+ this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
367
446
  this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
368
447
  this.#interactionOwnerships = this.#interactionRegistry.ownerships;
369
448
  this.#interactionClaims = this.#interactionRegistry.claims;
449
+ this.#controlOwnerships = this.#interactionRegistry.controls;
370
450
  }
371
451
 
372
452
  async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
@@ -443,6 +523,61 @@ export class HarnessClient {
443
523
  return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
444
524
  }
445
525
 
526
+ async listModels(options = {}) {
527
+ await this.ensureRunning(options);
528
+ const value = await this.rpc('llm.models', {}, 30_000, options);
529
+ return validateModelCatalog(value, 'llm.models');
530
+ }
531
+
532
+ async getSessionModels(sessionId, options = {}) {
533
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
534
+ await this.ensureRunning(options);
535
+ const value = await this.rpc('session.models', { sessionId }, 30_000, options);
536
+ return validateModelCatalog(value, 'session.models', { session: true });
537
+ }
538
+
539
+ async selectSessionModel(sessionId, selection, options = {}) {
540
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
541
+ if (!validModelSelection(selection)) {
542
+ throw new TypeError('A provider and model are required');
543
+ }
544
+ await this.ensureRunning(options);
545
+ const operation = (maintenanceSignal) => {
546
+ const signal = maintenanceSignal && options.signal
547
+ ? AbortSignal.any([maintenanceSignal, options.signal])
548
+ : (maintenanceSignal ?? options.signal);
549
+ return this.rpc('session.selectModel', {
550
+ sessionId,
551
+ provider: selection.provider,
552
+ model: selection.model,
553
+ }, 30_000, signal ? { ...options, signal } : options);
554
+ };
555
+ const value = this.#sessionMaintenanceExecutor
556
+ ? await this.#sessionMaintenanceExecutor({ sessionId, operation })
557
+ : await operation();
558
+ if (!value || typeof value !== 'object' || !validModelSelection(value.selected)) {
559
+ throw new Error('Harness returned an invalid response for session.selectModel');
560
+ }
561
+ return value;
562
+ }
563
+
564
+ async isSessionRunning(sessionId, options = {}) {
565
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
566
+ await this.ensureRunning(options);
567
+ const value = await this.rpc('session.list', {}, 30_000, options);
568
+ if (!value || typeof value !== 'object' || !Array.isArray(value.items)) {
569
+ throw new Error('Harness returned an invalid response for session.list');
570
+ }
571
+ for (const item of value.items) {
572
+ if (!item || typeof item !== 'object'
573
+ || typeof item.sessionId !== 'string' || !item.sessionId
574
+ || typeof item.running !== 'boolean') {
575
+ throw new Error('Harness returned an invalid response for session.list');
576
+ }
577
+ }
578
+ return value.items.find((item) => item.sessionId === sessionId)?.running ?? false;
579
+ }
580
+
446
581
  async adoptWorkspaceSession(value, options = {}) {
447
582
  return adoptRegisteredWorkspaceSession(this, value, options);
448
583
  }
@@ -585,6 +720,154 @@ export class HarnessClient {
585
720
  }
586
721
  }
587
722
 
723
+ #registerControlOwnership(ownership) {
724
+ if (!ownership.control) return;
725
+ let routes = this.#controlOwnerships.get(ownership.control.owner);
726
+ if (!routes) {
727
+ routes = new Map();
728
+ this.#controlOwnerships.set(ownership.control.owner, routes);
729
+ }
730
+ const owners = routes.get(ownership.control.key) ?? new Set();
731
+ owners.add(ownership);
732
+ routes.set(ownership.control.key, owners);
733
+ }
734
+
735
+ #unregisterControlOwnership(ownership) {
736
+ if (!ownership.control) return;
737
+ const routes = this.#controlOwnerships.get(ownership.control.owner);
738
+ const owners = routes?.get(ownership.control.key);
739
+ owners?.delete(ownership);
740
+ if (owners?.size === 0) routes.delete(ownership.control.key);
741
+ if (routes?.size === 0) this.#controlOwnerships.delete(ownership.control.owner);
742
+ }
743
+
744
+ #controlCandidates(sessionId, control) {
745
+ const normalized = normalizeControl(control);
746
+ if (!normalized) return [];
747
+ const owners = this.#controlOwnerships.get(normalized.owner)?.get(normalized.key);
748
+ return [...(owners ?? [])]
749
+ .filter((ownership) => ownership.sessionId === sessionId)
750
+ .sort((left, right) => left.order - right.order);
751
+ }
752
+
753
+ #activeControlOwnership(sessionId, control) {
754
+ return this.#controlCandidates(sessionId, control).find((ownership) => (
755
+ ownership.started
756
+ && ownership.active
757
+ && !ownership.completed
758
+ && ownership.turn !== null
759
+ )) ?? null;
760
+ }
761
+
762
+ async #refreshControlOwnership(sessionId, control, options) {
763
+ const candidates = this.#controlCandidates(sessionId, control);
764
+ // An exact local owner is mandatory before even observing the Session.
765
+ // This prevents an unrelated chat bound to the same Session from using
766
+ // run state as authority to cancel or steer somebody else's turn.
767
+ if (candidates.length === 0) return null;
768
+ try {
769
+ const history = await this.rpc(
770
+ 'session.history',
771
+ { sessionId, maxMessages: 50 },
772
+ 30_000,
773
+ options,
774
+ );
775
+ this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
776
+ } catch (error) {
777
+ if (!(error instanceof HarnessRpcError) || error.code !== 'session-not-found') throw error;
778
+ for (const ownership of candidates) {
779
+ ownership.active = false;
780
+ ownership.completed = true;
781
+ }
782
+ return null;
783
+ }
784
+ return this.#activeControlOwnership(sessionId, control);
785
+ }
786
+
787
+ async hasActiveTurn(sessionId, control, options = {}) {
788
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
789
+ return Boolean(await this.#refreshControlOwnership(sessionId, control, options));
790
+ }
791
+
792
+ async stopActiveTurn(sessionId, control, options = {}) {
793
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
794
+ const ownership = await this.#refreshControlOwnership(sessionId, control, options);
795
+ if (!ownership) return false;
796
+ if (ownership.stopRequested) return true;
797
+ // Re-check after the refresh await. The owning ask may have completed and
798
+ // unregistered while session.history was in flight.
799
+ if (this.#activeControlOwnership(sessionId, control) !== ownership) return false;
800
+ ownership.stopRequested = true;
801
+ try {
802
+ if (this.#controlExecutor) {
803
+ const accepted = this.#controlExecutor({
804
+ sessionId,
805
+ expectedTurn: ownership.turn,
806
+ promptRpcId: ownership.promptRpcId,
807
+ action: 'stop',
808
+ });
809
+ if (accepted && typeof accepted.then === 'function') {
810
+ throw new TypeError('controlExecutor must return synchronously');
811
+ }
812
+ if (accepted !== undefined) {
813
+ if (typeof accepted !== 'boolean') {
814
+ throw new TypeError('controlExecutor must return a boolean or undefined');
815
+ }
816
+ if (!accepted) ownership.stopRequested = false;
817
+ return accepted;
818
+ }
819
+ }
820
+ await this.rpc(
821
+ 'session.cancel',
822
+ { sessionId, keepInbox: true },
823
+ 30_000,
824
+ options,
825
+ );
826
+ return true;
827
+ } catch (error) {
828
+ if (this.#activeControlOwnership(sessionId, control) === ownership) {
829
+ ownership.stopRequested = false;
830
+ }
831
+ if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
832
+ throw error;
833
+ }
834
+ }
835
+
836
+ async steerActiveTurn(sessionId, text, control, options = {}) {
837
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
838
+ if (typeof text !== 'string' || !text.trim()) {
839
+ throw new TypeError('Steering text is required');
840
+ }
841
+ const ownership = await this.#refreshControlOwnership(sessionId, control, options);
842
+ if (!ownership || ownership.stopRequested) return false;
843
+ if (this.#activeControlOwnership(sessionId, control) !== ownership) return false;
844
+ if (this.#controlExecutor) {
845
+ const accepted = this.#controlExecutor({
846
+ sessionId,
847
+ expectedTurn: ownership.turn,
848
+ promptRpcId: ownership.promptRpcId,
849
+ action: 'steer',
850
+ text,
851
+ });
852
+ if (accepted && typeof accepted.then === 'function') {
853
+ throw new TypeError('controlExecutor must return synchronously');
854
+ }
855
+ if (accepted !== undefined) {
856
+ if (typeof accepted !== 'boolean') {
857
+ throw new TypeError('controlExecutor must return a boolean or undefined');
858
+ }
859
+ return accepted;
860
+ }
861
+ }
862
+ await this.rpc('session.prompt', {
863
+ sessionId,
864
+ mode: 'steer',
865
+ content: [{ type: 'text', text }],
866
+ clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
867
+ }, 30_000, options);
868
+ return true;
869
+ }
870
+
588
871
  #consumeInteractionOwnerships(sessionId, entries) {
589
872
  for (const ownership of this.#interactionOwnerships.get(sessionId) ?? []) {
590
873
  consumeInteractionOwnership(ownership, entries);
@@ -634,6 +917,7 @@ export class HarnessClient {
634
917
  const onInteractionResolved = typeof options.onInteractionResolved === 'function'
635
918
  ? options.onInteractionResolved
636
919
  : undefined;
920
+ const control = normalizeControl(options.control);
637
921
  await this.ensureRunning({ signal });
638
922
  const before = await this.rpc(
639
923
  'session.history',
@@ -655,23 +939,29 @@ export class HarnessClient {
655
939
  // The mux is host-global. A prompt RPC becomes the owner only when its
656
940
  // durable user/message starts a turn, so two chats bound to one Session
657
941
  // cannot answer each other's questions or approvals.
658
- const ownership = interactionController
942
+ const ownership = interactionController || control
659
943
  ? {
944
+ sessionId,
660
945
  promptRpcId,
661
946
  active: false,
662
947
  started: false,
663
948
  completed: false,
949
+ stopRequested: false,
664
950
  turn: null,
665
951
  openTurn: null,
666
952
  lastSeq: baselineSeq,
667
953
  reconnect: null,
668
954
  order: -1,
669
955
  toolCalls: new Map(),
956
+ control,
670
957
  }
671
958
  : null;
672
959
  let interactionTask = null;
673
960
 
674
- if (ownership) this.#registerInteractionOwnership(sessionId, ownership);
961
+ if (ownership) {
962
+ this.#registerInteractionOwnership(sessionId, ownership);
963
+ this.#registerControlOwnership(ownership);
964
+ }
675
965
 
676
966
  try {
677
967
  if (interactionSignal) {
@@ -706,39 +996,52 @@ export class HarnessClient {
706
996
  clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
707
997
  }, 30_000, { rpcId: promptRpcId, signal });
708
998
 
709
- const deadline = Date.now() + timeoutMs;
710
- while (Date.now() < deadline) {
711
- await sleep(300, signal);
712
- const history = await this.rpc(
713
- 'session.history',
714
- { sessionId, maxMessages: 50 },
715
- 30_000,
716
- { signal },
717
- );
718
- const wasActive = ownership?.active === true;
719
- if (ownership) {
720
- this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
721
- if (!wasActive && ownership.active) ownership.reconnect?.();
722
- }
723
- const update = tracker.consume(history.events ?? []);
724
- if (update && onUpdate) {
725
- try {
726
- await onUpdate(update);
727
- } catch (error) {
728
- console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
999
+ try {
1000
+ const deadline = Date.now() + timeoutMs;
1001
+ while (Date.now() < deadline) {
1002
+ await sleep(300, signal);
1003
+ const history = await this.rpc(
1004
+ 'session.history',
1005
+ { sessionId, maxMessages: 50 },
1006
+ 30_000,
1007
+ { signal },
1008
+ );
1009
+ const wasActive = ownership?.active === true;
1010
+ if (ownership) {
1011
+ this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
1012
+ if (!wasActive && ownership.active) ownership.reconnect?.();
1013
+ }
1014
+ const update = tracker.consume(history.events ?? []);
1015
+ if (update && onUpdate) {
1016
+ try {
1017
+ await onUpdate(update);
1018
+ } catch (error) {
1019
+ console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
1020
+ }
729
1021
  }
1022
+ if (!tracker.finished) continue;
1023
+ if (tracker.answer) return tracker.answer;
1024
+ if (ownership?.stopRequested) throw turnStoppedError();
1025
+ throw new Error(
1026
+ `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
1027
+ );
730
1028
  }
731
- if (!tracker.finished) continue;
1029
+ throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
1030
+ } catch (error) {
1031
+ // Once cancellation was accepted, transport/poll failures and timeouts
1032
+ // describe the convergence of that stop, not an unrelated ask failure.
1033
+ if (!ownership?.stopRequested) throw error;
732
1034
  if (tracker.answer) return tracker.answer;
733
- throw new Error(
734
- `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
735
- );
1035
+ if (error?.code === 'turn-stopped') throw error;
1036
+ throw turnStoppedError();
736
1037
  }
737
- throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
738
1038
  } finally {
1039
+ if (ownership) {
1040
+ this.#unregisterControlOwnership(ownership);
1041
+ this.#unregisterInteractionOwnership(sessionId, ownership);
1042
+ }
739
1043
  interactionController?.abort(new DOMException('Harness turn finished', 'AbortError'));
740
1044
  if (interactionTask) await interactionTask.catch(() => undefined);
741
- if (ownership) this.#unregisterInteractionOwnership(sessionId, ownership);
742
1045
  }
743
1046
  }
744
1047
 
@@ -775,6 +1078,10 @@ export class HarnessClient {
775
1078
  socket.removeEventListener('error', handleError);
776
1079
  signal.removeEventListener('abort', handleAbort);
777
1080
  if (ownership?.reconnect === close) ownership.reconnect = null;
1081
+ if (signal.aborted) {
1082
+ resolve();
1083
+ return;
1084
+ }
778
1085
  void callbackTail.then(() => {
779
1086
  const failure = error ?? callbackFailure;
780
1087
  if (failure) reject(failure);