dsh-lark-agent 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bridge.js CHANGED
@@ -1,4 +1,6 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import { basename, isAbsolute, relative, sep } from 'node:path';
2
4
  import { DshRuntimeError } from './dsh-runtime.js';
3
5
  import { authorize, chunksByBytes, ownerKey, parseMessage, sessionKey } from './message.js';
4
6
  import { MemoryStoreError } from './memory-store.js';
@@ -10,14 +12,72 @@ import { modelChoice, thinkingChoice, modelControlPanel, modelStateText, sameSel
10
12
  import { botMenuCommand, BOT_MENU_EVENT } from './bot-menu.js';
11
13
  import { CommandFeedback } from './command-feedback.js';
12
14
  import { PresentationError, presentationDraft, presentationCard, presentationText } from './presentation.js';
15
+ import { LiveReply } from './live-reply.js';
16
+ import { answeredText, approvalCard, questionAnswers, questionCard, simpleQuestion } from './interaction-cards.js';
17
+ import { permissionPanel, permissionStateText } from './permission-controls.js';
13
18
  class ArtifactDeliveryError extends Error {
14
19
  }
20
+ const APPROVAL_TIMEOUT_MS = 5 * 60_000;
21
+ const QUESTION_TIMEOUT_MS = 10 * 60_000;
22
+ // After a live card cannot be created (for example, before the CardKit scope is
23
+ // granted), use ordinary replies for a while instead of failing every turn.
24
+ const LIVE_RETRY_MS = 10 * 60_000;
25
+ const MAX_FILE_BYTES = 30 * 1024 * 1024;
26
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
27
+ const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
28
+ const STOPPING_TEXT = '连接正在关闭,本次处理已停止。';
29
+ // Clicks on these panels update the clicked card itself; anything else answers with a new message.
30
+ const IN_PLACE = /^\/(?:(?:model|thinking)(?: (?:use [a-f0-9]{16}|page [0-9]{1,4}))?|permission(?: use [a-z][a-z0-9-]{0,39})?|sessions|new|resume [a-f0-9]{12})$/;
31
+ // A switch that finishes sooner goes straight to its result, without a loading frame.
32
+ const PANEL_PROGRESS_DELAY_MS = 150;
33
+ const STALE_SETTINGS = '设置刚刚变化,已刷新为最新选项,请重新选择。';
34
+ /** The card's model settings changed after it was drawn, so its choice is not applied. */
35
+ class StaleCardError extends Error {
36
+ constructor() { super('模型设置已变化,请重新打开模型或思考深度菜单后操作。'); }
37
+ }
38
+ function commandError(error) {
39
+ return error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError
40
+ || error instanceof ModelPreferencesError || error instanceof StaleCardError
41
+ ? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
42
+ }
43
+ /** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
44
+ async function uploadable(workspace, path) {
45
+ let real;
46
+ try {
47
+ real = await realpath(path);
48
+ }
49
+ catch {
50
+ return { reason: '找不到这个文件' };
51
+ }
52
+ const inside = relative(workspace, real);
53
+ if (!inside || inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside))
54
+ return { reason: '不在这个私聊的工作区内,没有上传' };
55
+ let info;
56
+ try {
57
+ info = await stat(real);
58
+ }
59
+ catch {
60
+ return { reason: '无法读取' };
61
+ }
62
+ if (!info.isFile())
63
+ return { reason: '不是普通文件' };
64
+ if (info.size === 0)
65
+ return { reason: '是空文件' };
66
+ if (info.size > MAX_FILE_BYTES)
67
+ return { reason: '超过飞书 30 MB 的文件上限' };
68
+ if (/[\u0000-\u001f\u007f]/.test(basename(real)))
69
+ return { reason: '文件名含有无法发送的字符' };
70
+ return { path: real, image: /\.(?:png|jpe?g|gif|webp)$/i.test(real) && info.size <= MAX_IMAGE_BYTES };
71
+ }
15
72
  const HELP = `直接把想做的事告诉我就好。
16
73
 
17
74
  比如:帮我理清一个问题、整理一段文字,或者接着刚才的话题继续。你也可以在私聊告诉我,以后希望我怎样配合你。
18
75
 
19
76
  私聊底部菜单可以新建话题、找历史对话,或调整模型和思考深度。
20
77
  也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
78
+ const HARNESS_HELP = `
79
+
80
+ 这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。`;
21
81
  const ADVANCED_HELP = `高级命令(按需使用):
22
82
  /new 或 /reset:归档当前会话,下一条消息开启新会话;长期记忆保留
23
83
  /sessions:选择以前的对话;/sessions detail 查看完整列表和 ID
@@ -27,6 +87,7 @@ const ADVANCED_HELP = `高级命令(按需使用):
27
87
  /stop:停止当前处理并取消排队消息
28
88
  /results:查看当前话题的作品;/result <ID>:查看内容;/publish <ID>:在本人私聊生成飞书文档或画板
29
89
  /model:查看当前模型并选择;/thinking:选择当前模型支持的思考深度
90
+ /permission:本人私聊切换 Harness 权限模式(如仅可查看、工作区内修改、完全权限)
30
91
  /remember <内容>:私聊保存私密个人记忆;群聊仅保存当前聊天记忆
31
92
  /remember --chat <内容>:只在当前聊天/话题保存记忆
32
93
  /memory:列出可用记忆(最多 20 条)
@@ -86,6 +147,8 @@ export class BotBridge {
86
147
  failedDisposals = new Set();
87
148
  cardActionsReady = false;
88
149
  progress;
150
+ interactions = new Map();
151
+ liveUnavailableUntil = 0;
89
152
  constructor(config, runtime, transport, ledger, state, audit = () => undefined, progressTransport, artifactPublisher) {
90
153
  this.config = config;
91
154
  this.runtime = runtime;
@@ -125,7 +188,7 @@ export class BotBridge {
125
188
  return;
126
189
  }
127
190
  let formValues;
128
- if (binding.command.startsWith('/answer ')) {
191
+ if (binding.command.startsWith('/answer ') || binding.command.startsWith('/reply ')) {
129
192
  if (data.action_name !== 'dsh_submit' || typeof data.form_value !== 'string' || data.form_value.length > 8000)
130
193
  return;
131
194
  try {
@@ -137,7 +200,7 @@ export class BotBridge {
137
200
  if (!formValues || typeof formValues !== 'object' || Array.isArray(formValues))
138
201
  return;
139
202
  }
140
- const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking) use )/.test(binding.command);
203
+ const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(binding.command);
141
204
  return { messageId: data.message_id, senderId: binding.ownerId, chatId: binding.chatId,
142
205
  chatType: binding.chatType, threadId: binding.threadId, text: binding.command,
143
206
  requestId: `card:${data.event_id}`, actionNonce: mutation ? binding.nonce : undefined,
@@ -201,6 +264,12 @@ export class BotBridge {
201
264
  return;
202
265
  if (!message.menu && message.chatType === 'p2p')
203
266
  await this.state.directChats?.record(message.senderId, message.chatId);
267
+ // A running turn holds its queue while it waits for its approval or
268
+ // question, so those answers must not queue behind it.
269
+ if (message.requestId?.startsWith('card:') && /^\/(?:approve|deny|choose|reply) /.test(message.text)) {
270
+ await this.answerInteraction(message);
271
+ return;
272
+ }
204
273
  const key = sessionKey(message, this.config);
205
274
  let lane = this.lanes.get(key);
206
275
  if (message.text === '/stop') {
@@ -228,7 +297,7 @@ export class BotBridge {
228
297
  const generation = lane.generation;
229
298
  current.pending++;
230
299
  this.pending++;
231
- const finishProgress = message.menu ? () => undefined : this.progress.begin(message.messageId);
300
+ const finishProgress = message.menu || this.streams(message, user) ? () => undefined : this.progress.begin(message.messageId);
232
301
  const acceptedAt = Date.now();
233
302
  const job = current.tail.then(async () => {
234
303
  if (this.stopped || generation !== current.generation)
@@ -276,16 +345,27 @@ export class BotBridge {
276
345
  if (this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
277
346
  message.text = '/model current';
278
347
  let text = message.text;
348
+ const inPlace = this.inPlace(message);
349
+ // A stale card is redrawn with the current state instead of answered with another message.
279
350
  if (message.expectedSessionId !== undefined) {
280
351
  const current = await this.state.sessions.current(this.sessionContext(message));
281
352
  if ((current?.id ?? null) !== message.expectedSessionId) {
353
+ if (inPlace)
354
+ return this.refreshPanel(message, lane, '话题刚刚变化,已刷新为最新状态,请重新选择。');
282
355
  return this.reply(message, '这张卡片对应的会话状态已改变,请重新发送 /status 或 /sessions 后操作。');
283
356
  }
284
357
  }
285
358
  if (message.expectedModelRevision !== undefined) {
359
+ // A shared session may have been switched in the App after this card was drawn.
360
+ if (lane.session?.sharesModelSelection && !lane.unavailable) {
361
+ await this.mirrorModel(message, lane.session, await this.state.modelPreferences?.get(this.context(message)));
362
+ }
286
363
  const current = await this.state.modelPreferences?.get(this.context(message));
287
- if ((current?.revision ?? null) !== message.expectedModelRevision)
288
- return this.reply(message, '模型设置已变化,请重新打开模型或思考深度菜单后操作。');
364
+ if ((current?.revision ?? null) !== message.expectedModelRevision) {
365
+ if (inPlace)
366
+ return this.refreshPanel(message, lane, STALE_SETTINGS);
367
+ return this.reply(message, new StaleCardError().message);
368
+ }
289
369
  }
290
370
  if (this.state.artifacts && (/^\/(?:result|publish|answer) [a-f0-9]{12}$/.test(text) || text === '/results')) {
291
371
  try {
@@ -326,6 +406,8 @@ export class BotBridge {
326
406
  }
327
407
  }
328
408
  if (text.startsWith('/')) {
409
+ if (inPlace)
410
+ return this.panelInPlace(message, lane);
329
411
  const feedback = this.commandFeedback(message);
330
412
  let response;
331
413
  let panel;
@@ -337,8 +419,7 @@ export class BotBridge {
337
419
  }
338
420
  catch (error) {
339
421
  failed = true;
340
- response = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError
341
- ? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
422
+ response = commandError(error);
342
423
  this.audit({ stage: 'command-failed', messageId: message.messageId });
343
424
  }
344
425
  const payload = panel ? renderPanel(panel) : renderPanel({ title: failed ? '暂时未完成' : '已处理',
@@ -354,48 +435,92 @@ export class BotBridge {
354
435
  lane.controller = controller;
355
436
  let timedOut = false;
356
437
  const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.config.turnTimeoutMs);
438
+ const harness = message.chatType === 'p2p' && lane.user.directMode === 'harness';
439
+ let live;
440
+ // Once a live card exists it carries this turn's whole outcome; nothing else is sent as text.
441
+ const conclude = async (final) => {
442
+ const card = live;
443
+ live = undefined;
444
+ if (!await card.finish(final))
445
+ this.audit({ stage: 'live-card-unconfirmed', messageId: message.messageId });
446
+ };
357
447
  try {
358
448
  await this.ensureSession(message, lane);
359
449
  if (controller.signal.aborted) {
360
450
  if (timedOut)
361
- await this.reply(message, '本次处理超时,已请求取消。请缩小问题范围后重试。');
451
+ await this.reply(message, TIMEOUT_TEXT);
362
452
  return;
363
453
  }
364
454
  const context = this.context(message);
365
455
  const memoryText = promptMemories(await this.state.memories.list(context));
366
456
  const artifactsText = this.state.artifacts && lane.saved ? JSON.stringify((await this.state.artifacts.list(this.artifactContext(message, lane.saved.id)))
367
457
  .map(row => ({ id: row.id, title: row.draft.title, kind: row.draft.kind }))) : undefined;
368
- const result = await lane.session.run(text, controller.signal, { memoryText, ...(artifactsText ? { artifactsText } : {}) });
458
+ if (this.streams(message, lane.user)) {
459
+ const candidate = new LiveReply(this.liveTransport(), { messageId: message.messageId, inThread: Boolean(message.threadId),
460
+ idempotencyKey: createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'live'])).digest('hex').slice(0, 48) }, { audit: stage => this.audit({ stage, messageId: message.messageId }) });
461
+ if (await candidate.open()) {
462
+ live = candidate;
463
+ // Under the answer: what this reply runs with. Names may wait for the model catalog.
464
+ void this.settingsLine(lane).then(line => { if (line)
465
+ candidate.setFooter(line); }, () => undefined);
466
+ }
467
+ else
468
+ this.liveUnavailableUntil = Date.now() + LIVE_RETRY_MS;
469
+ }
470
+ const turnContext = { memoryText, ...(artifactsText ? { artifactsText } : {}) };
471
+ // Only the owner's harness-mode chat can be asked anything; a restricted session gets no hooks at all.
472
+ const observer = live || harness ? {
473
+ ...(live ? { stream: (event) => live?.push(event) } : {}),
474
+ ...(harness ? { approve: (prompt, signal) => this.approve(message, prompt, signal),
475
+ ask: (questions, signal) => this.ask(message, questions, signal) } : {}),
476
+ } : undefined;
477
+ const result = await (observer ? lane.session.run(text, controller.signal, turnContext, observer)
478
+ : lane.session.run(text, controller.signal, turnContext));
369
479
  // Native conversation data must be durable before acknowledging a turn.
370
480
  await lane.session.checkpoint();
371
481
  await this.state.sessions.touch(this.sessionContext(message), lane.saved.id);
372
482
  clearTimeout(timer);
373
483
  if (controller.signal.aborted || this.stopped) {
374
- if (timedOut && !this.stopped)
375
- await this.reply(message, '本次处理超时,已请求取消。请缩小问题范围后重试。');
484
+ if (live)
485
+ await conclude({ phase: 'stopped', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT : '已停止。' });
486
+ else if (timedOut && !this.stopped)
487
+ await this.reply(message, TIMEOUT_TEXT);
376
488
  return;
377
489
  }
378
490
  const answer = typeof result === 'string' ? result : result.text;
379
491
  const artifacts = typeof result === 'string' ? [] : result.artifacts;
492
+ const files = typeof result === 'string' ? [] : result.files ?? [];
380
493
  const bounded = Array.from(answer).slice(0, this.config.maxOutputChars).join('');
494
+ const truncated = bounded.length < answer.length;
381
495
  // The answer is already in native history. Send it before artifact work
382
496
  // that can fail, so a storage limit cannot swallow a complete reply.
383
- if (bounded || !artifacts.length)
384
- await this.reply(message, bounded ? bounded + (bounded.length < answer.length ? '\n\n(回复已截断)' : '') : 'dsh 本次没有返回文本。', controller.signal);
497
+ if (live)
498
+ await conclude({ phase: 'done', answer: bounded || (artifacts.length || files.length ? '已整理好,见下方。' : 'dsh 本次没有返回文本。'),
499
+ ...(typeof result !== 'string' && result.reasoning ? { reasoning: result.reasoning } : {}), ...(truncated ? { note: '回复已截断。' } : {}) });
500
+ else if (bounded || !artifacts.length)
501
+ await this.reply(message, bounded ? bounded + (truncated ? '\n\n(回复已截断)' : '') : 'dsh 本次没有返回文本。', controller.signal);
385
502
  await this.presentArtifacts(message, lane, artifacts, controller.signal);
503
+ await this.deliverFiles(message, lane, files, controller.signal);
386
504
  this.audit({ stage: 'replied', messageId: message.messageId });
387
505
  }
388
506
  catch (error) {
389
507
  clearTimeout(timer);
390
508
  if (error instanceof ArtifactDeliveryError)
391
509
  throw error;
510
+ const known = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError;
511
+ if (live) {
512
+ const stopped = timedOut || controller.signal.aborted || this.stopped;
513
+ await conclude({ phase: stopped ? 'stopped' : 'failed', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT
514
+ : controller.signal.aborted ? '已停止。' : known ? error.message : '处理没有完成,请稍后重试。' }).catch(() => undefined);
515
+ return;
516
+ }
392
517
  if (this.stopped)
393
518
  return;
394
519
  if (timedOut)
395
- await this.reply(message, '本次处理超时,已请求取消。请缩小问题范围后重试。');
520
+ await this.reply(message, TIMEOUT_TEXT);
396
521
  else if (!controller.signal.aborted) {
397
522
  // Transport failures may have delivered partial output. Do not send an extra ambiguous retry.
398
- if (error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError)
523
+ if (known)
399
524
  await this.reply(message, error.message);
400
525
  else
401
526
  this.audit({ stage: 'delivery-or-runtime-failed', messageId: message.messageId });
@@ -406,6 +531,242 @@ export class BotBridge {
406
531
  lane.controller = undefined;
407
532
  }
408
533
  }
534
+ /** The model, thinking depth and permission mode a reply runs with, by name. */
535
+ async settingsLine(lane) {
536
+ const session = lane.session;
537
+ const selection = session?.modelSelection?.();
538
+ if (!session || !selection)
539
+ return;
540
+ const catalog = await this.runtime.catalog?.().catch(() => undefined);
541
+ const model = catalog?.models.find(item => item.provider === selection.provider && item.model === selection.model);
542
+ const efforts = model?.reasoning?.efforts ?? [];
543
+ const effort = selection.reasoningEffort ? efforts.find(item => item.id === selection.reasoningEffort)?.name ?? selection.reasoningEffort
544
+ : efforts.length ? '模型默认' : undefined;
545
+ const permission = session.permissionState?.();
546
+ const preset = permission && (permission.options.find(option => option.id === permission.current)?.name ?? (permission.current === 'custom' ? '自定义' : permission.current));
547
+ return [model?.name ?? selection.model, effort && `思考深度 ${effort}`, preset && `权限 ${preset}`].filter(Boolean).join(' · ');
548
+ }
549
+ /** Whether this message's reply streams into a live card, which makes the typing reaction redundant. */
550
+ streams(message, user) {
551
+ return message.chatType === 'p2p' && user.directMode === 'harness' && this.config.streamingCards && this.config.responseMode !== 'text'
552
+ && !message.text.startsWith('/') && Date.now() >= this.liveUnavailableUntil && this.liveTransport() !== undefined;
553
+ }
554
+ liveTransport() {
555
+ const transport = this.transport;
556
+ return transport.createCardEntity && transport.replyCardEntity && transport.streamCardText && transport.updateCardEntity
557
+ ? transport : undefined;
558
+ }
559
+ canInteract(message) {
560
+ return message.chatType === 'p2p' && !this.stopped && this.cardActionsReady && this.config.cardInteractions && this.config.responseMode !== 'text'
561
+ && Boolean(this.state.actions && this.transport.createFeedback && this.transport.updateFeedback);
562
+ }
563
+ issueAction(message, command) {
564
+ return this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType,
565
+ threadId: message.threadId, sourceMessageId: message.messageId, command });
566
+ }
567
+ /** Ask the owner about one Harness approval. Anything but an explicit allow denies the call. */
568
+ async approve(message, prompt, signal) {
569
+ if (!this.canInteract(message) || signal.aborted)
570
+ return false;
571
+ const id = randomBytes(6).toString('hex');
572
+ // Register before the card exists, so even an immediate click finds it.
573
+ const pending = this.waitForAnswer(id, message, answer => answer.verb === 'approve' ? true : answer.verb === 'deny' ? false : undefined, signal, APPROVAL_TIMEOUT_MS);
574
+ const card = await this.openInteraction(message, id, approvalCard(prompt, { allow: this.issueAction(message, `/approve ${id}`),
575
+ deny: this.issueAction(message, `/deny ${id}`) }));
576
+ if (!card) {
577
+ pending.cancel();
578
+ return false;
579
+ }
580
+ const outcome = await pending.result;
581
+ const allowed = outcome.kind === 'answer' && outcome.value;
582
+ await this.closeInteraction(card, approvalCard(prompt, undefined, outcome.kind === 'answer'
583
+ ? (allowed ? { text: '已允许这一次。', tone: 'success' } : { text: '已拒绝,这次不会执行。', tone: 'error' })
584
+ : { text: outcome.kind === 'timeout' ? '5 分钟内没有确认,已按拒绝处理。' : '本次处理已停止,没有执行。', tone: 'muted' }));
585
+ return allowed;
586
+ }
587
+ /** Ask the owner a Harness question. Without an answer the host gets an error and continues. */
588
+ async ask(message, questions, signal) {
589
+ const unavailable = '这里无法用卡片提问。请把问题写在回复里,由主人直接回答。';
590
+ if (!this.canInteract(message))
591
+ throw new Error(unavailable);
592
+ const id = randomBytes(6).toString('hex');
593
+ const first = questions[0];
594
+ const tokens = simpleQuestion(questions)
595
+ ? { choose: Array.from({ length: first.options.length + 1 }, (_unused, index) => this.issueAction(message, `/choose ${id} ${index}`)) }
596
+ : { reply: this.issueAction(message, `/reply ${id}`) };
597
+ const pending = this.waitForAnswer(id, message, answer => questionAnswers(questions, answer), signal, QUESTION_TIMEOUT_MS);
598
+ const card = await this.openInteraction(message, id, questionCard(questions, tokens));
599
+ if (!card) {
600
+ pending.cancel();
601
+ throw new Error(unavailable);
602
+ }
603
+ const outcome = await pending.result;
604
+ await this.closeInteraction(card, questionCard(questions, undefined, outcome.kind === 'answer' ? { text: answeredText(questions, outcome.value), tone: 'success' }
605
+ : { text: outcome.kind === 'timeout' ? '10 分钟内没有回答,已按未回答继续。' : '本次处理已停止,提问已关闭。', tone: 'muted' }));
606
+ if (outcome.kind !== 'answer')
607
+ throw new Error(outcome.kind === 'timeout' ? '主人没有在 10 分钟内回答。' : '提问已取消。');
608
+ return outcome.value;
609
+ }
610
+ waitForAnswer(id, message, parse, signal, timeoutMs) {
611
+ let finish;
612
+ const result = new Promise(resolve => {
613
+ let done = false;
614
+ const timer = setTimeout(() => finish({ kind: 'timeout' }), timeoutMs);
615
+ const onAbort = () => finish({ kind: 'cancelled' });
616
+ const entry = { ownerId: message.senderId, chatId: message.chatId, stop: onAbort,
617
+ settle: answer => {
618
+ const value = parse(answer);
619
+ if (value === undefined || done)
620
+ return false;
621
+ finish({ kind: 'answer', value });
622
+ return true;
623
+ } };
624
+ finish = outcome => {
625
+ if (done)
626
+ return;
627
+ done = true;
628
+ clearTimeout(timer);
629
+ signal.removeEventListener('abort', onAbort);
630
+ if (this.interactions.get(id) === entry)
631
+ this.interactions.delete(id);
632
+ resolve(outcome);
633
+ };
634
+ this.interactions.set(id, entry);
635
+ signal.addEventListener('abort', onAbort, { once: true });
636
+ if (signal.aborted)
637
+ onAbort();
638
+ });
639
+ return { result, cancel: () => finish({ kind: 'cancelled' }) };
640
+ }
641
+ async openInteraction(message, id, payload) {
642
+ if (payload.kind !== 'card' || this.stopped)
643
+ return;
644
+ const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'interaction', id]))
645
+ .digest('hex').slice(0, 48);
646
+ try {
647
+ return await this.transport.createFeedback({ messageId: message.messageId, inThread: Boolean(message.threadId) }, payload, { idempotencyKey });
648
+ }
649
+ catch {
650
+ this.audit({ stage: 'interaction-card-failed', messageId: message.messageId });
651
+ return;
652
+ }
653
+ }
654
+ async closeInteraction(messageId, payload) {
655
+ if (payload.kind !== 'card')
656
+ return;
657
+ try {
658
+ await this.transport.updateFeedback(messageId, payload);
659
+ }
660
+ catch {
661
+ this.audit({ stage: 'interaction-update-failed', messageId });
662
+ }
663
+ }
664
+ async answerInteraction(message) {
665
+ const match = /^\/(approve|deny|choose|reply) ([a-f0-9]{12})(?: ([0-9]{1,2}))?$/.exec(message.text);
666
+ const pending = match ? this.interactions.get(match[2]) : undefined;
667
+ if (!match || !pending || pending.ownerId !== message.senderId || pending.chatId !== message.chatId) {
668
+ await this.reply(message, '这张卡片已经失效。需要的话,直接在聊天里告诉我。');
669
+ return;
670
+ }
671
+ if (!pending.settle({ verb: match[1], ...(match[3] === undefined ? {} : { index: Number(match[3]) }),
672
+ ...(message.formValues === undefined ? {} : { formValues: message.formValues }) }))
673
+ await this.reply(message, '这次选择无效,请在卡片上重新选择后提交。');
674
+ }
675
+ /** Send files the host's delivery tool presented, from this user's workspace only. */
676
+ async deliverFiles(message, lane, files, signal) {
677
+ if (!files.length || signal.aborted || this.stopped)
678
+ return;
679
+ const workspace = await realpath(lane.user.cwd).catch(() => undefined);
680
+ const notes = [];
681
+ for (const [index, file] of files.entries()) {
682
+ if (signal.aborted || this.stopped)
683
+ return;
684
+ const name = basename(file.path);
685
+ const checked = workspace && this.transport.replyFile ? await uploadable(workspace, file.path) : { reason: '这个连接不能发送文件' };
686
+ if ('reason' in checked) {
687
+ notes.push(`${name}:${checked.reason}(电脑上的位置:${file.path})`);
688
+ continue;
689
+ }
690
+ const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'file', index, checked.path]))
691
+ .digest('hex').slice(0, 48);
692
+ try {
693
+ await this.transport.replyFile(message.messageId, checked.path, { idempotencyKey, inThread: Boolean(message.threadId), image: checked.image });
694
+ this.audit({ stage: 'file-sent', messageId: message.messageId });
695
+ }
696
+ catch {
697
+ // The upload may still have arrived. Name it instead of sending again.
698
+ notes.push(`${name}:发送结果没有确认,请在电脑上查看 ${file.path}`);
699
+ this.audit({ stage: 'file-send-unknown', messageId: message.messageId });
700
+ }
701
+ }
702
+ if (notes.length)
703
+ await this.reply(message, `有 ${notes.length} 个文件没有发到这里:\n${notes.join('\n')}`, signal);
704
+ }
705
+ /** A private-chat click on a settings or topic panel, which can update the clicked card. */
706
+ inPlace(message) {
707
+ return message.chatType === 'p2p' && Boolean(message.requestId?.startsWith('card:')) && this.config.responseMode !== 'text'
708
+ && Boolean(this.transport.updateFeedback) && IN_PLACE.test(message.text);
709
+ }
710
+ /**
711
+ * Run a panel click and show its progress and result on the clicked card. That
712
+ * card exists, so a failed progress frame never holds back the result, and an
713
+ * unconfirmed update is never followed by another message.
714
+ */
715
+ async panelInPlace(message, lane) {
716
+ const target = message.messageId;
717
+ const mutation = /^\/(?:(?:model|thinking|permission) use |resume |new$)/.test(message.text);
718
+ const progress = mutation ? await this.commandPanel(message, lane, { pending: true }).catch(() => undefined) : undefined;
719
+ const feedback = new CommandFeedback(async () => {
720
+ if (progress && !this.stopped) {
721
+ try {
722
+ await this.transport.updateFeedback(target, renderPanel(progress));
723
+ }
724
+ catch {
725
+ this.audit({ stage: 'panel-progress-failed', messageId: target });
726
+ }
727
+ }
728
+ return target;
729
+ }, (_id, payload) => this.transport.updateFeedback(target, payload), PANEL_PROGRESS_DELAY_MS);
730
+ let panel, response, failed = false;
731
+ try {
732
+ response = await this.command(message, lane);
733
+ panel = await this.commandPanel(message, lane);
734
+ }
735
+ catch (error) {
736
+ // A card that went stale while it ran is refreshed, not reported as a failure.
737
+ const stale = error instanceof StaleCardError;
738
+ failed = !stale;
739
+ response = stale ? STALE_SETTINGS : commandError(error);
740
+ if (failed)
741
+ this.audit({ stage: 'command-failed', messageId: message.messageId });
742
+ panel = await this.commandPanel(message, lane, stale ? { notice: response } : { error: response }).catch(() => undefined);
743
+ if (stale && !panel)
744
+ panel = { title: '卡片已过期', tone: 'muted', status: { text: response, tone: 'muted' }, summary: response, sections: [] };
745
+ }
746
+ const payload = renderPanel(this.stopped ? { title: '连接正在关闭', tone: 'muted', summary: '连接恢复后可重新打开设置,已保存的选择保留',
747
+ sections: [{ text: '连接恢复后可重新打开菜单。已保存的选择会保留。' }] }
748
+ : panel ?? { title: failed ? '暂时未完成' : '已处理', tone: failed ? 'error' : 'success', status: { text: response, tone: failed ? 'error' : 'success' },
749
+ summary: response, sections: [] });
750
+ try {
751
+ await feedback.finish(payload, () => this.transport.updateFeedback(target, payload));
752
+ this.audit({ stage: 'panel-updated', messageId: target });
753
+ }
754
+ catch {
755
+ this.audit({ stage: 'panel-update-failed', messageId: target });
756
+ }
757
+ }
758
+ /** Redraw a stale card with the current state, without running its command. */
759
+ async refreshPanel(message, lane, notice) {
760
+ const panel = await this.commandPanel(message, lane, { notice }).catch(() => undefined);
761
+ const payload = renderPanel(panel ?? { title: '卡片已过期', tone: 'muted', status: { text: notice, tone: 'muted' }, summary: notice, sections: [] });
762
+ try {
763
+ await this.transport.updateFeedback(message.messageId, payload);
764
+ this.audit({ stage: 'panel-refreshed', messageId: message.messageId });
765
+ }
766
+ catch {
767
+ this.audit({ stage: 'panel-update-failed', messageId: message.messageId });
768
+ }
769
+ }
409
770
  commandFeedback(message) {
410
771
  // Private controls contain no model-generated output. Group replies retain
411
772
  // their existing authorization gate and native typing reaction.
@@ -456,6 +817,7 @@ export class BotBridge {
456
817
  const context = this.context(message);
457
818
  return {
458
819
  cwd: lane.user.cwd, allowedTools: lane.user.allowedTools,
820
+ mode: message.chatType === 'p2p' && lane.user.directMode === 'harness' ? 'harness' : 'restricted',
459
821
  provider: this.config.provider, model: this.config.model,
460
822
  presentation: Boolean(this.state.artifacts),
461
823
  ...selection,
@@ -482,25 +844,29 @@ export class BotBridge {
482
844
  if (lane.unavailable)
483
845
  await this.unload(lane);
484
846
  if (lane.session) {
485
- if (modelPreference && lane.session.modelSelection && !sameSelection(lane.session.modelSelection(), modelPreference.selection)) {
847
+ // A session that shares its model with the App follows its own record instead of the chat's copy.
848
+ if (!lane.session.sharesModelSelection && modelPreference && lane.session.modelSelection && !sameSelection(lane.session.modelSelection(), modelPreference.selection)) {
486
849
  if (lane.session.setModelSelection)
487
850
  lane.session.setModelSelection(modelPreference.selection);
488
851
  else
489
852
  await this.unload(lane);
490
853
  }
491
- if (lane.session)
854
+ if (lane.session) {
855
+ await this.adoptPermission(lane);
492
856
  return;
857
+ }
493
858
  }
494
859
  const context = this.sessionContext(message);
495
860
  const saved = await this.state.sessions.current(context);
861
+ // Like the App: a shared session starts on the host default and resumes on its own record.
862
+ const selection = this.sharedModel(message, lane) ? undefined : modelPreference?.selection;
496
863
  const handle = saved
497
- ? await this.runtime.resume(this.policy(message, lane, modelPreference?.selection), saved.nativeId)
498
- : await this.runtime.create(this.policy(message, lane, modelPreference?.selection));
864
+ ? await this.runtime.resume(this.policy(message, lane, selection), saved.nativeId)
865
+ : await this.runtime.create(this.policy(message, lane, selection));
499
866
  try {
500
867
  await handle.checkpoint();
501
- if (!modelPreference && handle.modelSelection && this.state.modelPreferences) {
502
- await this.state.modelPreferences.set(this.context(message), handle.modelSelection(), { expectedRevision: null });
503
- }
868
+ await this.applyPermission(message, lane, handle, !saved);
869
+ await this.mirrorModel(message, handle, modelPreference);
504
870
  // Publish a route only after even the empty native log is durable, and
505
871
  // before submitting the first prompt. An abandoned creation has no work.
506
872
  lane.saved = saved ? await this.bindRestoredSession(context, saved, handle)
@@ -529,6 +895,8 @@ export class BotBridge {
529
895
  lane.unavailable = false;
530
896
  return;
531
897
  }
898
+ // Keep a preset changed in the App before this session goes away (/new, /resume, idle sweep).
899
+ await this.adoptPermission(lane).catch(() => this.audit({ stage: 'permission-adopt-failed' }));
532
900
  lane.unavailable = true;
533
901
  try {
534
902
  await handle.dispose();
@@ -550,9 +918,12 @@ export class BotBridge {
550
918
  const context = this.context(message);
551
919
  const conversation = this.sessionContext(message);
552
920
  if (command === 'help')
553
- return args === 'advanced' ? ADVANCED_HELP : HELP;
921
+ return args === 'advanced' ? ADVANCED_HELP
922
+ : HELP + (message.chatType === 'p2p' && lane.user.directMode === 'harness' ? HARNESS_HELP : '');
554
923
  if (command === 'model' || command === 'thinking')
555
924
  return this.modelCommand(message, lane, command, args);
925
+ if (command === 'permission')
926
+ return this.permissionCommand(message, lane, args);
556
927
  if (command === 'whoami')
557
928
  return `用户:${message.senderId}\n聊天:${message.chatId}\n范围:${message.chatType === 'p2p' ? '私聊' : '群聊'}${message.threadId ? '/独立话题' : ''}\n身份由飞书验证。个人记忆默认仅本人私聊可读,群聊仅可用本人明确授权的条目。`;
558
929
  if (command === 'status') {
@@ -600,11 +971,11 @@ export class BotBridge {
600
971
  return `已经在会话「${target.title}」中。`;
601
972
  await this.unload(lane);
602
973
  const preference = await this.state.modelPreferences?.get(context);
603
- const handle = await this.runtime.resume(this.policy(message, lane, preference?.selection), target.nativeId);
974
+ const handle = await this.runtime.resume(this.policy(message, lane, this.sharedModel(message, lane) ? undefined : preference?.selection), target.nativeId);
604
975
  try {
605
976
  await handle.checkpoint();
606
- if (!preference && handle.modelSelection && this.state.modelPreferences)
607
- await this.state.modelPreferences.set(context, handle.modelSelection(), { expectedRevision: null });
977
+ await this.applyPermission(message, lane, handle, false);
978
+ await this.mirrorModel(message, handle, preference);
608
979
  await this.bindRestoredSession(conversation, target, handle);
609
980
  lane.saved = await this.state.sessions.activate(conversation, target.id);
610
981
  lane.session = handle;
@@ -672,6 +1043,91 @@ export class BotBridge {
672
1043
  }
673
1044
  return '未知命令。发送 /help 查看可用命令。';
674
1045
  }
1046
+ harness(message, lane) {
1047
+ return message.chatType === 'p2p' && lane.user.directMode === 'harness';
1048
+ }
1049
+ /**
1050
+ * The owner's harness chat on the App host shares model and thinking depth
1051
+ * with the App, like any App session; an administrator's fixed route does not.
1052
+ */
1053
+ sharedModel(message, lane) {
1054
+ return this.harness(message, lane) && Boolean(this.runtime.sharesModelSelection?.()) && !(this.config.provider && this.config.model);
1055
+ }
1056
+ /** Record what an opened session runs as the chat's copy: the first capture, or a shared session's own record. */
1057
+ async mirrorModel(message, handle, preference) {
1058
+ if (!handle.modelSelection || !this.state.modelPreferences || (preference && !handle.sharesModelSelection))
1059
+ return;
1060
+ const selection = handle.modelSelection();
1061
+ if (preference && sameSelection(preference.selection, selection))
1062
+ return;
1063
+ await this.state.modelPreferences.set(this.context(message), selection, { expectedRevision: preference?.revision ?? null });
1064
+ }
1065
+ /** The host's presets, from the live session when there is one. Undefined when the host offers none. */
1066
+ permissionView(lane) {
1067
+ const live = lane.session?.permissionState?.();
1068
+ if (live)
1069
+ return live;
1070
+ const options = this.runtime.permissionOptions?.() ?? [];
1071
+ return options.length ? { options } : undefined;
1072
+ }
1073
+ /** What this chat runs: the live session's preset, else the chat's saved choice. */
1074
+ async permissionChoice(message, lane) {
1075
+ if (lane.unavailable)
1076
+ await this.unload(lane);
1077
+ await this.adoptPermission(lane);
1078
+ const view = this.permissionView(lane);
1079
+ if (!view)
1080
+ return;
1081
+ if (view.current)
1082
+ return view;
1083
+ const saved = await this.state.permissionPreferences?.get(this.context(message));
1084
+ return saved && view.options.some(option => option.id === saved) ? { ...view, current: saved } : view;
1085
+ }
1086
+ /** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
1087
+ async permissionCommand(message, lane, args) {
1088
+ if (!this.harness(message, lane))
1089
+ return message.chatType === 'p2p' ? '这个私聊使用管理员配置的助手,没有可切换的权限模式。' : '权限模式只能在你和我的私聊里切换。';
1090
+ const view = await this.permissionChoice(message, lane);
1091
+ if (!view)
1092
+ throw new DshRuntimeError('PERMISSION_UNAVAILABLE');
1093
+ if (!args)
1094
+ return permissionStateText(view);
1095
+ const choice = /^use ([a-z][a-z0-9-]{0,39})$/.exec(args)?.[1];
1096
+ const option = view.options.find(item => item.id === choice);
1097
+ if (!option)
1098
+ return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
1099
+ // Save first: a later session in this chat starts in the chosen mode even if the live switch fails.
1100
+ await this.state.permissionPreferences?.set(this.context(message), option.id);
1101
+ if (lane.session?.setPermission) {
1102
+ lane.session.setPermission(option.id);
1103
+ lane.permissionSeen = option.id;
1104
+ }
1105
+ message.permissionChanged = true;
1106
+ return `已切换为「${option.name}」。这个私聊里的对话都会使用它,电脑上打开这段对话也会看到同样的模式。`;
1107
+ }
1108
+ /** Bring a newly opened harness session to the chat's choice, or a new one without a choice to the App's default. */
1109
+ async applyPermission(message, lane, handle, fresh) {
1110
+ if (!this.harness(message, lane) || !handle.setPermission)
1111
+ return;
1112
+ const { ownerKey, conversationKey } = this.context(message);
1113
+ lane.permissionContext = { ownerKey, conversationKey };
1114
+ const saved = await this.state.permissionPreferences?.get(lane.permissionContext);
1115
+ const state = handle.permissionState?.();
1116
+ // The App's own new sessions start on its default preset; a resumed one keeps what it recorded.
1117
+ const target = saved ?? (fresh ? state?.defaultPreset : undefined);
1118
+ if (state && target && state.current !== target && state.options.some(option => option.id === target))
1119
+ handle.setPermission(target);
1120
+ lane.permissionSeen = handle.permissionState?.()?.current;
1121
+ }
1122
+ /** A preset changed in the App on this chat's live session becomes the chat's choice, so new sessions keep it. */
1123
+ async adoptPermission(lane) {
1124
+ const live = lane.permissionContext ? lane.session?.permissionState?.() : undefined;
1125
+ if (!live || live.current === lane.permissionSeen)
1126
+ return;
1127
+ lane.permissionSeen = live.current;
1128
+ if (live.options.some(option => option.id === live.current))
1129
+ await this.state.permissionPreferences?.set(lane.permissionContext, live.current);
1130
+ }
675
1131
  async modelState(message, lane) {
676
1132
  if (!this.runtime.catalog || !this.runtime.resolveModelSelection || !this.state.modelPreferences)
677
1133
  throw new DshRuntimeError('MODEL_UNAVAILABLE');
@@ -682,6 +1138,17 @@ export class BotBridge {
682
1138
  const catalog = await this.runtime.catalog();
683
1139
  const context = this.context(message);
684
1140
  let preference = await this.state.modelPreferences.get(context);
1141
+ if (this.sharedModel(message, lane)) {
1142
+ // As in the App: the live session's own record; without one, what this chat last ran,
1143
+ // and for a chat that will start a new session, the host default.
1144
+ const live = lane.session?.sharesModelSelection ? lane.session.modelSelection?.() : undefined;
1145
+ const fresh = !live && !await this.state.sessions.current(this.sessionContext(message));
1146
+ const selection = live ?? (fresh || !preference ? catalog.defaultSelection ?? await this.runtime.resolveModelSelection() : preference.selection);
1147
+ if (!preference || !sameSelection(preference.selection, selection)) {
1148
+ preference = await this.state.modelPreferences.set(context, selection, { expectedRevision: preference?.revision ?? null });
1149
+ }
1150
+ return message.modelView = { catalog, selection: preference.selection, preference };
1151
+ }
685
1152
  if (!preference) {
686
1153
  let selection = lane.session?.modelSelection?.();
687
1154
  if (!selection) {
@@ -706,12 +1173,21 @@ export class BotBridge {
706
1173
  return message.modelView = { catalog, selection: preference.selection, preference };
707
1174
  }
708
1175
  async modelCommand(message, lane, kind, args) {
1176
+ const shared = this.sharedModel(message, lane);
1177
+ const match = /^use ([a-f0-9]{16})$/.exec(args);
1178
+ // Like the App's selector, a pick goes into this chat's own session, so open it first.
1179
+ if (match && shared && !lane.session && await this.state.sessions.current(this.sessionContext(message))) {
1180
+ await this.ensureSession(message, lane);
1181
+ message.modelView = undefined;
1182
+ }
709
1183
  const state = await this.modelState(message, lane);
710
1184
  if (!args || args === 'current' || /^page [0-9]{1,4}$/.test(args))
711
1185
  return modelStateText(state.selection, state.catalog);
712
- const match = /^use ([a-f0-9]{16})$/.exec(args);
713
1186
  if (!match)
714
1187
  return `请通过菜单选择,或发送 /${kind} 查看当前可用选项。`;
1188
+ // Reopening the session may have brought in a choice made in the App since the card was drawn.
1189
+ if (message.expectedModelRevision !== undefined && state.preference.revision !== message.expectedModelRevision)
1190
+ throw new StaleCardError();
715
1191
  let candidate = kind === 'model' ? modelChoice(state.catalog, match[1]) : thinkingChoice(state.catalog, state.selection, match[1]);
716
1192
  if (!candidate)
717
1193
  throw new DshRuntimeError('INVALID_MODEL_SELECTION');
@@ -720,7 +1196,31 @@ export class BotBridge {
720
1196
  const selection = await this.runtime.resolveModelSelection(candidate);
721
1197
  if (!sameSelection(selection, state.selection)) {
722
1198
  const saved = await this.state.modelPreferences.set(this.context(message), selection, { expectedRevision: state.preference.revision });
723
- if (lane.session) {
1199
+ if (shared) {
1200
+ // Recorded in the session, then made the App's default for new sessions, as the App's selector does.
1201
+ const recorded = Boolean(lane.session?.sharesModelSelection);
1202
+ if (recorded) {
1203
+ lane.session.setModelSelection(saved.selection);
1204
+ // An unconfirmed pick must not drive another turn: the session is closed and reopened first.
1205
+ try {
1206
+ await lane.session.checkpoint();
1207
+ }
1208
+ catch (error) {
1209
+ lane.unavailable = true;
1210
+ throw error;
1211
+ }
1212
+ }
1213
+ try {
1214
+ await this.runtime.saveDefaultModel?.(saved.selection);
1215
+ }
1216
+ catch (error) {
1217
+ // Without a session holding the pick, the App default is the only place it lives.
1218
+ if (!recorded)
1219
+ throw error;
1220
+ this.audit({ stage: 'model-default-unsaved', messageId: message.messageId });
1221
+ }
1222
+ }
1223
+ else if (lane.session) {
724
1224
  if (lane.session.setModelSelection) {
725
1225
  try {
726
1226
  lane.session.setModelSelection(saved.selection);
@@ -735,49 +1235,87 @@ export class BotBridge {
735
1235
  message.modelView = { catalog: state.catalog, selection: saved.selection, preference: saved };
736
1236
  }
737
1237
  message.modelChanged = true;
738
- return `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留,只影响你在当前聊天中的选择。`;
1238
+ return shared ? `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留;电脑上的 Harness 也会用这个设置。`
1239
+ : `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留,只影响你在当前聊天中的选择。`;
739
1240
  }
740
- async commandPanel(message, lane) {
1241
+ /**
1242
+ * The management card for a command. `card` describes a click being shown on
1243
+ * its own card: the switch in flight, its refusal, or a refresh of a stale card.
1244
+ */
1245
+ async commandPanel(message, lane, card) {
741
1246
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
742
1247
  const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
743
- if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged)) {
1248
+ const choice = /^use (\S+)$/.exec(args)?.[1];
1249
+ const pending = card?.pending ? choice : undefined;
1250
+ const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
1251
+ const issue = (target, extra = {}) => this.state.actions.issue({
1252
+ ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType, threadId: message.threadId,
1253
+ sourceMessageId: message.messageId, command: target, ...extra
1254
+ });
1255
+ if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged || card)) {
1256
+ if (card?.pending && !pending)
1257
+ return;
744
1258
  const state = await this.modelState(message, lane);
745
1259
  const current = await this.state.sessions.current(this.sessionContext(message));
746
- const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
747
- return modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
1260
+ const panel = modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
748
1261
  if (!interactive)
749
1262
  return;
750
- const token = this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType,
751
- threadId: message.threadId, sourceMessageId: message.messageId, command: target, expectedSessionId: current?.id ?? null,
1263
+ const token = issue(target, { expectedSessionId: current?.id ?? null,
752
1264
  ...(mutation ? { expectedModelRevision: state.preference?.revision ?? null } : {}) });
753
1265
  return { label, value: { token }, primary };
754
- }, /^page /.test(args) ? Number(args.slice(5)) : 0, message.modelChanged);
1266
+ }, { ...(/^page /.test(args) ? { page: Number(args.slice(5)) } : {}), changed: message.modelChanged, pending, error: card?.error, notice: card?.notice });
1267
+ // The settings menu reaches the permission switch from here too.
1268
+ if (interactive && this.harness(message, lane) && this.permissionView(lane))
1269
+ panel.actions = [...panel.actions ?? [],
1270
+ pending ? { label: '权限模式', value: {}, disabled: true } : { label: '权限模式', value: { token: issue('/permission') } }];
1271
+ return panel;
1272
+ }
1273
+ if (command === 'permission' && (!args || message.permissionChanged || card) && this.harness(message, lane)) {
1274
+ if (card?.pending && !pending)
1275
+ return;
1276
+ const view = await this.permissionChoice(message, lane);
1277
+ if (!view)
1278
+ return;
1279
+ return permissionPanel(view, (label, target, primary, confirm) => interactive
1280
+ ? { label, value: { token: issue(target) }, primary, ...(confirm ? { confirm } : {}) } : undefined, { changed: message.permissionChanged, pending, error: card?.error, notice: card?.notice });
755
1281
  }
756
1282
  // Ordinary conversation and diagnostics stay out of management cards.
757
1283
  // A card is useful here because the user explicitly wants to pick a topic.
758
- if (command !== 'sessions' || args === 'detail')
1284
+ const topic = card && (command === 'resume' || command === 'new');
1285
+ if ((command !== 'sessions' || args === 'detail') && !topic)
759
1286
  return;
760
1287
  const conversation = this.sessionContext(message);
761
1288
  const current = await this.state.sessions.current(conversation);
762
1289
  const sessions = await this.state.sessions.list(conversation);
763
1290
  if (!sessions.length)
764
1291
  return;
765
- const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
1292
+ const busy = Boolean(card?.pending);
1293
+ const target = command === 'resume' ? sessions.find(s => s.id === args) : undefined;
1294
+ const status = card?.error ? { text: card.error, tone: 'error' }
1295
+ : busy ? { text: target ? `正在切换到「${preview(target.title, 40)}」…` : '正在开始新话题…', tone: 'pending' }
1296
+ : topic && command === 'resume' && target ? { text: `已切换到「${preview(target.title, 40)}」,可以接着聊了。`, tone: 'success' }
1297
+ : topic ? { text: '好,换个话题。直接发消息就会开始新的对话,之前的还在这里。', tone: 'success' }
1298
+ : card?.notice ? { text: card.notice } : undefined;
766
1299
  const actions = [];
767
- const add = (label, target, options = {}) => {
1300
+ const add = (label, command, options = {}) => {
1301
+ if (busy) {
1302
+ actions.push({ label, value: {}, disabled: true });
1303
+ return;
1304
+ }
768
1305
  if (!interactive)
769
1306
  return;
770
1307
  const token = this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId,
771
1308
  chatType: message.chatType, threadId: message.threadId, sourceMessageId: message.messageId,
772
- command: target, ...(options.expectedSessionId === undefined ? {} : { expectedSessionId: options.expectedSessionId }) });
773
- actions.push({ label, value: { token }, primary: options.primary, confirm: options.confirm });
1309
+ command, ...(options.expectedSessionId === undefined ? {} : { expectedSessionId: options.expectedSessionId }) });
1310
+ actions.push({ label, value: { token }, confirm: options.confirm });
774
1311
  };
775
1312
  for (const session of sessions.filter(s => s.id !== current?.id).slice(0, 5))
776
1313
  add(`继续 ${preview(session.title, 14)}`, `/resume ${session.id}`, { expectedSessionId: current?.id ?? null, confirm: { title: '接着这个话题聊?', text: `继续「${preview(session.title, 40)}」,现在的对话会保留。` } });
777
- add('换个话题', '/new', { expectedSessionId: current?.id ?? null,
778
- confirm: { title: '开始新话题?', text: '之前的对话会保留,需要时可以再找回来。' } });
779
- return { title: '以前的对话', subtitle: '选一个话题,接着聊',
780
- summary: '选择以前的话题继续,或开始一段新对话', actions,
1314
+ if (current)
1315
+ add('换个话题', '/new', { expectedSessionId: current.id,
1316
+ confirm: { title: '开始新话题?', text: '之前的对话会保留,需要时可以再找回来。' } });
1317
+ return { title: '以前的对话', subtitle: '选一个话题,接着聊', tone: status?.tone ?? 'normal', ...(status ? { status } : {}),
1318
+ summary: status?.text ?? '选择以前的话题继续,或开始一段新对话', actions,
781
1319
  sections: sessions.slice(0, 5).map(s => ({ title: preview(s.title, 40), text: s.id === current?.id ? '正在聊这个话题' : '可以接着聊' })),
782
1320
  ...(!interactive || sessions.length > 5 ? { note: '完整列表和更多操作:/sessions detail' } : {}),
783
1321
  };
@@ -961,10 +1499,14 @@ export class BotBridge {
961
1499
  lane.controller?.abort();
962
1500
  lane.session?.cancel();
963
1501
  }
1502
+ for (const interaction of [...this.interactions.values()])
1503
+ interaction.stop();
964
1504
  await Promise.allSettled([...this.admissions]);
965
1505
  await Promise.all([...this.lanes.values()].map(lane => lane.tail));
966
1506
  await this.sweepTail;
967
1507
  await this.progress.drain();
1508
+ // Shutdown disposes sessions directly; keep presets changed in the App first.
1509
+ await Promise.allSettled([...this.lanes.values()].map(lane => this.adoptPermission(lane)));
968
1510
  const sessions = new Set([...this.failedDisposals, ...[...this.lanes.values()].flatMap(lane => lane.session ? [lane.session] : [])]);
969
1511
  const results = await Promise.allSettled([...sessions].map(session => session.dispose()));
970
1512
  if (results.some(result => result.status === 'rejected'))