@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
@@ -35,6 +35,11 @@ export const SHARED_COMMAND_CATALOG = Object.freeze([
35
35
  defineCatalogCommand('workspace', '切换工作区', [
36
36
  '/workspace 工作区序号或绝对路径 切换工作区',
37
37
  ], { aliases: ['ws'] }),
38
+ defineCatalogCommand('conv', '设置当前对话专属工作区', [
39
+ '/conv 或 /conversation 查看当前对话工作区',
40
+ '/conv 工作区绝对路径或序号 设置当前对话专属工作区',
41
+ '/conv clear 清除专属工作区,回到 bot 默认工作区',
42
+ ], { aliases: ['conversation', 'thread'] }),
38
43
  defineCatalogCommand('workspacelist', '列出工作区绝对路径', [
39
44
  '/workspacelist 列出工作区绝对路径',
40
45
  '/ws、/wsl、/workspaces 工作区命令别名',
@@ -80,11 +80,21 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
80
80
  if (typeof sessionId !== 'string' || !sessionId) {
81
81
  return commandResult(t('当前聊天还没有可压缩的会话,请先发送一条消息。'));
82
82
  }
83
- if (typeof harness?.executeCommand !== 'function') {
84
- return commandResult(t('当前机器人暂不支持上下文压缩。'));
85
- }
86
83
  try {
87
- const execution = await harness.executeCommand(sessionId, '/compact', options);
84
+ let execution;
85
+ if (typeof harness?.workspaceSession === 'function') {
86
+ const session = harness.workspaceSession(sessionId, conversationKey);
87
+ if (typeof session?.executeCommand !== 'function') {
88
+ return commandResult(t('当前机器人暂不支持上下文压缩。'));
89
+ }
90
+ execution = await session.executeCommand('/compact', options);
91
+ } else {
92
+ // Legacy Harnesses have no scoped Session handle to carry the route fence.
93
+ if (typeof harness?.executeCommand !== 'function') {
94
+ return commandResult(t('当前机器人暂不支持上下文压缩。'));
95
+ }
96
+ execution = await harness.executeCommand(sessionId, '/compact', options);
97
+ }
88
98
  if (execution === undefined) {
89
99
  return commandResult(t('当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。'));
90
100
  }
@@ -24,7 +24,7 @@ function boundSession(harness, state, key) {
24
24
  if (typeof harness?.workspaceSession !== 'function') {
25
25
  throw new TypeError('Harness does not support workspace sessions');
26
26
  }
27
- const session = harness.workspaceSession(sessionId);
27
+ const session = harness.workspaceSession(sessionId, key);
28
28
  if (!session || typeof session !== 'object') {
29
29
  throw new TypeError('Harness returned an invalid workspace session');
30
30
  }
@@ -128,7 +128,7 @@ export function createDeferredDeliveryCoordinator({
128
128
  entry = { ...entry, turn: outcome.turn };
129
129
  }
130
130
  if (stopping) {
131
- const session = harness.workspaceSession?.(entry.sessionId);
131
+ const session = harness.workspaceSession?.(entry.sessionId, entry.key);
132
132
  const stopped = typeof session?.stopDeferredTurn === 'function'
133
133
  ? await session.stopDeferredTurn({ turn: entry.turn, promptRpcId: entry.promptRpcId }, {
134
134
  signal: activeSignal, isCurrent: () => bound(entry),
@@ -124,7 +124,7 @@ export async function runHistoryCommand(text, harness, state, key, {
124
124
  }
125
125
 
126
126
  try {
127
- const session = harness?.workspaceSession?.(sessionId);
127
+ const session = harness?.workspaceSession?.(sessionId, key);
128
128
  if (typeof session?.readHistory !== 'function') {
129
129
  return commandResult(t('当前 Harness 暂不支持读取会话历史。'));
130
130
  }
@@ -5,4 +5,6 @@ export default {
5
5
  'The Discord Gateway Intents are misconfigured. Please check the Bot settings in the Developer Portal.',
6
6
  'Discord机器人': 'Discord Bot',
7
7
  ' Gateway 长连接': ' Gateway long-lived connection',
8
+ 'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。':
9
+ 'The Thread creation result cannot be confirmed yet. If the Thread was created, retry inside it; if it was not, mention the bot again shortly.',
8
10
  };
@@ -125,6 +125,47 @@ export default {
125
125
  '/compact 压缩当前会话的较早上下文': '/compact Compact the earlier context of the current session',
126
126
  '/workspace 工作区序号或绝对路径 切换工作区':
127
127
  '/workspace <workspace index or absolute path> Switch workspace',
128
+ '设置当前对话专属工作区': 'Set a workspace dedicated to this conversation',
129
+ '/conv 或 /conversation 查看当前对话工作区':
130
+ '/conv or /conversation Show the workspace of this conversation',
131
+ '/conv 工作区绝对路径或序号 设置当前对话专属工作区':
132
+ '/conv <workspace absolute path or index> Set a workspace dedicated to this conversation',
133
+ '/conv clear 清除专属工作区,回到 bot 默认工作区':
134
+ '/conv clear Clear the dedicated workspace and fall back to the bot default',
135
+ '对话专属:/conv 工作区序号或绝对路径(仅影响当前对话)':
136
+ 'Dedicated to this conversation: /conv <workspace index or absolute path> (affects this conversation only)',
137
+ '当前对话工作区:{workspace}': 'This conversation uses the workspace: {workspace}',
138
+ '状态:已为该对话显式绑定,之后修改 bot 默认工作区不会影响本对话。':
139
+ 'Status: explicitly bound for this conversation; changing the bot default workspace later will not affect it.',
140
+ '状态:未显式绑定,当前跟随 bot 默认工作区。':
141
+ 'Status: not explicitly bound; this conversation currently follows the bot default workspace.',
142
+ '当前对话工作区已切换为:{workspace}': 'This conversation now uses the workspace: {workspace}',
143
+ '已清除对话专属工作区,当前使用 bot 默认工作区:{workspace}(之后默认工作区的变化会同步到本对话)':
144
+ 'Cleared the dedicated workspace. This conversation now uses the bot default: {workspace} (later changes to that default follow here as well)',
145
+ '可切换的工作区({count}):': 'Available workspaces ({count}):',
146
+ '用法:/conv 工作区序号或绝对路径': 'Usage: /conv <workspace index or absolute path>',
147
+ '清除:/conv clear': 'Clear: /conv clear',
148
+ '{message}\n用法:/conv 工作区绝对路径': '{message}\nUsage: /conv <workspace absolute path>',
149
+ '当前机器人暂不支持按对话设置专属工作区。':
150
+ 'This bot does not support per-conversation workspaces yet.',
151
+ '当前机器人暂不支持设置对话工作区。':
152
+ 'This bot does not support setting a conversation workspace yet.',
153
+ '当前消息缺少可设置的对话上下文。':
154
+ 'This message has no conversation context to bind a workspace to.',
155
+ '暂时无法读取当前对话工作区,请稍后重试。':
156
+ 'This conversation workspace is temporarily unavailable. Please try again later.',
157
+ '暂时无法清除对话工作区,请稍后重试。':
158
+ 'The conversation workspace could not be cleared right now. Please try again later.',
159
+ '机器人正在移除或已重新接入,无法读取对话工作区。':
160
+ 'The bot is being removed or was reconnected, so the conversation workspace cannot be read.',
161
+ '机器人正在移除或已重新接入,无法清除对话工作区。':
162
+ 'The bot is being removed or was reconnected, so the conversation workspace cannot be cleared.',
163
+ '机器人正在移除或已重新接入,无法切换对话工作区。':
164
+ 'The bot is being removed or was reconnected, so the conversation workspace cannot be switched.',
165
+ '不带工作区参数时,/sessionlist 默认列出当前对话的有效工作区。':
166
+ 'Without a workspace argument, /sessionlist lists the workspace this conversation effectively uses.',
167
+ '不带工作区参数时,默认列出当前对话的有效工作区(未设置对话专属工作区时即 bot 默认工作区)。':
168
+ 'Without a workspace argument it lists the workspace this conversation effectively uses (the bot default when no conversation workspace is set).',
128
169
  '/workspacelist 列出工作区绝对路径': '/workspacelist List absolute workspace paths',
129
170
  '/ws、/wsl、/workspaces 工作区命令别名': '/ws, /wsl, /workspaces Workspace command aliases',
130
171
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题':
@@ -1,5 +1,10 @@
1
1
  // English translations (telegram area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ // Terminal status written back over a placeholder whose in-place edit was
4
+ // rejected, so it is the last thing a reader sees on a degraded reply.
5
+ '回复已发送。': 'The reply was sent.',
6
+ '回复发送结果未能确认。': 'The reply delivery result could not be confirmed.',
7
+ '消息发送失败,请稍后重试。': 'The message could not be sent. Try again later.',
3
8
  '开启一个全新会话': 'Start a brand-new Session',
4
9
  '压缩当前会话的较早上下文': 'Compact the earlier context of the current Session',
5
10
  '切换工作区': 'Switch Workspace',
@@ -1,5 +1,7 @@
1
+ import diagnostics from '../../weixin/connection-error.en.mjs';
1
2
  // English translations (weixin area). Keys are exact Chinese literals passed to t().
2
3
  export default {
4
+ ...diagnostics,
3
5
  // weixin-bridge.mjs
4
6
  '微信已连接 DeepSeek Harness。': 'WeChat is connected to DeepSeek Harness.',
5
7
  '结果文件「{name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。': 'The result file "{name}" was generated, but the WeChat bot currently has no permission to send file messages. Please check the bot\'s file messaging capability.',
@@ -10,17 +10,60 @@ import { EN } from './i18n-en.mjs';
10
10
 
11
11
  let language = 'zh';
12
12
 
13
+ const listeners = new Set();
14
+
13
15
  // Accepts 'en', 'en-US', 'english' (any case) as English; anything else
14
- // (including undefined and unrecognized values) selects Chinese.
15
- export function setImHostLanguage(lang) {
16
+ // (including undefined and unrecognized values) selects Chinese. Pure: use it
17
+ // to judge a candidate tag without switching the active language.
18
+ export function normalizeImHostLanguage(lang) {
16
19
  const normalized = typeof lang === 'string' ? lang.trim().toLowerCase() : '';
17
- language = normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized) ? 'en' : 'zh';
20
+ return normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized) ? 'en' : 'zh';
21
+ }
22
+
23
+ /**
24
+ * Select the language of every host-side message. Subscribers registered
25
+ * through onImHostLanguageChange are notified only when the resolved language
26
+ * actually changes, so re-applying the same selection in a different spelling
27
+ * (or an unrecognized tag that keeps falling back to Chinese) is free.
28
+ */
29
+ export function setImHostLanguage(lang) {
30
+ const next = normalizeImHostLanguage(lang);
31
+ if (next === language) return language;
32
+ const previous = language;
33
+ language = next;
34
+ // Snapshot first: a subscriber may unsubscribe (or subscribe) while running.
35
+ for (const listener of [...listeners]) {
36
+ if (!listeners.has(listener)) continue;
37
+ try {
38
+ listener(next, previous);
39
+ } catch {
40
+ // Each subscriber owns its own diagnostics; one that fails must not
41
+ // strand the rest, nor abandon a language switch that already happened.
42
+ }
43
+ }
44
+ return language;
18
45
  }
19
46
 
20
47
  export function getImHostLanguage() {
21
48
  return language;
22
49
  }
23
50
 
51
+ /**
52
+ * Observe committed changes to the host message language. Platform-side
53
+ * surfaces that were localized once at connect time (the Telegram command
54
+ * menu, for example) re-synchronize from here instead of waiting for a
55
+ * reconnect. Subscribers must not throw; the disposer is idempotent.
56
+ */
57
+ export function onImHostLanguageChange(listener) {
58
+ if (typeof listener !== 'function') {
59
+ throw new TypeError('onImHostLanguageChange requires a listener function');
60
+ }
61
+ listeners.add(listener);
62
+ return () => {
63
+ listeners.delete(listener);
64
+ };
65
+ }
66
+
24
67
  // Translate a user-facing Chinese literal. In zh mode (the default) this is
25
68
  // the identity function. Optional `params` fills `{name}` placeholders in
26
69
  // both the Chinese key and its translation, e.g.
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ import { normalizeInterfaceLanguageTag } from './interface-language.mjs';
6
+
7
+ const DOCUMENT_VERSION = 1;
8
+
9
+ function invalidTagError() {
10
+ const error = new Error('Invalid DSH interface language tag.');
11
+ error.code = 'interface-language-invalid';
12
+ return error;
13
+ }
14
+
15
+ // Mirrors the atomic settings writes of the inbound attachment TTL store and
16
+ // the update service: create a private temporary file, then rename it in.
17
+ async function writeSettingsDocument(path, document) {
18
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
19
+ const temporary = `${path}.${randomUUID()}.tmp`;
20
+ try {
21
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
22
+ encoding: 'utf8',
23
+ mode: 0o600,
24
+ flag: 'wx',
25
+ });
26
+ await rename(temporary, path);
27
+ } finally {
28
+ await unlink(temporary).catch((error) => {
29
+ if (error.code !== 'ENOENT') throw error;
30
+ });
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Durable mirror of the DSH interface language, so bot messages keep following
36
+ * it across Host restarts and before any browser has connected. Only the
37
+ * lowest resolution layer lives here (see ./interface-language.mjs): an
38
+ * unreadable document resolves to "nothing mirrored" rather than pinning a
39
+ * language nobody chose.
40
+ */
41
+ export class InterfaceLanguageStore {
42
+ #path;
43
+ #tag = null;
44
+ // Whether the document on disk is known to already say what #tag says. False
45
+ // for a missing or unreadable document, so the next report repairs it.
46
+ #stored = false;
47
+
48
+ constructor(path) {
49
+ if (typeof path !== 'string' || !path) {
50
+ throw new TypeError('interface language store path is required');
51
+ }
52
+ this.#path = path;
53
+ }
54
+
55
+ async load() {
56
+ let raw;
57
+ try {
58
+ raw = await readFile(this.#path, 'utf8');
59
+ } catch (error) {
60
+ if (error?.code !== 'ENOENT') throw error;
61
+ this.#tag = null;
62
+ this.#stored = false;
63
+ await this.#removeStaleTemporaries();
64
+ return this;
65
+ }
66
+ const read = this.#readTag(raw);
67
+ this.#tag = read === undefined ? null : read;
68
+ this.#stored = read !== undefined;
69
+ await this.#removeStaleTemporaries();
70
+ return this;
71
+ }
72
+
73
+ // Crash leftovers from an interrupted atomic write are unreferenced by
74
+ // anyone; remove them so the settings directory stays clean.
75
+ async #removeStaleTemporaries() {
76
+ const directory = dirname(this.#path);
77
+ const prefix = `${basename(this.#path)}.`;
78
+ try {
79
+ const entries = await readdir(directory);
80
+ await Promise.all(entries
81
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
82
+ .map((name) => unlink(join(directory, name)).catch(() => {})));
83
+ } catch {
84
+ // A missing directory or concurrent removal is fine; cleanup is best-effort.
85
+ }
86
+ }
87
+
88
+ // Returns the mirrored tag (null when the document mirrors nothing), or
89
+ // undefined when the document is damaged or from an unknown future version.
90
+ #readTag(raw) {
91
+ let document;
92
+ try {
93
+ document = JSON.parse(raw);
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ if (!document || typeof document !== 'object' || Array.isArray(document)) return undefined;
98
+ if (document.version !== DOCUMENT_VERSION) return undefined;
99
+ if (document.interfaceLanguage === undefined) return null;
100
+ return normalizeInterfaceLanguageTag(document.interfaceLanguage) ?? undefined;
101
+ }
102
+
103
+ getLanguageTag() {
104
+ return this.#tag;
105
+ }
106
+
107
+ /**
108
+ * Record the interface language reported by the settings UI. Passing null
109
+ * clears the mirror, returning resolution to the layers above it. The
110
+ * settings page reports its locale on every mount, so an unchanged value is
111
+ * accepted without rewriting the document.
112
+ */
113
+ async setLanguageTag(value) {
114
+ const tag = value === null || value === undefined
115
+ ? null
116
+ : normalizeInterfaceLanguageTag(value);
117
+ if (tag === null && value !== null && value !== undefined) throw invalidTagError();
118
+ if (tag === this.#tag && this.#stored) return tag;
119
+ await writeSettingsDocument(this.#path, {
120
+ version: DOCUMENT_VERSION,
121
+ ...(tag === null ? {} : { interfaceLanguage: tag }),
122
+ });
123
+ this.#tag = tag;
124
+ this.#stored = true;
125
+ return tag;
126
+ }
127
+ }
@@ -0,0 +1,51 @@
1
+ // Resolution policy for the DSH interface language that dsh-im's bot messages
2
+ // follow.
3
+ //
4
+ // The host message language (./i18n.mjs) is a two-value switch: English, or
5
+ // Chinese as the always-available fallback. The DSH interface language is a
6
+ // BCP 47-style tag that a language pack may extend beyond the shipped zh/en
7
+ // pair, so a tag is carried verbatim through resolution and persistence and is
8
+ // collapsed to a dictionary language only where it reaches setImHostLanguage().
9
+
10
+ /** Tag shape accepted by DSH's own locale preference (@deepseek-ai/dsh-client-locale). */
11
+ const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
12
+
13
+ /**
14
+ * Resolution layers, highest precedence first.
15
+ *
16
+ * - `config`: the plugin's own `language` option (or `DSH_IM_LANGUAGE`). An
17
+ * operator who pinned a language in the Host composition keeps it, whatever
18
+ * any individual browser reads the interface in.
19
+ * - `settings`: the explicit selection in DSH's Language row, read from the
20
+ * Host user-settings document. This is the setting users mean by "DSH is set
21
+ * to English".
22
+ * - `mirror`: the last effective interface locale reported by the settings UI.
23
+ * DSH stores nothing when the interface language came from the browser's
24
+ * language list, so without this layer a reader who never opened the
25
+ * Language row would still be answered in Chinese.
26
+ */
27
+ export const HOST_LANGUAGE_SOURCES = Object.freeze(['config', 'settings', 'mirror']);
28
+
29
+ /**
30
+ * Normalize one interface-language tag. Surrounding whitespace is trimmed;
31
+ * anything that is not a BCP 47-style tag returns null so a malformed layer is
32
+ * skipped rather than silently overriding the layers below it.
33
+ */
34
+ export function normalizeInterfaceLanguageTag(value) {
35
+ if (typeof value !== 'string') return null;
36
+ const trimmed = value.trim();
37
+ return LANGUAGE_TAG.test(trimmed) ? trimmed : null;
38
+ }
39
+
40
+ /**
41
+ * Pick the winning interface-language tag across the layers, in
42
+ * HOST_LANGUAGE_SOURCES order. Returns `{ tag: null, source: 'default' }` when
43
+ * no layer names a usable tag, which keeps Chinese as the shipped default.
44
+ */
45
+ export function resolveHostLanguageTag(layers = {}) {
46
+ for (const source of HOST_LANGUAGE_SOURCES) {
47
+ const tag = normalizeInterfaceLanguageTag(layers[source]);
48
+ if (tag !== null) return { tag, source };
49
+ }
50
+ return { tag: null, source: 'default' };
51
+ }
@@ -443,7 +443,7 @@ async function boundSession(harness, state, key, options) {
443
443
  if (typeof harness?.workspaceSession !== 'function') {
444
444
  throw new TypeError('Harness does not support workspace sessions');
445
445
  }
446
- const session = harness.workspaceSession(sessionId);
446
+ const session = harness.workspaceSession(sessionId, key);
447
447
  if (!session || typeof session.sessionExists !== 'function') {
448
448
  throw new TypeError('Harness returned an invalid workspace session');
449
449
  }
@@ -705,11 +705,13 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
705
705
  throw new TypeError('Harness cannot create a conversation session');
706
706
  }
707
707
  // An explicit choice must remain usable even when the saved bot model expires.
708
- const sessionId = await harness.createSession({ ...requestOptions, inheritBotModel: false });
708
+ const sessionId = await harness.createSession({
709
+ ...requestOptions, inheritBotModel: false, conversationKey: key,
710
+ });
709
711
  if (typeof sessionId !== 'string' || !sessionId) {
710
712
  throw new TypeError('Harness returned an invalid session id');
711
713
  }
712
- const session = harness.workspaceSession(sessionId);
714
+ const session = harness.workspaceSession(sessionId, key);
713
715
  applied = await selectAndVerifyModel(session, selection, requestOptions);
714
716
  const currentSessionId = state.sessionFor(key);
715
717
  if (typeof currentSessionId === 'string' && currentSessionId) {
@@ -324,6 +324,32 @@ export class TokenBotController {
324
324
  };
325
325
  }
326
326
 
327
+ /**
328
+ * Re-synchronize the platform-side command menu of every connected bot.
329
+ *
330
+ * Called when the host message language changes: a menu the platform stored
331
+ * at connect time would otherwise keep the previous language until the bot
332
+ * reconnected. Runtimes of channels without a platform-side menu expose no
333
+ * refresh hook and are skipped, and one bot's failure never hides the rest.
334
+ * @returns the number of bots that accepted a refreshed menu.
335
+ */
336
+ async refreshCommandMenus() {
337
+ if (this.#closed) return 0;
338
+ const refreshed = await Promise.all([...this.#runtimes].map(async ([botId, runtime]) => {
339
+ if (typeof runtime?.refreshCommandMenu !== 'function') return false;
340
+ try {
341
+ return await runtime.refreshCommandMenu() === true;
342
+ } catch (error) {
343
+ this.#logger.warn?.(
344
+ `[dsh-im:${this.#descriptor.key}] bot ${botId} command menu refresh failed:`,
345
+ error,
346
+ );
347
+ return false;
348
+ }
349
+ }));
350
+ return refreshed.filter(Boolean).length;
351
+ }
352
+
327
353
  async close() {
328
354
  if (this.#closed) return;
329
355
  this.#closed = true;