@xmanrui/dsh-im 0.10.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.
- package/README.en.md +36 -6
- package/README.md +36 -6
- package/lib/client.js +377 -27
- package/lib/index.js +145 -135
- package/package.json +31 -2
- package/plugin-src/client/channels/dingtalk/api.js +19 -0
- package/plugin-src/client/channels/dingtalk/index.js +67 -10
- package/plugin-src/client/channels/feishu/index.js +35 -5
- package/plugin-src/client/channels/qq/api.js +21 -0
- package/plugin-src/client/channels/qq/index.js +39 -1
- package/plugin-src/client/channels/shared/token-channel.js +29 -3
- package/plugin-src/client/channels/wecom/api.js +11 -0
- package/plugin-src/client/channels/wecom/index.js +71 -1
- package/plugin-src/client/channels/weixin/api.js +11 -0
- package/plugin-src/client/channels/weixin/index.js +40 -4
- package/plugin-src/client/channels/whatsapp/index.js +41 -2
- package/plugin-src/client/i18n.js +13 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +8 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
- package/plugin-src/host/channels/feishu/production.mjs +8 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
- package/plugin-src/host/channels/qq/production.mjs +8 -1
- package/plugin-src/host/channels/qq/rpc.mjs +35 -2
- package/plugin-src/host/channels/shared/production.mjs +8 -1
- package/plugin-src/host/channels/shared/rpc.mjs +25 -1
- package/plugin-src/host/channels/slack/production.mjs +8 -1
- package/plugin-src/host/channels/slack/rpc.mjs +28 -2
- package/plugin-src/host/channels/wecom/production.mjs +8 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
- package/plugin-src/host/channels/weixin/production.mjs +8 -1
- package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
- package/plugin-src/host/channels/whatsapp/production.mjs +8 -1
- package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
- package/plugin-src/host/harness-session-coordinator.mjs +134 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +85 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/discord/discord-runtime.mjs +10 -0
- package/src/channels/feishu/bridge.mjs +87 -0
- package/src/channels/feishu/feishu-runtime.mjs +45 -0
- package/src/channels/feishu/message-utils.mjs +94 -7
- package/src/channels/feishu/multi-bot-controller.mjs +21 -0
- package/src/channels/qq/qq-bridge.mjs +91 -0
- package/src/channels/qq/qq-controller.mjs +18 -0
- package/src/channels/qq/qq-runtime.mjs +26 -0
- package/src/channels/shared/bot-workspace-store.mjs +47 -1
- package/src/channels/shared/connection-test.mjs +54 -0
- package/src/channels/shared/control-command.mjs +84 -0
- package/src/channels/shared/harness-approval.mjs +4 -0
- package/src/channels/shared/harness-client.mjs +342 -35
- package/src/channels/shared/model-command.mjs +314 -0
- package/src/channels/shared/session-binding-lock.mjs +40 -0
- package/src/channels/shared/text-harness-bridge.mjs +100 -0
- package/src/channels/shared/token-bot-controller.mjs +21 -0
- package/src/channels/shared/workspace-session.mjs +21 -9
- package/src/channels/slack/slack-controller.mjs +19 -0
- package/src/channels/slack/slack-runtime.mjs +10 -0
- package/src/channels/telegram/telegram-runtime.mjs +10 -0
- package/src/channels/wecom/wecom-bridge.mjs +79 -0
- package/src/channels/wecom/wecom-controller.mjs +18 -0
- package/src/channels/wecom/wecom-runtime.mjs +18 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +85 -0
- package/src/channels/weixin/weixin-controller.mjs +18 -0
- package/src/channels/weixin/weixin-runtime.mjs +25 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +16 -0
|
@@ -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 = {
|
|
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,15 +384,18 @@ 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,
|
|
329
397
|
workspace,
|
|
330
|
-
agentPreset
|
|
398
|
+
agentPreset,
|
|
331
399
|
autostart = false,
|
|
332
400
|
dshBin = 'dsh',
|
|
333
401
|
fetchImpl = fetch,
|
|
@@ -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,9 +422,17 @@ 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.
|
|
435
|
+
this.#agentPreset = agentPreset ?? undefined;
|
|
358
436
|
this.#autostart = autostart;
|
|
359
437
|
this.#dshBin = dshBin;
|
|
360
438
|
this.#fetch = fetchImpl;
|
|
@@ -363,9 +441,12 @@ export class HarnessClient {
|
|
|
363
441
|
this.#rpcIdPrefix = rpcIdPrefix.trim();
|
|
364
442
|
this.#logPrefix = logPrefix.trim();
|
|
365
443
|
this.#commandExecutor = commandExecutor;
|
|
444
|
+
this.#controlExecutor = controlExecutor;
|
|
445
|
+
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
|
|
366
446
|
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
|
367
447
|
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
|
368
448
|
this.#interactionClaims = this.#interactionRegistry.claims;
|
|
449
|
+
this.#controlOwnerships = this.#interactionRegistry.controls;
|
|
369
450
|
}
|
|
370
451
|
|
|
371
452
|
async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
|
|
@@ -442,6 +523,61 @@ export class HarnessClient {
|
|
|
442
523
|
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
|
443
524
|
}
|
|
444
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
|
+
|
|
445
581
|
async adoptWorkspaceSession(value, options = {}) {
|
|
446
582
|
return adoptRegisteredWorkspaceSession(this, value, options);
|
|
447
583
|
}
|
|
@@ -458,10 +594,9 @@ export class HarnessClient {
|
|
|
458
594
|
async createSession(options = {}) {
|
|
459
595
|
await this.ensureRunning(options);
|
|
460
596
|
const workspaceId = await this.workspaceId(options);
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}, 30_000, options);
|
|
597
|
+
const payload = { workspaceId };
|
|
598
|
+
if (this.#agentPreset !== undefined) payload.agentPreset = this.#agentPreset;
|
|
599
|
+
const created = await this.rpc('session.create', payload, 30_000, options);
|
|
465
600
|
return created.sessionId;
|
|
466
601
|
}
|
|
467
602
|
|
|
@@ -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)
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
|
734
|
-
|
|
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);
|