@xmanrui/dsh-im 2.0.1 → 2.1.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.
@@ -14,6 +14,7 @@ const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.a
14
14
  const CALLBACK_PROBE_TIMEOUT_NOTICE = '⚠️ 修复验证超时:未收到测试卡按钮的 card.action.trigger,不能确认按钮已修复。请不要重复授权;先检查飞书开放平台的卡片回调配置,确认后再发送 /repair。';
15
15
  const CALLBACK_PROBE_SEND_FAILURE_NOTICE = '⚠️ 修复验证失败:无法发送专用测试卡,不能确认 card.action.trigger 已恢复。请不要重复授权;先检查机器人消息权限和连接状态。';
16
16
  const CALLBACK_PROBE_ABORT_NOTICE = '⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。';
17
+ const REUSABLE_WS_STATES = new Set(['connected', 'connecting', 'reconnecting']);
17
18
 
18
19
  function nonEmptyString(value) {
19
20
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -24,6 +25,15 @@ function strictCardOperatorOpenId(event) {
24
25
  ?? nonEmptyString(event?.operator?.operator_id?.open_id);
25
26
  }
26
27
 
28
+ function websocketState(wsClient, fallback) {
29
+ try {
30
+ const state = wsClient?.getConnectionStatus?.()?.state;
31
+ return typeof state === 'string' && state ? state : fallback;
32
+ } catch {
33
+ return fallback;
34
+ }
35
+ }
36
+
27
37
  function probeError(code, message) {
28
38
  const error = new Error(message);
29
39
  error.code = code;
@@ -104,6 +114,7 @@ export class FeishuRuntime {
104
114
  #bridge = null;
105
115
  #wsClient = null;
106
116
  #starting = null;
117
+ #stopping = null;
107
118
  #abortController = null;
108
119
  #pendingCardActionProbes = new Map();
109
120
  #status;
@@ -170,25 +181,59 @@ export class FeishuRuntime {
170
181
  }
171
182
 
172
183
  async start() {
173
- if (this.#wsClient && this.#status.ready) return this.status;
174
- if (this.#starting) return this.#starting;
184
+ while (true) {
185
+ while (this.#stopping) await this.#stopping;
186
+ if (this.#starting) return this.#starting;
187
+
188
+ const wsClient = this.#wsClient;
189
+ if (wsClient) {
190
+ const state = websocketState(wsClient, this.#status.feishuLongConnectionState);
191
+ if (REUSABLE_WS_STATES.has(state)) return this.status;
192
+
193
+ await this.stop({ preserveError: state === 'failed' });
194
+ continue;
195
+ }
196
+
197
+ // A partial/failed attempt may have created resources before its
198
+ // WSClient became observable. Drain them before assigning a new attempt.
199
+ if (this.#client || this.#bridge || this.#abortController) {
200
+ await this.stop({
201
+ preserveError: this.#status.feishuLongConnectionState === 'failed',
202
+ });
203
+ continue;
204
+ }
205
+
206
+ break;
207
+ }
175
208
 
176
- this.#starting = this.#start().finally(() => {
177
- this.#starting = null;
209
+ let starting;
210
+ starting = this.#start().finally(() => {
211
+ if (this.#starting === starting) this.#starting = null;
178
212
  });
179
- return this.#starting;
213
+ this.#starting = starting;
214
+ return starting;
180
215
  }
181
216
 
182
217
  async #start() {
183
218
  const abortController = new AbortController();
184
219
  this.#abortController = abortController;
185
220
  const { signal } = abortController;
221
+ const abortError = () => (
222
+ signal.reason ?? new DOMException('Feishu runtime stopped', 'AbortError')
223
+ );
224
+ const isCurrentStart = () => (
225
+ !signal.aborted && this.#abortController === abortController
226
+ );
227
+ const assertCurrentStart = () => {
228
+ if (!isCurrentStart()) throw abortError();
229
+ };
186
230
  this.#status.startedAt = new Date().toISOString();
187
231
  this.#status.feishuLongConnectionState = 'connecting';
188
232
  this.#status.lastError = null;
189
233
 
190
234
  try {
191
235
  await this.#harness.ensureRunning({ signal });
236
+ assertCurrentStart();
192
237
  this.#status.harnessReachable = true;
193
238
 
194
239
  const sdkDomain = this.#domain === 'lark'
@@ -204,13 +249,14 @@ export class FeishuRuntime {
204
249
  this.#requestTimeoutMs,
205
250
  );
206
251
  if (httpInstance) larkConfig.httpInstance = httpInstance;
207
- this.#client = new this.#lark.Client(larkConfig);
252
+ const client = new this.#lark.Client(larkConfig);
253
+ this.#client = client;
208
254
  const channel = new VerifiedFeishuChannel({
209
- client: this.#client,
255
+ client,
210
256
  initialText: t('已连接 DeepSeek Harness,正在思考…'),
211
257
  });
212
- this.#bridge = new FeishuHarnessBridge({
213
- client: this.#client,
258
+ const bridge = new FeishuHarnessBridge({
259
+ client,
214
260
  channel,
215
261
  harness: this.#harness,
216
262
  state: this.#state,
@@ -226,22 +272,22 @@ export class FeishuRuntime {
226
272
  signal,
227
273
  logger: this.#logger,
228
274
  });
275
+ this.#bridge = bridge;
229
276
 
230
277
  const dispatcher = new this.#lark.EventDispatcher({}).register({
231
278
  'im.message.receive_v1': (event) => {
232
- this.#bridge.accept(event);
233
- return {};
279
+ if (isCurrentStart()) void bridge.accept(event);
234
280
  },
235
- 'im.message.reaction.created_v1': () => ({}),
236
- 'im.message.reaction.deleted_v1': () => ({}),
281
+ 'im.message.reaction.created_v1': () => undefined,
282
+ 'im.message.reaction.deleted_v1': () => undefined,
237
283
  // Interactive-card button callbacks (only delivered when the app
238
284
  // subscribes card.action.trigger; the number-reply fallback covers
239
285
  // apps that do not).
240
286
  'card.action.trigger': (event) => {
287
+ if (!isCurrentStart()) return;
241
288
  this.#status.cardActionsReceived += 1;
242
289
  this.#status.lastCardActionAt = new Date().toISOString();
243
- if (!this.#consumeCardActionProbe(event)) this.#bridge.onCardAction(event);
244
- return {};
290
+ if (!this.#consumeCardActionProbe(event)) void bridge.onCardAction(event);
245
291
  },
246
292
  });
247
293
 
@@ -249,37 +295,49 @@ export class FeishuRuntime {
249
295
  let settleError;
250
296
  const ready = new Promise((resolve, reject) => {
251
297
  let settled = false;
252
- const timer = setTimeout(() => {
298
+ const onAbort = () => {
299
+ settleError(abortError());
300
+ };
301
+ const settle = (callback, value) => {
253
302
  if (settled) return;
254
303
  settled = true;
255
- reject(new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`));
304
+ clearTimeout(timer);
305
+ signal.removeEventListener('abort', onAbort);
306
+ callback(value);
307
+ };
308
+ const timer = setTimeout(() => {
309
+ settle(
310
+ reject,
311
+ new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`),
312
+ );
256
313
  }, this.#connectTimeoutMs);
257
314
  settleReady = () => {
258
- if (settled) return;
259
- settled = true;
260
- clearTimeout(timer);
261
- resolve();
315
+ settle(resolve);
262
316
  };
263
317
  settleError = (error) => {
264
- if (settled) return;
265
- settled = true;
266
- clearTimeout(timer);
267
- reject(error);
318
+ settle(reject, error);
268
319
  };
320
+ signal.addEventListener('abort', onAbort, { once: true });
321
+ if (signal.aborted) onAbort();
269
322
  });
323
+ // The SDK constructor can throw before Promise.all attaches below.
324
+ // Keep the abort-driven rejection observed in that path as well.
325
+ void ready.catch(() => undefined);
270
326
 
271
- this.#wsClient = new this.#lark.WSClient({
327
+ const wsClient = new this.#lark.WSClient({
272
328
  ...larkConfig,
273
329
  ...(this.#wsAgent ? { agent: this.#wsAgent } : {}),
274
330
  loggerLevel: this.#lark.LoggerLevel.info,
275
- handshakeTimeoutMs: 15000,
331
+ handshakeTimeoutMs: this.#connectTimeoutMs,
276
332
  onReady: () => {
333
+ if (!isCurrentStart()) return;
277
334
  this.#status.feishuLongConnectionState = 'connected';
278
335
  this.#status.ready = true;
279
336
  this.#status.lastError = null;
280
337
  settleReady();
281
338
  },
282
339
  onError: (error) => {
340
+ if (!isCurrentStart()) return;
283
341
  this.#status.feishuLongConnectionState = 'failed';
284
342
  this.#status.ready = false;
285
343
  this.#status.lastError = error?.message ?? String(error);
@@ -287,25 +345,35 @@ export class FeishuRuntime {
287
345
  settleError(error);
288
346
  },
289
347
  onReconnecting: () => {
348
+ if (!isCurrentStart()) return;
290
349
  this.#status.feishuLongConnectionState = 'reconnecting';
291
350
  this.#status.ready = false;
292
351
  },
293
352
  onReconnected: () => {
353
+ if (!isCurrentStart()) return;
294
354
  this.#status.feishuLongConnectionState = 'connected';
295
355
  this.#status.ready = true;
296
356
  this.#status.lastError = null;
297
357
  },
298
358
  });
299
- await this.#wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
300
- settleError(error);
301
- });
302
- await ready;
359
+ this.#wsClient = wsClient;
360
+ const wsStarted = Promise.resolve()
361
+ .then(() => wsClient.start({ eventDispatcher: dispatcher }))
362
+ .catch((error) => {
363
+ settleError(error);
364
+ throw error;
365
+ });
366
+ await Promise.all([wsStarted, ready]);
367
+ assertCurrentStart();
303
368
  return this.status;
304
369
  } catch (error) {
370
+ // stop() owns the terminal idle state for an explicitly aborted start.
371
+ // In particular, do not let the rejected handshake waiter overwrite it.
372
+ if (signal.aborted) throw error;
305
373
  this.#status.ready = false;
306
374
  this.#status.feishuLongConnectionState = 'failed';
307
375
  this.#status.lastError = error?.message ?? String(error);
308
- await this.stop({ preserveError: true });
376
+ await this.#cleanup({ preserveError: true, abortController });
309
377
  throw error;
310
378
  }
311
379
  }
@@ -492,10 +560,30 @@ export class FeishuRuntime {
492
560
  });
493
561
  }
494
562
 
495
- async stop({ preserveError = false } = {}) {
496
- const error = preserveError ? this.#status.lastError : null;
563
+ stop(options = {}) {
564
+ if (this.#stopping) return this.#stopping;
565
+
566
+ let stopping;
567
+ stopping = this.#stop(options).finally(() => {
568
+ if (this.#stopping === stopping) this.#stopping = null;
569
+ });
570
+ this.#stopping = stopping;
571
+ return stopping;
572
+ }
573
+
574
+ async #stop({ preserveError = false } = {}) {
497
575
  const abortController = this.#abortController;
498
- this.#abortController = null;
576
+ if (this.#abortController === abortController) this.#abortController = null;
577
+ abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
578
+
579
+ const starting = this.#starting;
580
+ if (starting) await starting.catch(() => undefined);
581
+ return this.#cleanup({ preserveError, abortController });
582
+ }
583
+
584
+ async #cleanup({ preserveError = false, abortController } = {}) {
585
+ const error = preserveError ? this.#status.lastError : null;
586
+ if (this.#abortController === abortController) this.#abortController = null;
499
587
  abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
500
588
  for (const probe of this.#pendingCardActionProbes.values()) {
501
589
  clearTimeout(probe.timeout);
@@ -508,14 +596,12 @@ export class FeishuRuntime {
508
596
  }
509
597
  this.#pendingCardActionProbes.clear();
510
598
  this.#status.ready = false;
511
- if (this.#wsClient) {
512
- this.#wsClient.close({ force: true });
513
- this.#wsClient = null;
514
- }
515
- if (this.#bridge) {
516
- await this.#bridge.waitForIdle();
517
- this.#bridge = null;
518
- }
599
+ const wsClient = this.#wsClient;
600
+ this.#wsClient = null;
601
+ wsClient?.close({ force: true });
602
+ const bridge = this.#bridge;
603
+ this.#bridge = null;
604
+ if (bridge) await bridge.waitForIdle();
519
605
  this.#client = null;
520
606
  this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
521
607
  this.#status.lastError = error;
@@ -70,7 +70,7 @@ export async function runControlCommand(text, harness, state, key, {
70
70
 
71
71
  const session = boundSession(harness, state, key);
72
72
  if (!session) {
73
- return commandResult(t('当前聊天没有正在运行的任务,请直接发送普通消息。'));
73
+ return commandResult(t('当前聊天没有绑定会话,无法补充指令。请先绑定会话。'));
74
74
  }
75
75
  if (typeof session.steerActiveTurn !== 'function') {
76
76
  throw new TypeError('Harness session does not support steering active turns');
@@ -82,5 +82,5 @@ export async function runControlCommand(text, harness, state, key, {
82
82
  );
83
83
  return steered
84
84
  ? commandResult(t('已提交补充指令,Agent 会在下一步读取。'))
85
- : commandResult(t('当前聊天没有正在运行的任务,请直接发送普通消息。'));
85
+ : commandResult(t('任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。'));
86
86
  }
@@ -223,6 +223,11 @@ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
223
223
  origin: summary?.origin === 'subagent' ? 'subagent' : null,
224
224
  summaryAvailable: summary !== undefined,
225
225
  };
226
+ const lastSeq = summary?.projections?.asOfSeq;
227
+ // This is the projection's durable lower bound, not necessarily the live
228
+ // log tail for a cold session. Harness uses -1 as the legitimate bound
229
+ // for a session with no projected events yet.
230
+ if (Number.isSafeInteger(lastSeq) && lastSeq >= -1) session.lastSeq = lastSeq;
226
231
  const time = sessionTimeMs(summary);
227
232
  if (time !== null) session.time = time;
228
233
  return session;
@@ -131,8 +131,130 @@ export default {
131
131
  '已取消关注「{title}」。': 'Unwatched "{title}".',
132
132
  '取消失败:{message}': 'Could not unwatch: {message}',
133
133
  '飞书交互问题发送失败。': 'Failed to send the Feishu interaction question.',
134
+ '当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。':
135
+ 'The current task is still running. Stop it or wait for it to finish before starting a new session.',
136
+ '请输入补充指令后再提交。': 'Enter an instruction before submitting.',
137
+ '操作过于频繁,请稍后再试。': 'Too many card actions are pending. Please try again shortly.',
138
+ '卡片操作失败,请稍后重试。': 'The card action failed. Please try again later.',
139
+ '请先选择至少一个会话。': 'Select at least one session first.',
140
+ '已批量关注 {count} 个会话。': 'Now watching {count} sessions.',
141
+ '已批量关注 {count} 个会话,另有 {failed} 个未成功。':
142
+ 'Now watching {count} sessions; {failed} could not be processed.',
143
+ '已关注(或已达关注上限)。': 'Sessions are already watched, or the watch limit has been reached.',
144
+ '已取消关注 {count} 个会话。': 'Stopped watching {count} sessions.',
145
+ '已取消关注 {count} 个会话,另有 {failed} 个未成功。':
146
+ 'Stopped watching {count} sessions; {failed} could not be processed.',
147
+ '所选会话均未处理成功,请稍后重试。':
148
+ 'None of the selected sessions could be processed. Please try again later.',
149
+ '所选会话已在关注列表中。': 'The selected sessions are already being watched.',
150
+ '所选会话已不在关注列表中。': 'The selected sessions are no longer in the watch list.',
151
+ '未取消任何关注。': 'No watches were removed.',
152
+ '当前没有绑定的会话,请先从会话列表选择。':
153
+ 'No session is currently bound. Select one from the session list first.',
154
+ '已就绪,直接发消息即可继续当前会话。':
155
+ 'Ready. Send a message to continue the current session.',
156
+ '修复需在私聊中验证接入者身份,请直接发送 /repair 开始。':
157
+ 'Repair must verify the owner in a direct chat. Send /repair there to begin.',
158
+ '暂时无法获取预设列表,请稍后重试。':
159
+ 'Could not load the preset list. Please try again later.',
160
+ '暂时无法获取系统状态,请稍后重试。':
161
+ 'Could not load system status. Please try again later.',
162
+ '连接正常': 'Connected',
163
+ '预设:{preset}': 'Preset: {preset}',
164
+ '未知': 'Unknown',
165
+ '/stop 执行完成。': '/stop completed.',
166
+ '停止任务失败,请稍后重试。': 'Could not stop the task. Please try again later.',
167
+ '已提交补充指令。': 'Instruction submitted.',
168
+ '预设重置失败,请稍后重试。': 'Could not reset the preset. Please try again later.',
169
+ '预设切换失败,请稍后重试。': 'Could not switch the preset. Please try again later.',
134
170
 
135
171
  // feishu/feishu-cards.mjs — interactive cards
172
+ '🤖 助手中心': '🤖 Assistant center',
173
+ '**设置**': '**Settings**',
174
+ '切换会话': 'Switch session',
175
+ '选择会话(当前未绑定)': 'Select a session (none currently bound)',
176
+ '跟随默认': 'Follow default',
177
+ '切换预设': 'Switch preset',
178
+ '切换模型': 'Switch model',
179
+ '当前工作区暂无可用会话。': 'There are no available sessions in the current workspace.',
180
+ '🤖 切换预设': '🤖 Switch preset',
181
+ '🧠 切换模型': '🧠 Switch model',
182
+ '🆕 新会话': '🆕 New session',
183
+ '📋 会话/关注': '📋 Sessions / watches',
184
+ '🗂 工作区列表': '🗂 Workspace list',
185
+ '**任务控制**': '**Task controls**',
186
+ '⏹ 停止': '⏹ Stop',
187
+ '📐 压缩': '📐 Compact',
188
+ '**补充指令**': '**Steer task**',
189
+ '选择补充指令': 'Choose an instruction',
190
+ '继续': 'Continue',
191
+ '加速运行': 'Move faster',
192
+ '总结当前进展': 'Summarize current progress',
193
+ '更简洁些': 'Be more concise',
194
+ '更详细些': 'Be more detailed',
195
+ '✏️ 更多 / 自定义…': '✏️ More / custom…',
196
+ '🗄 归档:{state}': '🗄 Archived: {state}',
197
+ '已显示': 'shown',
198
+ '已隐藏': 'hidden',
199
+ '切换归档显示': 'Toggle archived sessions',
200
+ '📊 状态': '📊 Status',
201
+ '📖 帮助': '📖 Help',
202
+ '**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧修复 · **6**帮助':
203
+ '**Number fallback**\n**1** Workspace list · **2** New session · **3** Session list · **4** Status\n**5** 🔧 Repair · **6** Help',
204
+ '跟随 Host 默认{default}': 'Follow Host default{default}',
205
+ '**当前**:{value}': '**Current**: {value}',
206
+ '**Host 默认**:{value}': '**Host default**: {value}',
207
+ '未设置': 'Not set',
208
+ '选择预设': 'Select a preset',
209
+ '🔄 跟随默认': '🔄 Follow default',
210
+ '当前没有可选择的预设。': 'There are no presets to choose from.',
211
+ '🤖 预设列表': '🤖 Preset list',
212
+ '**当前模型**:{model}': '**Current model**: {model}',
213
+ '选择模型': 'Select a model',
214
+ '🧠 模型列表': '🧠 Model list',
215
+ '{icon} 飞书机器人{state}': '{icon} Feishu bot {state}',
216
+ '已连接': 'connected',
217
+ '未连接': 'disconnected',
218
+ '📂 工作区:`{workspace}`': '📂 Workspace: `{workspace}`',
219
+ '🤖 预设:{preset}': '🤖 Preset: {preset}',
220
+ '🧠 模型:{model}': '🧠 Model: {model}',
221
+ '💬 会话:{count} 个': '💬 Sessions: {count}',
222
+ '📊 系统状态': '📊 System status',
223
+ '📋 会话 / 工作区': '📋 Sessions / workspace',
224
+ '/sessionlist 列出工作区会话': '/sessionlist List workspace sessions',
225
+ '/session ID 绑定已有会话': '/session ID Bind an existing session',
226
+ '/workspacelist 列出工作区': '/workspacelist List workspaces',
227
+ '/workspace 路径 切换工作区': '/workspace PATH Switch workspace',
228
+ '/new 开启全新会话': '/new Start a new session',
229
+ '📊 状态 / 压缩': '📊 Status / compact',
230
+ '/status 连接状态': '/status Connection status',
231
+ '/compact 压缩当前会话上下文': '/compact Compact the current session context',
232
+ '/archived on/off 会话列表显示/隐藏归档': '/archived on/off Show/hide archived sessions',
233
+ '👁 关注': '👁 Watches',
234
+ '/watch ID 关注会话(完成后推送)': '/watch ID Watch a session (push on completion)',
235
+ '/watchlist 关注列表': '/watchlist List watched sessions',
236
+ '/unwatch ID 取消关注': '/unwatch ID Stop watching a session',
237
+ '🤖 预设 / 模型': '🤖 Presets / models',
238
+ '/models 列出模型': '/models List models',
239
+ '/model 2 按序号切换模型': '/model 2 Switch model by index',
240
+ '🎮 任务控制': '🎮 Task controls',
241
+ '/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
242
+ '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
243
+ '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
244
+ '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/model 2` — 切换模型\n`/repair` — 修复卡片按钮':
245
+ '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/model 2` — switch model\n`/repair` — repair card buttons',
246
+ '**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**修复 · **6**帮助':
247
+ '**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Repair · **6** Help',
248
+ '从下方下拉选择补充指令;最后一项可自定义输入。':
249
+ 'Choose an instruction below; the last option lets you enter a custom one.',
250
+ '当前没有绑定会话,请先绑定会话再补充指令。':
251
+ 'No session is bound. Bind one before steering the task.',
252
+ '➕ 补充指令': '➕ Steer task',
253
+ '输入补充指令后点「提交」,发送给当前运行的任务。':
254
+ 'Enter an instruction and press Submit to send it to the running task.',
255
+ '输入你的补充指令': 'Enter your instruction',
256
+ '提交': 'Submit',
257
+ '➕ 自定义指令': '➕ Custom instruction',
136
258
  '🤖 助手菜单': '🤖 Assistant menu',
137
259
  '**点击按钮或直接回复数字**': '**Tap a button or reply with a number**',
138
260
  '1 · 会话列表': '1 · Sessions',
@@ -182,6 +304,18 @@ export default {
182
304
  '任务完成会自动推送,回复数字或点按钮取消关注:':
183
305
  'Completion is pushed automatically. Reply with a number or tap a button to unwatch:',
184
306
  '👁 关注列表': '👁 Watch list',
307
+ '⭐ 取消关注': '⭐ Unwatch',
308
+ '☆ 关注': '☆ Watch',
309
+ '🔍 关注列表': '🔍 Watch list',
310
+ '勾选要关注的会话': 'Select sessions to watch',
311
+ '勾选要取消关注的会话': 'Select sessions to unwatch',
312
+ '当前没有关注的会话。任务完成会自动推送结果。':
313
+ 'No sessions are being watched. Results are pushed automatically when tasks complete.',
314
+ '当前关注 **{count}** 个会话:': 'Currently watching **{count}** sessions:',
315
+ '**➕ 添加关注**(多选下拉勾选)': '**➕ Add watch** (select from the multi-select dropdown)',
316
+ '**➖ 取消关注**(多选下拉勾选)': '**➖ Remove watch** (select from the multi-select dropdown)',
317
+ '📋 会话列表': '📋 Session list',
318
+ '🔙 返回菜单': '🔙 Back to menu',
185
319
  '已完成': 'Completed',
186
320
  '已停止': 'Stopped',
187
321
  '已中止': 'Aborted',
@@ -210,6 +210,10 @@ export default {
210
210
  '已请求停止当前任务。': 'Stop requested for the current task.',
211
211
  '当前聊天没有正在运行的任务,请直接发送普通消息。':
212
212
  'This chat has no running task; just send a regular message.',
213
+ '当前聊天没有绑定会话,无法补充指令。请先绑定会话。':
214
+ 'No session is bound to this chat, so an additional instruction cannot be given. Bind a session first.',
215
+ '任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。':
216
+ 'The task has ended and there is no running task to steer. Send a message to start a new task.',
213
217
  '已提交补充指令,Agent 会在下一步读取。':
214
218
  'Additional instruction submitted; the Agent will read it at the next step.',
215
219
  };
@@ -8,6 +8,7 @@ const MODELS_COMMAND = /^\/models(?=$|\s)/i;
8
8
  const MODEL_USAGE = '用法:/model <序号> 或 /model <provider>/<model>';
9
9
  const MODELS_USAGE = '用法:/models(不带参数)';
10
10
  const SESSION_BINDING_CHANGED = 'session-binding-changed';
11
+ const MODEL_SELECTION_MISMATCH = 'model-selection-mismatch';
11
12
  const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
12
13
 
13
14
  function commandResult(message) {
@@ -78,6 +79,32 @@ function modelId(provider, model) {
78
79
  return `${provider}/${model}`;
79
80
  }
80
81
 
82
+ function sameModel(left, right) {
83
+ return left?.provider === right?.provider && left?.model === right?.model;
84
+ }
85
+
86
+ function selectionMismatch(expected, actual, source) {
87
+ const error = new Error(`Harness ${source} did not confirm the selected model`);
88
+ error.code = MODEL_SELECTION_MISMATCH;
89
+ error.expected = expected;
90
+ error.actual = actual;
91
+ error.source = source;
92
+ return error;
93
+ }
94
+
95
+ function sessionBindingChanged() {
96
+ const error = new Error('Conversation binding changed during model selection');
97
+ error.code = SESSION_BINDING_CHANGED;
98
+ return error;
99
+ }
100
+
101
+ function assertSessionBinding(state, key, expectedSessionId) {
102
+ const currentSessionId = typeof state?.sessionFor === 'function'
103
+ ? state.sessionFor(key)
104
+ : null;
105
+ if (currentSessionId !== expectedSessionId) throw sessionBindingChanged();
106
+ }
107
+
81
108
  function matchingModel(catalog, requested) {
82
109
  for (const group of catalog.groups) {
83
110
  for (const model of group.models) {
@@ -181,6 +208,23 @@ function modelErrorMessage(error, action) {
181
208
  if (code === SESSION_BINDING_CHANGED) {
182
209
  return t('当前聊天绑定的会话已发生变化,请重试。');
183
210
  }
211
+ if (code === MODEL_SELECTION_MISMATCH) {
212
+ const expected = error?.expected;
213
+ const actual = error?.actual;
214
+ const lines = [t('模型切换失败,请稍后重试。')];
215
+ if (expected?.provider && expected?.model) {
216
+ lines.push('', `requested: ${safeDisplayText(modelId(expected.provider, expected.model))}`);
217
+ }
218
+ if (actual?.provider && actual?.model) {
219
+ const label = error?.source === 'models.current'
220
+ ? t('当前模型:')
221
+ : 'selectModel.selected:';
222
+ lines.push(`${label} ${safeDisplayText(modelId(actual.provider, actual.model))}`);
223
+ } else {
224
+ lines.push(`${error?.source ?? 'Harness'}: unconfirmed`);
225
+ }
226
+ return lines.join('\n');
227
+ }
184
228
  if (code === 'cancelled' || error?.name === 'AbortError') {
185
229
  return action === 'list' ? t('获取模型列表已取消。') : t('模型切换已取消。');
186
230
  }
@@ -230,6 +274,21 @@ async function sessionCatalog(session, options) {
230
274
  return normalizeCatalog(await session.models(options), { requireCurrent: true });
231
275
  }
232
276
 
277
+ async function selectAndVerifyModel(session, selection, options) {
278
+ if (typeof session?.selectModel !== 'function') {
279
+ throw new TypeError('Harness session does not support model selection');
280
+ }
281
+ const selected = (await session.selectModel(selection, options))?.selected;
282
+ if (!sameModel(selected, selection)) {
283
+ throw selectionMismatch(selection, selected, 'selectModel.selected');
284
+ }
285
+ const current = (await sessionCatalog(session, options)).current;
286
+ if (!sameModel(current, selection)) {
287
+ throw selectionMismatch(selection, current, 'models.current');
288
+ }
289
+ return current;
290
+ }
291
+
233
292
  function isModelsCommand(command) {
234
293
  return MODELS_COMMAND.test(command);
235
294
  }
@@ -312,11 +371,10 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
312
371
  ].join('\n'));
313
372
  }
314
373
 
374
+ let applied;
315
375
  if (bound) {
316
- if (typeof bound.session.selectModel !== 'function') {
317
- throw new TypeError('Harness session does not support model selection');
318
- }
319
- await bound.session.selectModel(selection, requestOptions);
376
+ applied = await selectAndVerifyModel(bound.session, selection, requestOptions);
377
+ assertSessionBinding(state, key, bound.sessionId);
320
378
  } else {
321
379
  if (typeof harness?.createSession !== 'function'
322
380
  || typeof harness?.workspaceSession !== 'function'
@@ -329,15 +387,10 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
329
387
  throw new TypeError('Harness returned an invalid session id');
330
388
  }
331
389
  const session = harness.workspaceSession(sessionId);
332
- if (!session || typeof session.selectModel !== 'function') {
333
- throw new TypeError('Harness session does not support model selection');
334
- }
335
- await session.selectModel(selection, requestOptions);
390
+ applied = await selectAndVerifyModel(session, selection, requestOptions);
336
391
  const currentSessionId = state.sessionFor(key);
337
392
  if (typeof currentSessionId === 'string' && currentSessionId) {
338
- const changed = new Error('Conversation binding changed during model selection');
339
- changed.code = SESSION_BINDING_CHANGED;
340
- throw changed;
393
+ throw sessionBindingChanged();
341
394
  }
342
395
  if (await state.setSession(key, sessionId) === false) {
343
396
  const stale = new Error('Workspace changed while binding the new session');
@@ -348,7 +401,7 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
348
401
  return commandResult(t(`模型已切换为:
349
402
  {model}
350
403
 
351
- 后续消息将使用该模型。`, { model: modelId(selection.provider, selection.model) }));
404
+ 后续消息将使用该模型。`, { model: modelId(applied.provider, applied.model) }));
352
405
  });
353
406
  } catch (error) {
354
407
  return commandResult(modelErrorMessage(error, 'select'));
@@ -92,8 +92,8 @@ async function selectedWorkspacePath(value) {
92
92
  }
93
93
  }
94
94
 
95
- export async function workspacePathSnapshot(harness) {
96
- const listed = await harness.listWorkspaces();
95
+ export async function workspacePathSnapshot(harness, options = {}) {
96
+ const listed = await harness.listWorkspaces(options);
97
97
  const currentValue = typeof harness?.currentWorkspace === 'function'
98
98
  ? harness.currentWorkspace()
99
99
  : null;
@@ -155,7 +155,7 @@ async function runWorkspaceListCommand(match, harness) {
155
155
  }
156
156
  }
157
157
 
158
- export async function resolveSessionListWorkspace(selector, harness) {
158
+ export async function resolveSessionListWorkspace(selector, harness, options = {}) {
159
159
  if (!selector) {
160
160
  if (typeof harness?.currentWorkspace !== 'function') {
161
161
  return { error: t('当前机器人没有可用的工作区。') };
@@ -169,7 +169,7 @@ export async function resolveSessionListWorkspace(selector, harness) {
169
169
  if (typeof harness?.listWorkspaces !== 'function') {
170
170
  return { error: t('当前机器人暂不支持按序号选择工作区。') };
171
171
  }
172
- const { paths } = await workspacePathSnapshot(harness);
172
+ const { paths } = await workspacePathSnapshot(harness, options);
173
173
  const position = Number(selector);
174
174
  if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
175
175
  return { error: t('工作区序号不存在,请先执行 /workspacelist。') };