@xmanrui/dsh-im 4.19.1 → 4.20.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 (53) hide show
  1. package/README.en.md +96 -4
  2. package/README.md +96 -4
  3. package/lib/client.js +666 -217
  4. package/lib/index.js +277 -273
  5. package/package.json +13 -1
  6. package/plugin-src/client/channels/weixin/api.js +35 -17
  7. package/plugin-src/client/channels/weixin/connection-error.js +68 -0
  8. package/plugin-src/client/channels/weixin/index.js +36 -12
  9. package/plugin-src/client/i18n.js +2 -0
  10. package/plugin-src/client/index.js +15 -0
  11. package/plugin-src/client/interface-language.js +89 -0
  12. package/plugin-src/host/channels/shared/startup.mjs +30 -4
  13. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
  14. package/plugin-src/host/channels/weixin/index.mjs +12 -3
  15. package/plugin-src/host/channels/weixin/production.mjs +53 -3
  16. package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
  17. package/plugin-src/host/host-language-rpc.mjs +71 -0
  18. package/plugin-src/host/host-language.mjs +157 -0
  19. package/plugin-src/host/index.mjs +15 -2
  20. package/scripts/verify-interface-language.mjs +333 -0
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
  22. package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
  23. package/src/channels/discord/discord-runtime.mjs +1 -1
  24. package/src/channels/feishu/bridge.mjs +44 -13
  25. package/src/channels/qq/qq-bridge.mjs +12 -4
  26. package/src/channels/qq/qq-menu.mjs +11 -8
  27. package/src/channels/shared/bot-workspace-store.mjs +532 -40
  28. package/src/channels/shared/command-catalog.mjs +5 -0
  29. package/src/channels/shared/compact-command.mjs +14 -4
  30. package/src/channels/shared/control-command.mjs +1 -1
  31. package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
  32. package/src/channels/shared/history-command.mjs +1 -1
  33. package/src/channels/shared/i18n-en/discord.mjs +2 -0
  34. package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
  35. package/src/channels/shared/i18n-en/telegram.mjs +5 -0
  36. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  37. package/src/channels/shared/i18n.mjs +46 -3
  38. package/src/channels/shared/interface-language-store.mjs +127 -0
  39. package/src/channels/shared/interface-language.mjs +51 -0
  40. package/src/channels/shared/model-command.mjs +5 -3
  41. package/src/channels/shared/token-bot-controller.mjs +26 -0
  42. package/src/channels/shared/workspace-command.mjs +114 -9
  43. package/src/channels/shared/workspace-session.mjs +55 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +76 -14
  45. package/src/channels/wecom/wecom-bridge.mjs +2 -2
  46. package/src/channels/weixin/connection-error.en.mjs +116 -0
  47. package/src/channels/weixin/connection-error.mjs +204 -0
  48. package/src/channels/weixin/diagnostic-details.mjs +40 -0
  49. package/src/channels/weixin/state-store.mjs +4 -3
  50. package/src/channels/weixin/weixin-api.mjs +20 -8
  51. package/src/channels/weixin/weixin-bridge.mjs +3 -2
  52. package/src/channels/weixin/weixin-controller.mjs +133 -104
  53. package/src/channels/weixin/weixin-runtime.mjs +35 -24
@@ -102,6 +102,35 @@ function conversationKeyOf(value) {
102
102
  return value;
103
103
  }
104
104
 
105
+ function conversationWorkspaceGenerationKey(botId, conversationKey) {
106
+ // Conversation keys validated by conversationKeyOf never contain the null
107
+ // separator, so the compound key stays unambiguous.
108
+ return `${botIdOf(botId)}\u0000${conversationKeyOf(conversationKey)}`;
109
+ }
110
+
111
+ function normalizeConversationWorkspaces(value) {
112
+ const conversationWorkspaces = Object.create(null);
113
+ if (value === undefined) return conversationWorkspaces;
114
+ // Override damage is isolated: an invalid entry is dropped rather than
115
+ // failing the whole document, because a missing/invalid override falls back
116
+ // to the bot workspace safely.
117
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
118
+ return conversationWorkspaces;
119
+ }
120
+ for (const [botId, overrides] of Object.entries(value)) {
121
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)) continue;
122
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) continue;
123
+ const normalized = Object.create(null);
124
+ for (const [conversationKey, workspace] of Object.entries(overrides)) {
125
+ if (typeof conversationKey !== 'string' || !conversationKey
126
+ || typeof workspace !== 'string' || !isAbsolute(workspace)) continue;
127
+ normalized[conversationKey] = resolve(workspace);
128
+ }
129
+ if (Object.keys(normalized).length > 0) conversationWorkspaces[botId] = normalized;
130
+ }
131
+ return conversationWorkspaces;
132
+ }
133
+
105
134
  function normalizeDeliveryTarget(value, { targetId, allowSessionSync = false } = {}) {
106
135
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
107
136
  throw deliveryTargetError('invalid-target', 'Invalid delivery target');
@@ -221,6 +250,7 @@ function normalizeDocument(value) {
221
250
  || typeof workspace !== 'string' || !isAbsolute(workspace)) return null;
222
251
  workspaces[botId] = resolve(workspace);
223
252
  }
253
+ const conversationWorkspaces = normalizeConversationWorkspaces(value.conversationWorkspaces);
224
254
  let agentPresets = {};
225
255
  if (value.agentPresets !== undefined) {
226
256
  if (!value.agentPresets || typeof value.agentPresets !== 'object'
@@ -285,6 +315,7 @@ function normalizeDocument(value) {
285
315
  // recovered from an interrupted/manual edit, retain it on the next write.
286
316
  version,
287
317
  workspaces,
318
+ conversationWorkspaces,
288
319
  agentPresets,
289
320
  models,
290
321
  contextEnhancement,
@@ -297,6 +328,7 @@ function normalizeDocument(value) {
297
328
  function storedDocument({
298
329
  version,
299
330
  workspaces,
331
+ conversationWorkspaces,
300
332
  agentPresets,
301
333
  models,
302
334
  contextEnhancement,
@@ -306,6 +338,9 @@ function storedDocument({
306
338
  }) {
307
339
  const document = { version, workspaces };
308
340
  if (Object.keys(aliases).length > 0) document.aliases = aliases;
341
+ if (Object.keys(conversationWorkspaces).length > 0) {
342
+ document.conversationWorkspaces = conversationWorkspaces;
343
+ }
309
344
  if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
310
345
  if (Object.keys(models).length > 0) document.models = models;
311
346
  if (Object.keys(contextEnhancement).length > 0) {
@@ -364,8 +399,11 @@ export class BotWorkspaceStore {
364
399
  #contextEnhancement = {};
365
400
  #deliveryTargets = Object.create(null);
366
401
  #accessPolicies = Object.create(null);
402
+ #conversationWorkspaces = Object.create(null);
367
403
  #generations = new Map();
368
404
  #nextGeneration = 1;
405
+ #conversationGenerations = new Map();
406
+ #nextConversationGeneration = 1;
369
407
  #incarnations = new Map();
370
408
  #nextIncarnation = 1;
371
409
  #removals = new Map();
@@ -392,6 +430,7 @@ export class BotWorkspaceStore {
392
430
  this.#contextEnhancement = normalized.contextEnhancement;
393
431
  this.#deliveryTargets = normalized.deliveryTargets;
394
432
  this.#accessPolicies = normalized.accessPolicies;
433
+ this.#conversationWorkspaces = normalized.conversationWorkspaces;
395
434
  } catch (error) {
396
435
  if (error?.code !== 'ENOENT') throw error;
397
436
  this.#version = 1;
@@ -402,9 +441,12 @@ export class BotWorkspaceStore {
402
441
  this.#contextEnhancement = {};
403
442
  this.#deliveryTargets = Object.create(null);
404
443
  this.#accessPolicies = Object.create(null);
444
+ this.#conversationWorkspaces = Object.create(null);
405
445
  }
406
446
  this.#generations.clear();
407
447
  this.#nextGeneration = 1;
448
+ this.#conversationGenerations.clear();
449
+ this.#nextConversationGeneration = 1;
408
450
  this.#incarnations.clear();
409
451
  this.#nextIncarnation = 1;
410
452
  this.#removals.clear();
@@ -435,6 +477,30 @@ export class BotWorkspaceStore {
435
477
  return this.#workspaces[botIdOf(botId)] ?? this.#defaultWorkspace;
436
478
  }
437
479
 
480
+ /**
481
+ * An explicit override is stored even when it equals the current bot default,
482
+ * so a later default change must never move a conversation that pinned its
483
+ * own workspace.
484
+ */
485
+ hasConversationWorkspaceOverride(botId, conversationKey) {
486
+ const id = botIdOf(botId);
487
+ return Boolean(conversationKey) && Boolean(this.#conversationWorkspaces[id]?.[conversationKey]);
488
+ }
489
+
490
+ conversationWorkspaceFor(botId, conversationKey) {
491
+ const id = botIdOf(botId);
492
+ const override = this.#conversationWorkspaces[id]?.[conversationKey];
493
+ if (override) return override;
494
+ return this.workspaceFor(id);
495
+ }
496
+
497
+ conversationGenerationFor(botId, conversationKey) {
498
+ if (typeof conversationKey !== 'string' || !conversationKey) return null;
499
+ return this.#conversationGenerations.get(
500
+ conversationWorkspaceGenerationKey(botId, conversationKey),
501
+ ) ?? null;
502
+ }
503
+
438
504
  agentPresetFor(botId) {
439
505
  return this.#agentPresets[botIdOf(botId)] ?? null;
440
506
  }
@@ -705,6 +771,30 @@ export class BotWorkspaceStore {
705
771
  });
706
772
  }
707
773
 
774
+ async setConversationWorkspace(botId, conversationKey, value, {
775
+ clearSession,
776
+ incarnation,
777
+ } = {}) {
778
+ const id = botIdOf(botId);
779
+ if (typeof conversationKey !== 'string' || !conversationKey
780
+ || conversationKey.length > 1_024 || conversationKey.trim() !== conversationKey
781
+ || /[\u0000-\u001f\u007f]/u.test(conversationKey)) {
782
+ throw new TypeError('conversationKey is required');
783
+ }
784
+ if (!this.has(id)
785
+ || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
786
+ const error = new Error('找不到要修改的机器人。');
787
+ error.code = 'workspace-bot-not-found';
788
+ throw error;
789
+ }
790
+ const token = this.publishConversationWorkspaceSwitch(id, conversationKey);
791
+ return this.applyConversationWorkspaceSwitch(id, conversationKey, value, {
792
+ token,
793
+ clearSession,
794
+ incarnation,
795
+ });
796
+ }
797
+
708
798
  async setAgentPreset(botId, value, { incarnation } = {}) {
709
799
  const id = botIdOf(botId);
710
800
  if (!this.has(id)
@@ -829,13 +919,124 @@ export class BotWorkspaceStore {
829
919
  });
830
920
  }
831
921
 
922
+ /**
923
+ * Publish the fence for a conversation-level switch before its asynchronous
924
+ * work starts. Every session that was resolved for this conversation is
925
+ * invalidated from this moment on: a bind or prompt already in flight must
926
+ * not keep running (or be written back) in the workspace being left behind.
927
+ * Returns the opaque token that applyConversationWorkspaceSwitch requires, so
928
+ * two overlapping switches cannot adopt each other's fence.
929
+ */
930
+ publishConversationWorkspaceSwitch(botId, conversationKey) {
931
+ const id = botIdOf(botId);
932
+ conversationKeyOf(conversationKey);
933
+ const token = this.#freshConversationGeneration();
934
+ this.#conversationGenerations.set(
935
+ conversationWorkspaceGenerationKey(id, conversationKey),
936
+ token,
937
+ );
938
+ return token;
939
+ }
940
+
941
+ /** True while this token is still the current fence for the conversation. */
942
+ isConversationWorkspaceSwitchCurrent(botId, conversationKey, token) {
943
+ const id = botIdOf(botId);
944
+ conversationKeyOf(conversationKey);
945
+ return this.#conversationGenerations.get(
946
+ conversationWorkspaceGenerationKey(id, conversationKey),
947
+ ) === token;
948
+ }
949
+
950
+ async applyConversationWorkspaceSwitch(botId, conversationKey, value, {
951
+ token,
952
+ clearSession,
953
+ sessionMatchesWorkspace,
954
+ incarnation,
955
+ } = {}) {
956
+ const id = botIdOf(botId);
957
+ const key = conversationKeyOf(conversationKey);
958
+ if (typeof token !== 'number') throw new TypeError('token is required');
959
+ if (!this.has(id)
960
+ || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
961
+ const error = new Error('找不到要修改的机器人。');
962
+ error.code = 'workspace-bot-not-found';
963
+ throw error;
964
+ }
965
+ // Validate before queueing so an invalid path fails without disturbing the
966
+ // conversation's current workspace or its session.
967
+ const workspace = value === null ? null : await validateWorkspacePath(value);
968
+ return this.#enqueue(id, async () => {
969
+ const assertCurrentSwitch = () => {
970
+ if (!this.has(id)
971
+ || (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
972
+ const error = new Error('找不到要修改的机器人。');
973
+ error.code = 'workspace-bot-not-found';
974
+ throw error;
975
+ }
976
+ if (!this.isConversationWorkspaceSwitchCurrent(id, key, token)) {
977
+ throw workspaceSessionStale(
978
+ 'The conversation workspace changed before this switch could be committed.',
979
+ );
980
+ }
981
+ };
982
+ assertCurrentSwitch();
983
+ const previousOverrides = this.#conversationWorkspaces[id];
984
+ const previous = previousOverrides?.[key];
985
+ const hadOverride = Boolean(previousOverrides)
986
+ && Object.hasOwn(previousOverrides, key);
987
+ if (workspace === null ? !hadOverride : (hadOverride && previous === workspace)) {
988
+ // The override may be unchanged while a binding persisted by an older
989
+ // version still points elsewhere. Only preserve a verified matching
990
+ // Session; never acknowledge /conv while keeping a foreign cwd.
991
+ const matches = await sessionMatchesWorkspace?.(this.conversationWorkspaceFor(id, key));
992
+ assertCurrentSwitch();
993
+ if (matches === false) {
994
+ await clearSession?.();
995
+ assertCurrentSwitch();
996
+ }
997
+ return this.conversationWorkspaceFor(id, key);
998
+ }
999
+ const next = workspace === null
1000
+ ? (() => {
1001
+ const overrides = { ...previousOverrides };
1002
+ delete overrides[key];
1003
+ return overrides;
1004
+ })()
1005
+ : { ...previousOverrides, [key]: workspace };
1006
+ await clearSession?.();
1007
+ assertCurrentSwitch();
1008
+ if (Object.keys(next).length > 0) this.#conversationWorkspaces[id] = next;
1009
+ else delete this.#conversationWorkspaces[id];
1010
+ try {
1011
+ // Binding a conversation to its own current default is still persisted:
1012
+ // a later bot-default change must not move a conversation that pinned
1013
+ // the workspace it was using.
1014
+ await this.#persist();
1015
+ } catch (error) {
1016
+ if (Object.keys(next).length > 0 || hadOverride) {
1017
+ this.#conversationWorkspaces[id] = {
1018
+ ...(previousOverrides ?? {}),
1019
+ ...(hadOverride ? { [key]: previous } : {}),
1020
+ };
1021
+ } else {
1022
+ delete this.#conversationWorkspaces[id];
1023
+ }
1024
+ throw error;
1025
+ }
1026
+ return this.conversationWorkspaceFor(id, key);
1027
+ });
1028
+ }
1029
+
832
1030
  async bindWorkspaceSession(botId, value, {
833
1031
  conversationKey,
834
1032
  sessionId,
835
1033
  clearSessions,
1034
+ clearSession,
836
1035
  setSession,
1036
+ onConversationGeneration,
837
1037
  incarnation,
838
1038
  expectedGeneration,
1039
+ expectedConversationGeneration,
839
1040
  } = {}) {
840
1041
  const id = botIdOf(botId);
841
1042
  if (typeof conversationKey !== 'string' || !conversationKey
@@ -851,6 +1052,18 @@ export class BotWorkspaceStore {
851
1052
  error.code = 'workspace-bot-not-found';
852
1053
  throw error;
853
1054
  }
1055
+ // Capture before any asynchronous lookup, unless the caller already captured
1056
+ // the fence before adopting the Session.
1057
+ let conversationGeneration = expectedConversationGeneration === undefined
1058
+ ? this.conversationGenerationFor(id, conversationKey)
1059
+ : expectedConversationGeneration;
1060
+ const assertConversationCurrent = () => {
1061
+ if (conversationGeneration !== this.conversationGenerationFor(id, conversationKey)) {
1062
+ throw workspaceSessionStale(
1063
+ 'The conversation workspace changed before the session binding could be committed.',
1064
+ );
1065
+ }
1066
+ };
854
1067
  const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value));
855
1068
  return this.#enqueue(id, async () => {
856
1069
  if (!this.has(id)
@@ -865,14 +1078,41 @@ export class BotWorkspaceStore {
865
1078
  'The bot workspace changed before the session binding could be committed.',
866
1079
  );
867
1080
  }
868
-
869
- if (!(await sameWorkspacePath(workspace, this.workspaceFor(id)))) {
1081
+ // An explicit session binding is also a conversation-level statement: a
1082
+ // workspace switch queued while the session was being adopted must not be
1083
+ // silently overwritten by the binding that started before it.
1084
+ assertConversationCurrent();
1085
+ const sameWorkspace = await sameWorkspacePath(workspace, this.workspaceFor(id));
1086
+ const previousOverrides = this.#conversationWorkspaces[id];
1087
+ const override = previousOverrides?.[conversationKey];
1088
+ const clearsOverride = override !== undefined && !(await sameWorkspacePath(workspace, override));
1089
+ assertConversationCurrent();
1090
+ if (!sameWorkspace || clearsOverride) {
1091
+ if (sameWorkspace && typeof clearSession !== 'function') {
1092
+ throw new TypeError('clearSession is required to reconcile a conversation workspace');
1093
+ }
870
1094
  const previous = this.#workspaces[id];
871
- // Fence every session resolved before this transition, then remove
872
- // the old workspace mappings before publishing the new workspace.
873
- this.#generations.set(id, this.#freshGeneration());
874
- await clearSessions();
875
- this.#workspaces[id] = workspace;
1095
+ if (clearsOverride) {
1096
+ // /session keeps its bot-default semantics, but cannot leave this
1097
+ // conversation pinned to a different cwd from the adopted Session.
1098
+ // Publish the new fence to the scope before any asynchronous work.
1099
+ conversationGeneration = this.publishConversationWorkspaceSwitch(id, conversationKey);
1100
+ onConversationGeneration?.(conversationGeneration);
1101
+ }
1102
+ if (!sameWorkspace) {
1103
+ this.#generations.set(id, this.#freshGeneration());
1104
+ await clearSessions();
1105
+ } else {
1106
+ await clearSession(conversationKey);
1107
+ }
1108
+ assertConversationCurrent();
1109
+ if (!sameWorkspace) this.#workspaces[id] = workspace;
1110
+ if (clearsOverride) {
1111
+ const next = { ...previousOverrides };
1112
+ delete next[conversationKey];
1113
+ if (Object.keys(next).length) this.#conversationWorkspaces[id] = next;
1114
+ else delete this.#conversationWorkspaces[id];
1115
+ }
876
1116
  try {
877
1117
  await this.#persist();
878
1118
  } catch (error) {
@@ -880,17 +1120,21 @@ export class BotWorkspaceStore {
880
1120
  // fenced. Restoring either could pair an old session with a state
881
1121
  // transition whose durable outcome is unknown.
882
1122
  this.#workspaces[id] = previous;
1123
+ if (clearsOverride) this.#conversationWorkspaces[id] = previousOverrides;
883
1124
  throw error;
884
1125
  }
885
1126
  }
886
1127
 
887
1128
  // This write remains inside the same bot transition as the workspace
888
1129
  // mutation, so another switch or bind cannot interleave between them.
1130
+ assertConversationCurrent();
889
1131
  await setSession(conversationKey, sessionId);
1132
+ assertConversationCurrent();
890
1133
  return {
891
1134
  workspace,
892
1135
  sessionId,
893
1136
  generation: this.#generations.get(id),
1137
+ conversationGeneration,
894
1138
  };
895
1139
  });
896
1140
  }
@@ -991,6 +1235,7 @@ export class BotWorkspaceStore {
991
1235
  ...Object.keys(this.#contextEnhancement),
992
1236
  ...Object.keys(this.#deliveryTargets),
993
1237
  ...Object.keys(this.#accessPolicies),
1238
+ ...Object.keys(this.#conversationWorkspaces),
994
1239
  ...this.#dirtyRemovals,
995
1240
  ]);
996
1241
  for (const botId of candidates) {
@@ -1028,6 +1273,12 @@ export class BotWorkspaceStore {
1028
1273
  return incarnation;
1029
1274
  }
1030
1275
 
1276
+ #freshConversationGeneration() {
1277
+ const generation = this.#nextConversationGeneration;
1278
+ this.#nextConversationGeneration += 1;
1279
+ return generation;
1280
+ }
1281
+
1031
1282
  #removalDetailsFor(transaction) {
1032
1283
  if (!transaction || typeof transaction !== 'object') {
1033
1284
  throw new TypeError('Invalid workspace removal transaction');
@@ -1045,8 +1296,10 @@ export class BotWorkspaceStore {
1045
1296
  const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
1046
1297
  const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
1047
1298
  const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
1299
+ const hadConversationWorkspaces = Object.hasOwn(this.#conversationWorkspaces, id);
1048
1300
  const needsCleanup = hadWorkspace || hadPreset || hadModel || hadAlias || hadContextEnhancement
1049
- || hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
1301
+ || hadDeliveryTargets || hadAccessPolicy || hadConversationWorkspaces
1302
+ || this.#dirtyRemovals.has(id);
1050
1303
  delete this.#workspaces[id];
1051
1304
  delete this.#agentPresets[id];
1052
1305
  delete this.#models[id];
@@ -1054,6 +1307,7 @@ export class BotWorkspaceStore {
1054
1307
  delete this.#contextEnhancement[id];
1055
1308
  delete this.#deliveryTargets[id];
1056
1309
  delete this.#accessPolicies[id];
1310
+ delete this.#conversationWorkspaces[id];
1057
1311
  this.#generations.delete(id);
1058
1312
  this.#incarnations.delete(id);
1059
1313
  if (!needsCleanup) return {
@@ -1089,10 +1343,12 @@ export class BotWorkspaceStore {
1089
1343
  version = this.#version,
1090
1344
  accessPolicies = this.#accessPolicies,
1091
1345
  aliases = this.#aliases,
1346
+ conversationWorkspaces = this.#conversationWorkspaces,
1092
1347
  ) {
1093
1348
  await writeStoredDocument(this.#path, storedDocument({
1094
1349
  version,
1095
1350
  workspaces: this.#workspaces,
1351
+ conversationWorkspaces,
1096
1352
  agentPresets: this.#agentPresets,
1097
1353
  models: this.#models,
1098
1354
  contextEnhancement,
@@ -1110,7 +1366,8 @@ export class BotWorkspaceStore {
1110
1366
  || Object.keys(this.#aliases).length > 0
1111
1367
  || Object.keys(this.#contextEnhancement).length > 0
1112
1368
  || Object.keys(this.#deliveryTargets).length > 0
1113
- || Object.keys(this.#accessPolicies).length > 0) {
1369
+ || Object.keys(this.#accessPolicies).length > 0
1370
+ || Object.keys(this.#conversationWorkspaces).length > 0) {
1114
1371
  await this.#persist();
1115
1372
  return;
1116
1373
  }
@@ -1246,6 +1503,107 @@ export function createBotWorkspaceScope(
1246
1503
  };
1247
1504
  };
1248
1505
  const sessionGenerations = new Map();
1506
+ // A conversation-level switch/clear publishes an opaque mask token before it
1507
+ // starts its asynchronous work, so a bind or prompt that was already resolved
1508
+ // for that conversation is rejected instead of running in the old workspace.
1509
+ // The store keeps the same token as the conversation's generation, so the
1510
+ // scope marker and the durable generation never disagree.
1511
+ const conversationSwitchMasks = new Map();
1512
+ // The in-flight switch of each conversation, so a message that starts while
1513
+ // /conv is still committing waits for the new workspace instead of resolving
1514
+ // a session in the old one.
1515
+ const pendingConversationSwitches = new Map();
1516
+
1517
+ function trackConversationSwitch(conversationKey, promise) {
1518
+ pendingConversationSwitches.set(conversationKey, promise);
1519
+ return promise.finally(() => {
1520
+ if (pendingConversationSwitches.get(conversationKey) === promise) {
1521
+ pendingConversationSwitches.delete(conversationKey);
1522
+ if (isCurrentScope()) refreshRetainedSession(conversationKey);
1523
+ }
1524
+ });
1525
+ }
1526
+
1527
+ function currentConversationGeneration(conversationKey) {
1528
+ // The store reports "no override recorded yet" as null; map it to an opaque
1529
+ // token so "before the first switch" is still a comparable state.
1530
+ const generation = workspaces.conversationGenerationFor(botId, conversationKey);
1531
+ return generation ?? 0;
1532
+ }
1533
+
1534
+ function maskConversationSwitch(conversationKey, token) {
1535
+ if (!conversationKey) return;
1536
+ conversationSwitchMasks.set(
1537
+ conversationKey,
1538
+ token === undefined ? currentConversationGeneration(conversationKey) : token,
1539
+ );
1540
+ }
1541
+
1542
+ function conversationGenerationIsStale(conversationKey, expected) {
1543
+ if (typeof conversationKey !== 'string' || !conversationKey) return false;
1544
+ const mask = conversationSwitchMasks.get(conversationKey);
1545
+ if (mask !== undefined && mask !== currentConversationGeneration(conversationKey)) {
1546
+ return true;
1547
+ }
1548
+ if (expected === undefined) return false;
1549
+ return expected !== currentConversationGeneration(conversationKey);
1550
+ }
1551
+
1552
+ function generationIsStale(entry, conversationKey = entry?.conversationKey) {
1553
+ return Boolean(entry)
1554
+ && ((entry.generation !== undefined && entry.generation !== workspaces.generationFor(botId))
1555
+ || (entry.conversationKey === conversationKey
1556
+ && conversationGenerationIsStale(conversationKey, entry.conversationGeneration)));
1557
+ }
1558
+
1559
+ /**
1560
+ * Refresh provenance only for a mapping that survived an unchanged/failed
1561
+ * switch. Removed mappings and pending creates retain their old fence, so a
1562
+ * delayed setSession cannot resurrect them after the switch finishes.
1563
+ */
1564
+ function refreshRetainedSession(conversationKey) {
1565
+ const sessionId = state.sessionFor?.(conversationKey);
1566
+ const entry = sessionGenerations.get(sessionId);
1567
+ if (entry?.conversationKey === conversationKey
1568
+ && entry.generation === workspaces.generationFor(botId)) {
1569
+ sessionGenerations.set(sessionId, {
1570
+ ...entry,
1571
+ conversationGeneration: currentConversationGeneration(conversationKey),
1572
+ });
1573
+ }
1574
+ }
1575
+
1576
+ async function conversationSessionMatchesWorkspace(conversationKey, workspace) {
1577
+ const sessionId = state.sessionFor?.(conversationKey);
1578
+ if (!sessionId) return true;
1579
+ let sessionWorkspace = sessionGenerations.get(sessionId)?.workspace;
1580
+ let matches = false;
1581
+ if (!sessionWorkspace && typeof harness.rpc === 'function') {
1582
+ // Read registration metadata only: adopting a Session is not a lookup.
1583
+ // Resolve by id before comparing real paths so symlink pins remain valid.
1584
+ const listed = await harness.rpc('workspace.list', {}, 30_000);
1585
+ if (!Array.isArray(listed?.items)) {
1586
+ throw new TypeError('Harness returned an invalid workspace list');
1587
+ }
1588
+ const owners = listed.items.filter((item) => Array.isArray(item?.sessionIds)
1589
+ && item.sessionIds.includes(sessionId));
1590
+ if (owners.length === 1 && typeof owners[0].path === 'string' && isAbsolute(owners[0].path)) {
1591
+ sessionWorkspace = owners[0].path;
1592
+ }
1593
+ } else if (!sessionWorkspace && typeof harness.listWorkspaceSessions === 'function') {
1594
+ const listed = await harness.listWorkspaceSessions(await canonicalWorkspacePath(workspace));
1595
+ if (!Array.isArray(listed?.sessions)) {
1596
+ throw new TypeError('Harness returned an invalid workspace session list');
1597
+ }
1598
+ matches = listed.sessions.some((session) => session?.sessionId === sessionId);
1599
+ }
1600
+ if (sessionWorkspace) matches = await sameWorkspacePath(sessionWorkspace, workspace);
1601
+ if (state.sessionFor(conversationKey) !== sessionId) {
1602
+ throw workspaceSessionStale('The session binding changed while its workspace was being checked.');
1603
+ }
1604
+ return matches;
1605
+ }
1606
+
1249
1607
  const scopedHarness = new Proxy(harness, {
1250
1608
  get(target, property) {
1251
1609
  if (property === 'agentPresetSettings') {
@@ -1341,6 +1699,73 @@ export function createBotWorkspaceScope(
1341
1699
  });
1342
1700
  };
1343
1701
  }
1702
+ if (property === 'currentConversationWorkspace') {
1703
+ return (conversationKey) => {
1704
+ if (!isCurrentScope()) {
1705
+ const error = new Error('找不到要修改的机器人。');
1706
+ error.code = 'workspace-bot-not-found';
1707
+ throw error;
1708
+ }
1709
+ return workspaces.conversationWorkspaceFor(botId, conversationKey);
1710
+ };
1711
+ }
1712
+ if (property === 'hasConversationWorkspaceOverride') {
1713
+ return (conversationKey) => {
1714
+ assertCurrentBotScope(isCurrentScope);
1715
+ return workspaces.hasConversationWorkspaceOverride(botId, conversationKey);
1716
+ };
1717
+ }
1718
+ if (property === 'pendingConversationWorkspaceSwitch') {
1719
+ // Read-only: callers in the message path wait for a switch that is
1720
+ // still committing before they resolve a session for this conversation.
1721
+ return (conversationKey) => pendingConversationSwitches.get(conversationKey) ?? null;
1722
+ }
1723
+ if (property === 'conversationWorkspaceGeneration') {
1724
+ // Read-only fence token for the conversation's effective workspace.
1725
+ // Callers outside this scope (message bridging) compare it across the
1726
+ // bind and the send so a late switch cannot be outrun.
1727
+ return (conversationKey) => currentConversationGeneration(conversationKey);
1728
+ }
1729
+ if (property === 'switchConversationWorkspace') {
1730
+ return (conversationKey, workspace) => {
1731
+ if (!isCurrentScope()) {
1732
+ const error = new Error('找不到要修改的机器人。');
1733
+ error.code = 'workspace-bot-not-found';
1734
+ return Promise.reject(error);
1735
+ }
1736
+ const token = workspaces.publishConversationWorkspaceSwitch(botId, conversationKey);
1737
+ maskConversationSwitch(conversationKey, token);
1738
+ return trackConversationSwitch(conversationKey, workspaces.applyConversationWorkspaceSwitch(botId, conversationKey, workspace, {
1739
+ token,
1740
+ sessionMatchesWorkspace: (selected) => conversationSessionMatchesWorkspace(conversationKey, selected),
1741
+ clearSession: async () => {
1742
+ await state.clearSession(conversationKey);
1743
+ // A handle resolved before this switch must not stay usable: the
1744
+ // conversation now belongs to another workspace.
1745
+ },
1746
+ incarnation,
1747
+ }));
1748
+ };
1749
+ }
1750
+ if (property === 'clearConversationWorkspace') {
1751
+ return (conversationKey) => {
1752
+ if (!isCurrentScope()) {
1753
+ const error = new Error('找不到要修改的机器人。');
1754
+ error.code = 'workspace-bot-not-found';
1755
+ return Promise.reject(error);
1756
+ }
1757
+ const token = workspaces.publishConversationWorkspaceSwitch(botId, conversationKey);
1758
+ maskConversationSwitch(conversationKey, token);
1759
+ return trackConversationSwitch(conversationKey, workspaces.applyConversationWorkspaceSwitch(botId, conversationKey, null, {
1760
+ token,
1761
+ sessionMatchesWorkspace: (selected) => conversationSessionMatchesWorkspace(conversationKey, selected),
1762
+ clearSession: async () => {
1763
+ await state.clearSession(conversationKey);
1764
+ },
1765
+ incarnation,
1766
+ }));
1767
+ };
1768
+ }
1344
1769
  if (property === 'bindWorkspaceSession') {
1345
1770
  return async (conversationKey, sessionId) => {
1346
1771
  if (typeof conversationKey !== 'string' || !conversationKey
@@ -1356,6 +1781,7 @@ export function createBotWorkspaceScope(
1356
1781
  throw new TypeError('Harness does not support adopting workspace sessions');
1357
1782
  }
1358
1783
  const expectedGeneration = workspaces.generationFor(botId);
1784
+ const expectedConversationGeneration = workspaces.conversationGenerationFor(botId, conversationKey);
1359
1785
  const adopted = await target.adoptWorkspaceSession(sessionId);
1360
1786
  if (!isCurrentScope()) {
1361
1787
  const error = new Error('找不到要修改的机器人。');
@@ -1367,6 +1793,12 @@ export function createBotWorkspaceScope(
1367
1793
  'The bot workspace changed while the session was being adopted.',
1368
1794
  );
1369
1795
  }
1796
+ if (expectedConversationGeneration
1797
+ !== workspaces.conversationGenerationFor(botId, conversationKey)) {
1798
+ throw workspaceSessionStale(
1799
+ 'The conversation workspace changed while the session was being adopted.',
1800
+ );
1801
+ }
1370
1802
  if (!adopted || typeof adopted !== 'object'
1371
1803
  || adopted.sessionId !== sessionId || typeof adopted.workspace !== 'string') {
1372
1804
  throw new TypeError('Harness returned an invalid adopted workspace session');
@@ -1375,21 +1807,31 @@ export function createBotWorkspaceScope(
1375
1807
  conversationKey,
1376
1808
  sessionId,
1377
1809
  clearSessions: () => state.clearSessions(),
1810
+ clearSession: (key) => state.clearSession(key),
1378
1811
  setSession: (key, selectedSessionId) => state.setSession(key, selectedSessionId),
1812
+ onConversationGeneration: (generation) => maskConversationSwitch(conversationKey, generation),
1379
1813
  incarnation,
1380
1814
  expectedGeneration,
1815
+ expectedConversationGeneration,
1381
1816
  });
1382
1817
  if (!isCurrentScope()) {
1383
1818
  const error = new Error('找不到要修改的机器人。');
1384
1819
  error.code = 'workspace-bot-not-found';
1385
1820
  throw error;
1386
1821
  }
1387
- if (bound.generation !== workspaces.generationFor(botId)) {
1822
+ if (bound.generation !== workspaces.generationFor(botId)
1823
+ || bound.conversationGeneration
1824
+ !== workspaces.conversationGenerationFor(botId, conversationKey)) {
1388
1825
  throw workspaceSessionStale(
1389
1826
  'The bot workspace changed before the session binding completed.',
1390
1827
  );
1391
1828
  }
1392
- sessionGenerations.set(sessionId, bound.generation);
1829
+ sessionGenerations.set(sessionId, {
1830
+ generation: bound.generation,
1831
+ workspace: bound.workspace,
1832
+ conversationKey,
1833
+ conversationGeneration: bound.conversationGeneration ?? 0,
1834
+ });
1393
1835
  return {
1394
1836
  ...adopted,
1395
1837
  workspace: bound.workspace,
@@ -1399,21 +1841,41 @@ export function createBotWorkspaceScope(
1399
1841
  }
1400
1842
  if (property === 'createSession') {
1401
1843
  return async (options = {}) => {
1402
- const { inheritBotModel = true, ...createOptions } = options;
1403
- await workspaces.whenBotIdle(botId);
1844
+ const { inheritBotModel = true, conversationKey, ...createOptions } = options;
1845
+ // /conv publishes its generation before path validation enters the
1846
+ // bot queue. Wait for both phases before pairing a workspace with its
1847
+ // generation; otherwise a new session can carry a new fence but old cwd.
1848
+ while (true) {
1849
+ const pendingSwitch = pendingConversationSwitches.get(conversationKey);
1850
+ if (pendingSwitch) await pendingSwitch;
1851
+ await workspaces.whenBotIdle(botId);
1852
+ if (!pendingConversationSwitches.has(conversationKey)) break;
1853
+ }
1404
1854
  if (!isCurrentScope()) {
1405
1855
  const error = new Error('找不到要修改的机器人。');
1406
1856
  error.code = 'workspace-bot-not-found';
1407
1857
  throw error;
1408
1858
  }
1409
1859
  const generation = workspaces.generationFor(botId);
1860
+ const conversationGeneration = conversationKey
1861
+ ? currentConversationGeneration(conversationKey)
1862
+ : null;
1410
1863
  const agentPreset = workspaces.agentPresetFor(botId);
1411
1864
  const model = inheritBotModel === false ? null : workspaces.modelFor(botId);
1865
+ const workspace = workspaces.conversationWorkspaceFor(botId, conversationKey);
1412
1866
  const sessionId = await target.createSession({
1413
1867
  ...createOptions,
1414
- workspace: workspaces.workspaceFor(botId),
1868
+ workspace,
1415
1869
  ...(agentPreset == null ? {} : { agentPreset }),
1416
1870
  });
1871
+ // A conversation-level workspace switch can commit while the session is
1872
+ // being created; never bind a session created in the stale workspace to
1873
+ // a conversation whose override already moved on.
1874
+ if (conversationGenerationIsStale(conversationKey, conversationGeneration)) {
1875
+ throw workspaceSessionStale(
1876
+ 'The conversation workspace changed while the session was being created.',
1877
+ );
1878
+ }
1417
1879
  if (model) {
1418
1880
  if (typeof target.selectSessionModel !== 'function') {
1419
1881
  throw new TypeError('Harness does not support model selection');
@@ -1429,23 +1891,42 @@ export function createBotWorkspaceScope(
1429
1891
  throw error;
1430
1892
  }
1431
1893
  }
1432
- sessionGenerations.set(sessionId, generation);
1894
+ sessionGenerations.set(sessionId, {
1895
+ generation,
1896
+ workspace,
1897
+ conversationKey,
1898
+ conversationGeneration,
1899
+ });
1433
1900
  return sessionId;
1434
1901
  };
1435
1902
  }
1436
1903
  if (property === 'workspaceSession') {
1437
- return (sessionId) => {
1904
+ return (sessionId, sessionConversationKey) => {
1438
1905
  if (typeof sessionId !== 'string' || !sessionId) {
1439
- throw new TypeError('sessionId is required');
1906
+ // A caller whose mapping was cleared under it must retry the
1907
+ // resolution instead of crashing on a handle it cannot use.
1908
+ return null;
1440
1909
  }
1441
- const generation = sessionGenerations.get(sessionId)
1910
+ const entry = sessionGenerations.get(sessionId);
1911
+ const generation = entry?.generation
1442
1912
  ?? workspaces.generationFor(botId);
1443
- // Transfer the mutable provenance entry into this immutable handle.
1444
- // A later handle for the same id captures its own generation instead
1445
- // of sharing deletion or rebinding state with this call.
1446
- sessionGenerations.delete(sessionId);
1913
+ // A session handle that names its conversation also fences the
1914
+ // conversation's effective workspace: a switch that starts after the
1915
+ // bind but before the prompt is sent must not run in the old workspace.
1916
+ const conversationKey = typeof sessionConversationKey === 'string'
1917
+ && sessionConversationKey
1918
+ ? sessionConversationKey
1919
+ : entry?.conversationKey ?? null;
1920
+ const conversationGeneration = conversationKey
1921
+ ? (entry?.conversationKey === conversationKey
1922
+ ? entry.conversationGeneration
1923
+ : currentConversationGeneration(conversationKey))
1924
+ : null;
1925
+ // Copy provenance into the immutable handle, but retain it for a later
1926
+ // setSession (notably /model, which binds after asynchronous selection).
1447
1927
  const isCurrentSession = () => isCurrentScope()
1448
- && generation === workspaces.generationFor(botId);
1928
+ && generation === workspaces.generationFor(botId)
1929
+ && !conversationGenerationIsStale(conversationKey, conversationGeneration);
1449
1930
  const invokeCurrentSession = async (method, args, action) => {
1450
1931
  if (!isCurrentSession()) {
1451
1932
  throw workspaceSessionStale(
@@ -1506,6 +1987,11 @@ export function createBotWorkspaceScope(
1506
1987
  steerActiveTurn(...args) {
1507
1988
  return invokeStartedSessionMutation('steerActiveTurn', args, 'turn steering');
1508
1989
  },
1990
+ ...(typeof target.executeCommand === 'function' ? {
1991
+ executeCommand(...args) {
1992
+ return invokeStartedSessionMutation('executeCommand', args, 'command execution');
1993
+ },
1994
+ } : {}),
1509
1995
  ask(...args) {
1510
1996
  if (!isCurrentSession()) {
1511
1997
  throw workspaceSessionStale(
@@ -1520,9 +2006,8 @@ export function createBotWorkspaceScope(
1520
2006
  if (property === 'sessionExists') {
1521
2007
  return (sessionId, ...args) => {
1522
2008
  if (!isCurrentScope()) return false;
1523
- const generation = sessionGenerations.get(sessionId);
1524
- if (generation !== undefined && generation !== workspaces.generationFor(botId)) {
1525
- sessionGenerations.delete(sessionId);
2009
+ const entry = sessionGenerations.get(sessionId);
2010
+ if (generationIsStale(entry)) {
1526
2011
  return false;
1527
2012
  }
1528
2013
  return target.sessionExists(sessionId, ...args);
@@ -1530,10 +2015,8 @@ export function createBotWorkspaceScope(
1530
2015
  }
1531
2016
  if (property === 'ask') {
1532
2017
  return (sessionId, ...args) => {
1533
- const generation = sessionGenerations.get(sessionId);
1534
- sessionGenerations.delete(sessionId);
1535
- if (!isCurrentScope()
1536
- || (generation !== undefined && generation !== workspaces.generationFor(botId))) {
2018
+ const entry = sessionGenerations.get(sessionId);
2019
+ if (!isCurrentScope() || generationIsStale(entry)) {
1537
2020
  const error = new Error('The bot workspace changed before this prompt started.');
1538
2021
  error.code = WORKSPACE_SESSION_STALE;
1539
2022
  throw error;
@@ -1543,10 +2026,8 @@ export function createBotWorkspaceScope(
1543
2026
  }
1544
2027
  if (property === 'executeCommand' && typeof target.executeCommand === 'function') {
1545
2028
  return (sessionId, ...args) => {
1546
- const generation = sessionGenerations.get(sessionId);
1547
- sessionGenerations.delete(sessionId);
1548
- if (!isCurrentScope()
1549
- || (generation !== undefined && generation !== workspaces.generationFor(botId))) {
2029
+ const entry = sessionGenerations.get(sessionId);
2030
+ if (!isCurrentScope() || generationIsStale(entry)) {
1550
2031
  const error = new Error('The bot workspace changed before this command started.');
1551
2032
  error.code = WORKSPACE_SESSION_STALE;
1552
2033
  throw error;
@@ -1565,18 +2046,29 @@ export function createBotWorkspaceScope(
1565
2046
  return (key, ...args) => {
1566
2047
  if (!isCurrentScope()) return null;
1567
2048
  const sessionId = target.sessionFor(key, ...args);
1568
- if (sessionId && !sessionGenerations.has(sessionId)) {
1569
- sessionGenerations.set(sessionId, workspaces.generationFor(botId));
2049
+ if (sessionId) {
2050
+ const entry = sessionGenerations.get(sessionId);
2051
+ if (generationIsStale(entry, key)) {
2052
+ // The conversation's effective workspace already moved on, so the
2053
+ // stored mapping must not be treated as a usable session. The
2054
+ // caller re-resolves one in the current workspace instead.
2055
+ return null;
2056
+ }
2057
+ if (!entry) {
2058
+ sessionGenerations.set(sessionId, {
2059
+ generation: workspaces.generationFor(botId),
2060
+ conversationKey: key,
2061
+ conversationGeneration: currentConversationGeneration(key),
2062
+ });
2063
+ }
1570
2064
  }
1571
2065
  return sessionId;
1572
2066
  };
1573
2067
  }
1574
2068
  if (property === 'setSession') {
1575
2069
  return (key, sessionId, ...args) => {
1576
- const generation = sessionGenerations.get(sessionId);
1577
- if (!isCurrentScope()
1578
- || (generation !== undefined && generation !== workspaces.generationFor(botId))) {
1579
- sessionGenerations.delete(sessionId);
2070
+ const entry = sessionGenerations.get(sessionId);
2071
+ if (!isCurrentScope() || generationIsStale(entry, key)) {
1580
2072
  return false;
1581
2073
  }
1582
2074
  return target.setSession(key, sessionId, ...args);