dsh-lark-agent 0.2.0 → 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
@@ -26,6 +26,20 @@ const MAX_FILE_BYTES = 30 * 1024 * 1024;
26
26
  const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
27
27
  const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
28
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
+ }
29
43
  /** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
30
44
  async function uploadable(workspace, path) {
31
45
  let real;
@@ -63,7 +77,7 @@ const HELP = `直接把想做的事告诉我就好。
63
77
  也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
64
78
  const HARNESS_HELP = `
65
79
 
66
- 这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或在 设置 → 模型 里点「权限模式」)可以切换权限,比如改成不再询问的「完全权限」。`;
80
+ 这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。`;
67
81
  const ADVANCED_HELP = `高级命令(按需使用):
68
82
  /new 或 /reset:归档当前会话,下一条消息开启新会话;长期记忆保留
69
83
  /sessions:选择以前的对话;/sessions detail 查看完整列表和 ID
@@ -331,16 +345,27 @@ export class BotBridge {
331
345
  if (this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
332
346
  message.text = '/model current';
333
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.
334
350
  if (message.expectedSessionId !== undefined) {
335
351
  const current = await this.state.sessions.current(this.sessionContext(message));
336
352
  if ((current?.id ?? null) !== message.expectedSessionId) {
353
+ if (inPlace)
354
+ return this.refreshPanel(message, lane, '话题刚刚变化,已刷新为最新状态,请重新选择。');
337
355
  return this.reply(message, '这张卡片对应的会话状态已改变,请重新发送 /status 或 /sessions 后操作。');
338
356
  }
339
357
  }
340
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
+ }
341
363
  const current = await this.state.modelPreferences?.get(this.context(message));
342
- if ((current?.revision ?? null) !== message.expectedModelRevision)
343
- 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
+ }
344
369
  }
345
370
  if (this.state.artifacts && (/^\/(?:result|publish|answer) [a-f0-9]{12}$/.test(text) || text === '/results')) {
346
371
  try {
@@ -381,6 +406,8 @@ export class BotBridge {
381
406
  }
382
407
  }
383
408
  if (text.startsWith('/')) {
409
+ if (inPlace)
410
+ return this.panelInPlace(message, lane);
384
411
  const feedback = this.commandFeedback(message);
385
412
  let response;
386
413
  let panel;
@@ -392,8 +419,7 @@ export class BotBridge {
392
419
  }
393
420
  catch (error) {
394
421
  failed = true;
395
- response = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError
396
- ? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
422
+ response = commandError(error);
397
423
  this.audit({ stage: 'command-failed', messageId: message.messageId });
398
424
  }
399
425
  const payload = panel ? renderPanel(panel) : renderPanel({ title: failed ? '暂时未完成' : '已处理',
@@ -432,8 +458,12 @@ export class BotBridge {
432
458
  if (this.streams(message, lane.user)) {
433
459
  const candidate = new LiveReply(this.liveTransport(), { messageId: message.messageId, inThread: Boolean(message.threadId),
434
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 }) });
435
- if (await candidate.open())
461
+ if (await candidate.open()) {
436
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
+ }
437
467
  else
438
468
  this.liveUnavailableUntil = Date.now() + LIVE_RETRY_MS;
439
469
  }
@@ -501,6 +531,21 @@ export class BotBridge {
501
531
  lane.controller = undefined;
502
532
  }
503
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
+ }
504
549
  /** Whether this message's reply streams into a live card, which makes the typing reaction redundant. */
505
550
  streams(message, user) {
506
551
  return message.chatType === 'p2p' && user.directMode === 'harness' && this.config.streamingCards && this.config.responseMode !== 'text'
@@ -534,8 +579,9 @@ export class BotBridge {
534
579
  }
535
580
  const outcome = await pending.result;
536
581
  const allowed = outcome.kind === 'answer' && outcome.value;
537
- await this.closeInteraction(card, approvalCard(prompt, undefined, outcome.kind === 'answer' ? (allowed ? '已允许这一次。' : '已拒绝,这次不会执行。')
538
- : outcome.kind === 'timeout' ? '5 分钟内没有确认,已按拒绝处理。' : '本次处理已停止,没有执行。'));
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' }));
539
585
  return allowed;
540
586
  }
541
587
  /** Ask the owner a Harness question. Without an answer the host gets an error and continues. */
@@ -555,8 +601,8 @@ export class BotBridge {
555
601
  throw new Error(unavailable);
556
602
  }
557
603
  const outcome = await pending.result;
558
- await this.closeInteraction(card, questionCard(questions, undefined, outcome.kind === 'answer' ? answeredText(questions, outcome.value)
559
- : outcome.kind === 'timeout' ? '10 分钟内没有回答,已按未回答继续。' : '本次处理已停止,提问已关闭。'));
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' }));
560
606
  if (outcome.kind !== 'answer')
561
607
  throw new Error(outcome.kind === 'timeout' ? '主人没有在 10 分钟内回答。' : '提问已取消。');
562
608
  return outcome.value;
@@ -656,6 +702,71 @@ export class BotBridge {
656
702
  if (notes.length)
657
703
  await this.reply(message, `有 ${notes.length} 个文件没有发到这里:\n${notes.join('\n')}`, signal);
658
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
+ }
659
770
  commandFeedback(message) {
660
771
  // Private controls contain no model-generated output. Group replies retain
661
772
  // their existing authorization gate and native typing reaction.
@@ -733,26 +844,29 @@ export class BotBridge {
733
844
  if (lane.unavailable)
734
845
  await this.unload(lane);
735
846
  if (lane.session) {
736
- 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)) {
737
849
  if (lane.session.setModelSelection)
738
850
  lane.session.setModelSelection(modelPreference.selection);
739
851
  else
740
852
  await this.unload(lane);
741
853
  }
742
- if (lane.session)
854
+ if (lane.session) {
855
+ await this.adoptPermission(lane);
743
856
  return;
857
+ }
744
858
  }
745
859
  const context = this.sessionContext(message);
746
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;
747
863
  const handle = saved
748
- ? await this.runtime.resume(this.policy(message, lane, modelPreference?.selection), saved.nativeId)
749
- : 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));
750
866
  try {
751
867
  await handle.checkpoint();
752
- await this.applyPermission(message, lane, handle);
753
- if (!modelPreference && handle.modelSelection && this.state.modelPreferences) {
754
- await this.state.modelPreferences.set(this.context(message), handle.modelSelection(), { expectedRevision: null });
755
- }
868
+ await this.applyPermission(message, lane, handle, !saved);
869
+ await this.mirrorModel(message, handle, modelPreference);
756
870
  // Publish a route only after even the empty native log is durable, and
757
871
  // before submitting the first prompt. An abandoned creation has no work.
758
872
  lane.saved = saved ? await this.bindRestoredSession(context, saved, handle)
@@ -781,6 +895,8 @@ export class BotBridge {
781
895
  lane.unavailable = false;
782
896
  return;
783
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' }));
784
900
  lane.unavailable = true;
785
901
  try {
786
902
  await handle.dispose();
@@ -855,12 +971,11 @@ export class BotBridge {
855
971
  return `已经在会话「${target.title}」中。`;
856
972
  await this.unload(lane);
857
973
  const preference = await this.state.modelPreferences?.get(context);
858
- 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);
859
975
  try {
860
976
  await handle.checkpoint();
861
- await this.applyPermission(message, lane, handle);
862
- if (!preference && handle.modelSelection && this.state.modelPreferences)
863
- 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);
864
979
  await this.bindRestoredSession(conversation, target, handle);
865
980
  lane.saved = await this.state.sessions.activate(conversation, target.id);
866
981
  lane.session = handle;
@@ -931,6 +1046,22 @@ export class BotBridge {
931
1046
  harness(message, lane) {
932
1047
  return message.chatType === 'p2p' && lane.user.directMode === 'harness';
933
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
+ }
934
1065
  /** The host's presets, from the live session when there is one. Undefined when the host offers none. */
935
1066
  permissionView(lane) {
936
1067
  const live = lane.session?.permissionState?.();
@@ -943,6 +1074,7 @@ export class BotBridge {
943
1074
  async permissionChoice(message, lane) {
944
1075
  if (lane.unavailable)
945
1076
  await this.unload(lane);
1077
+ await this.adoptPermission(lane);
946
1078
  const view = this.permissionView(lane);
947
1079
  if (!view)
948
1080
  return;
@@ -954,7 +1086,7 @@ export class BotBridge {
954
1086
  /** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
955
1087
  async permissionCommand(message, lane, args) {
956
1088
  if (!this.harness(message, lane))
957
- return '权限模式只能在你和我的私聊里切换。';
1089
+ return message.chatType === 'p2p' ? '这个私聊使用管理员配置的助手,没有可切换的权限模式。' : '权限模式只能在你和我的私聊里切换。';
958
1090
  const view = await this.permissionChoice(message, lane);
959
1091
  if (!view)
960
1092
  throw new DshRuntimeError('PERMISSION_UNAVAILABLE');
@@ -966,18 +1098,35 @@ export class BotBridge {
966
1098
  return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
967
1099
  // Save first: a later session in this chat starts in the chosen mode even if the live switch fails.
968
1100
  await this.state.permissionPreferences?.set(this.context(message), option.id);
969
- lane.session?.setPermission?.(option.id);
1101
+ if (lane.session?.setPermission) {
1102
+ lane.session.setPermission(option.id);
1103
+ lane.permissionSeen = option.id;
1104
+ }
970
1105
  message.permissionChanged = true;
971
- return `已切换为「${option.name}」。这个私聊里的对话都会使用它;电脑上其他会话不受影响。`;
1106
+ return `已切换为「${option.name}」。这个私聊里的对话都会使用它,电脑上打开这段对话也会看到同样的模式。`;
972
1107
  }
973
- /** Bring a newly opened harness session to the chat's saved permission preset. */
974
- async applyPermission(message, lane, handle) {
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) {
975
1110
  if (!this.harness(message, lane) || !handle.setPermission)
976
1111
  return;
977
- const saved = await this.state.permissionPreferences?.get(this.context(message));
978
- const state = saved ? handle.permissionState?.() : undefined;
979
- if (state && state.current !== saved && state.options.some(option => option.id === saved))
980
- handle.setPermission(saved);
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);
981
1130
  }
982
1131
  async modelState(message, lane) {
983
1132
  if (!this.runtime.catalog || !this.runtime.resolveModelSelection || !this.state.modelPreferences)
@@ -989,6 +1138,17 @@ export class BotBridge {
989
1138
  const catalog = await this.runtime.catalog();
990
1139
  const context = this.context(message);
991
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
+ }
992
1152
  if (!preference) {
993
1153
  let selection = lane.session?.modelSelection?.();
994
1154
  if (!selection) {
@@ -1013,12 +1173,21 @@ export class BotBridge {
1013
1173
  return message.modelView = { catalog, selection: preference.selection, preference };
1014
1174
  }
1015
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
+ }
1016
1183
  const state = await this.modelState(message, lane);
1017
1184
  if (!args || args === 'current' || /^page [0-9]{1,4}$/.test(args))
1018
1185
  return modelStateText(state.selection, state.catalog);
1019
- const match = /^use ([a-f0-9]{16})$/.exec(args);
1020
1186
  if (!match)
1021
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();
1022
1191
  let candidate = kind === 'model' ? modelChoice(state.catalog, match[1]) : thinkingChoice(state.catalog, state.selection, match[1]);
1023
1192
  if (!candidate)
1024
1193
  throw new DshRuntimeError('INVALID_MODEL_SELECTION');
@@ -1027,7 +1196,31 @@ export class BotBridge {
1027
1196
  const selection = await this.runtime.resolveModelSelection(candidate);
1028
1197
  if (!sameSelection(selection, state.selection)) {
1029
1198
  const saved = await this.state.modelPreferences.set(this.context(message), selection, { expectedRevision: state.preference.revision });
1030
- 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) {
1031
1224
  if (lane.session.setModelSelection) {
1032
1225
  try {
1033
1226
  lane.session.setModelSelection(saved.selection);
@@ -1042,17 +1235,26 @@ export class BotBridge {
1042
1235
  message.modelView = { catalog: state.catalog, selection: saved.selection, preference: saved };
1043
1236
  }
1044
1237
  message.modelChanged = true;
1045
- return `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留,只影响你在当前聊天中的选择。`;
1238
+ return shared ? `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留;电脑上的 Harness 也会用这个设置。`
1239
+ : `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留,只影响你在当前聊天中的选择。`;
1046
1240
  }
1047
- 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) {
1048
1246
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
1049
1247
  const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
1248
+ const choice = /^use (\S+)$/.exec(args)?.[1];
1249
+ const pending = card?.pending ? choice : undefined;
1050
1250
  const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
1051
1251
  const issue = (target, extra = {}) => this.state.actions.issue({
1052
1252
  ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType, threadId: message.threadId,
1053
1253
  sourceMessageId: message.messageId, command: target, ...extra
1054
1254
  });
1055
- if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged)) {
1255
+ if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged || card)) {
1256
+ if (card?.pending && !pending)
1257
+ return;
1056
1258
  const state = await this.modelState(message, lane);
1057
1259
  const current = await this.state.sessions.current(this.sessionContext(message));
1058
1260
  const panel = modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
@@ -1061,43 +1263,59 @@ export class BotBridge {
1061
1263
  const token = issue(target, { expectedSessionId: current?.id ?? null,
1062
1264
  ...(mutation ? { expectedModelRevision: state.preference?.revision ?? null } : {}) });
1063
1265
  return { label, value: { token }, primary };
1064
- }, /^page /.test(args) ? Number(args.slice(5)) : 0, message.modelChanged);
1065
- // The settings menu reaches the permission switch from here, without another menu entry.
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.
1066
1268
  if (interactive && this.harness(message, lane) && this.permissionView(lane))
1067
- panel.actions = [...panel.actions ?? [], { label: '权限模式', value: { token: issue('/permission') } }];
1269
+ panel.actions = [...panel.actions ?? [],
1270
+ pending ? { label: '权限模式', value: {}, disabled: true } : { label: '权限模式', value: { token: issue('/permission') } }];
1068
1271
  return panel;
1069
1272
  }
1070
- if (command === 'permission' && (!args || message.permissionChanged) && this.harness(message, lane)) {
1273
+ if (command === 'permission' && (!args || message.permissionChanged || card) && this.harness(message, lane)) {
1274
+ if (card?.pending && !pending)
1275
+ return;
1071
1276
  const view = await this.permissionChoice(message, lane);
1072
1277
  if (!view)
1073
1278
  return;
1074
1279
  return permissionPanel(view, (label, target, primary, confirm) => interactive
1075
- ? { label, value: { token: issue(target) }, primary, ...(confirm ? { confirm } : {}) } : undefined, message.permissionChanged);
1280
+ ? { label, value: { token: issue(target) }, primary, ...(confirm ? { confirm } : {}) } : undefined, { changed: message.permissionChanged, pending, error: card?.error, notice: card?.notice });
1076
1281
  }
1077
1282
  // Ordinary conversation and diagnostics stay out of management cards.
1078
1283
  // A card is useful here because the user explicitly wants to pick a topic.
1079
- if (command !== 'sessions' || args === 'detail')
1284
+ const topic = card && (command === 'resume' || command === 'new');
1285
+ if ((command !== 'sessions' || args === 'detail') && !topic)
1080
1286
  return;
1081
1287
  const conversation = this.sessionContext(message);
1082
1288
  const current = await this.state.sessions.current(conversation);
1083
1289
  const sessions = await this.state.sessions.list(conversation);
1084
1290
  if (!sessions.length)
1085
1291
  return;
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;
1086
1299
  const actions = [];
1087
- const add = (label, target, options = {}) => {
1300
+ const add = (label, command, options = {}) => {
1301
+ if (busy) {
1302
+ actions.push({ label, value: {}, disabled: true });
1303
+ return;
1304
+ }
1088
1305
  if (!interactive)
1089
1306
  return;
1090
1307
  const token = this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId,
1091
1308
  chatType: message.chatType, threadId: message.threadId, sourceMessageId: message.messageId,
1092
- command: target, ...(options.expectedSessionId === undefined ? {} : { expectedSessionId: options.expectedSessionId }) });
1093
- 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 });
1094
1311
  };
1095
1312
  for (const session of sessions.filter(s => s.id !== current?.id).slice(0, 5))
1096
1313
  add(`继续 ${preview(session.title, 14)}`, `/resume ${session.id}`, { expectedSessionId: current?.id ?? null, confirm: { title: '接着这个话题聊?', text: `继续「${preview(session.title, 40)}」,现在的对话会保留。` } });
1097
- add('换个话题', '/new', { expectedSessionId: current?.id ?? null,
1098
- confirm: { title: '开始新话题?', text: '之前的对话会保留,需要时可以再找回来。' } });
1099
- return { title: '以前的对话', subtitle: '选一个话题,接着聊',
1100
- 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,
1101
1319
  sections: sessions.slice(0, 5).map(s => ({ title: preview(s.title, 40), text: s.id === current?.id ? '正在聊这个话题' : '可以接着聊' })),
1102
1320
  ...(!interactive || sessions.length > 5 ? { note: '完整列表和更多操作:/sessions detail' } : {}),
1103
1321
  };
@@ -1287,6 +1505,8 @@ export class BotBridge {
1287
1505
  await Promise.all([...this.lanes.values()].map(lane => lane.tail));
1288
1506
  await this.sweepTail;
1289
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)));
1290
1510
  const sessions = new Set([...this.failedDisposals, ...[...this.lanes.values()].flatMap(lane => lane.session ? [lane.session] : [])]);
1291
1511
  const results = await Promise.allSettled([...sessions].map(session => session.dispose()));
1292
1512
  if (results.some(result => result.status === 'rejected'))
@@ -47,10 +47,12 @@ export type TurnStreamEvent = {
47
47
  callId: string;
48
48
  name: string;
49
49
  detail?: string;
50
+ code?: boolean;
50
51
  } | {
51
52
  type: 'tool-done';
52
53
  callId: string;
53
54
  ok: boolean;
55
+ exitCode?: number;
54
56
  };
55
57
  export interface ApprovalPrompt {
56
58
  toolName: string;
@@ -79,9 +81,11 @@ export interface PermissionOption {
79
81
  name: string;
80
82
  description?: string;
81
83
  }
84
+ /** defaultPreset is the App's choice for new sessions, when it is one of the options. */
82
85
  export interface PermissionState {
83
86
  current: string;
84
87
  options: PermissionOption[];
88
+ defaultPreset?: string;
85
89
  }
86
90
  /** One turn's channel hooks. A harness-mode session asks its owner through them. */
87
91
  export interface TurnObserver {
@@ -123,14 +127,24 @@ export interface RuntimeSession {
123
127
  cancel(): void;
124
128
  dispose(): Promise<void>;
125
129
  modelSelection?(): ModelSelection;
126
- /** Call only after the bridge validates and durably saves the complete choice. */
130
+ /**
131
+ * Call only after the bridge validates and durably saves the complete choice.
132
+ * In a session that shares its model with the App it is recorded in the
133
+ * session itself, so checkpoint before confirming it.
134
+ */
127
135
  setModelSelection?(next: ModelSelection): void;
136
+ /**
137
+ * True when this session decides its model like the App's own sessions: its
138
+ * latest pick (from Feishu or the App), else its last request's model, else
139
+ * the host default. The App then shows and changes the same choice.
140
+ */
141
+ sharesModelSelection?: boolean;
128
142
  /** Harness sessions on a host with permission presets; undefined otherwise. */
129
143
  permissionState?(): PermissionState | undefined;
130
144
  /** Switch this session's permission preset, as the App's own selector does. */
131
145
  setPermission?(id: string): void;
132
146
  }
133
- export type RuntimeErrorCode = 'SETUP_FAILED' | 'RESTORE_FAILED' | 'SESSION_IN_USE' | 'PERSISTENCE_FAILED' | 'TURN_FAILED' | 'CANCELLED' | 'OUTPUT_LIMIT' | 'BUSY' | 'DISPOSED' | 'MODEL_UNAVAILABLE' | 'INVALID_MODEL_SELECTION' | 'PERMISSION_UNAVAILABLE';
147
+ export type RuntimeErrorCode = 'SETUP_FAILED' | 'RESTORE_FAILED' | 'SESSION_IN_USE' | 'PERSISTENCE_FAILED' | 'TURN_FAILED' | 'CANCELLED' | 'OUTPUT_LIMIT' | 'BUSY' | 'DISPOSED' | 'MODEL_UNAVAILABLE' | 'INVALID_MODEL_SELECTION' | 'MODEL_NOT_SAVED' | 'PERMISSION_UNAVAILABLE';
134
148
  /** Safe to show to a chat user: never contains a provider, tool, or host error. */
135
149
  export declare class DshRuntimeError extends Error {
136
150
  readonly code: RuntimeErrorCode;
@@ -158,6 +172,10 @@ export declare class DshRuntime {
158
172
  create(policy: AgentPolicy): Promise<RuntimeSession>;
159
173
  /** The host's selectable permission presets, for a chat that has no live session yet. */
160
174
  permissionOptions(): PermissionOption[];
175
+ /** Whether harness sessions here share their model choice with the App (see RuntimeSession.sharesModelSelection). */
176
+ sharesModelSelection(): boolean;
177
+ /** Make a choice the App's default for new sessions, as the App's own model selector does. */
178
+ saveDefaultModel(selection: ModelSelection): Promise<void>;
161
179
  /** The preset a stored session runs on: its current projection, else its header. */
162
180
  private recordedPreset;
163
181
  resume(policy: AgentPolicy, sessionId: string): Promise<RuntimeSession>;