@xmanrui/dsh-im 4.18.0 → 4.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.en.md +8 -3
  2. package/README.md +8 -3
  3. package/lib/client.js +809 -515
  4. package/lib/index.js +269 -267
  5. package/package.json +5 -1
  6. package/plugin-src/client/bot-alias.js +92 -0
  7. package/plugin-src/client/channels/dingtalk/api.js +3 -0
  8. package/plugin-src/client/channels/dingtalk/index.js +7 -1
  9. package/plugin-src/client/channels/feishu/api.js +3 -0
  10. package/plugin-src/client/channels/feishu/index.js +7 -1
  11. package/plugin-src/client/channels/qq/api.js +3 -0
  12. package/plugin-src/client/channels/qq/index.js +9 -1
  13. package/plugin-src/client/channels/shared/token-api.js +3 -0
  14. package/plugin-src/client/channels/shared/token-channel.js +9 -2
  15. package/plugin-src/client/channels/wecom/api.js +3 -0
  16. package/plugin-src/client/channels/wecom/index.js +9 -1
  17. package/plugin-src/client/channels/wecom-app/api.js +3 -0
  18. package/plugin-src/client/channels/wecom-app/index.js +9 -1
  19. package/plugin-src/client/channels/weixin/api.js +3 -0
  20. package/plugin-src/client/channels/weixin/index.js +7 -1
  21. package/plugin-src/client/channels/whatsapp/api.js +3 -0
  22. package/plugin-src/client/channels/whatsapp/index.js +9 -1
  23. package/plugin-src/client/i18n.js +10 -0
  24. package/plugin-src/client/styles.js +26 -0
  25. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  26. package/plugin-src/host/channels/feishu/production.mjs +22 -0
  27. package/plugin-src/host/channels/feishu/rpc.mjs +14 -0
  28. package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
  29. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  30. package/plugin-src/host/channels/shared/bot-alias-rpc.mjs +15 -0
  31. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  32. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  33. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  34. package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
  35. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  36. package/plugin-src/host/channels/whatsapp/rpc.mjs +13 -0
  37. package/plugin-src/host/rpc-authority.mjs +3 -2
  38. package/plugin-src/host/session-sync-coordinator.mjs +19 -1
  39. package/scripts/verify-lan-management.mjs +176 -0
  40. package/src/channels/feishu/bridge.mjs +370 -7
  41. package/src/channels/feishu/feishu-cards.mjs +1 -1
  42. package/src/channels/feishu/feishu-runtime.mjs +6 -0
  43. package/src/channels/feishu/state-store.mjs +17 -0
  44. package/src/channels/shared/bot-alias.mjs +29 -0
  45. package/src/channels/shared/bot-workspace-store.mjs +71 -3
  46. package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
  47. package/src/channels/shared/message-failure.mjs +2 -1
  48. package/src/channels/shared/model-command.mjs +3 -2
  49. package/src/channels/shared/session-sync-registry.mjs +34 -0
  50. package/src/channels/shared/text-harness-bridge.mjs +38 -0
  51. package/src/channels/telegram/telegram-runtime.mjs +77 -25
@@ -28,6 +28,12 @@ import {
28
28
  validHarnessQuestion,
29
29
  } from '../shared/harness-question.mjs';
30
30
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
31
+ import { textFromHarnessContent } from '../shared/harness-client.mjs';
32
+ import { hasActiveHarnessInteractionOwner } from '../shared/harness-client.mjs';
33
+ import {
34
+ claimSessionSyncMirror,
35
+ releaseSessionSyncMirror,
36
+ } from '../shared/session-sync-registry.mjs';
31
37
  import {
32
38
  BatchInputManager,
33
39
  batchInputBusyMessage,
@@ -96,6 +102,7 @@ import {
96
102
  steerCard,
97
103
  watchListCard,
98
104
  workspaceListCard,
105
+ stepStatusText,
99
106
  } from './feishu-cards.mjs';
100
107
  import { t } from '../shared/i18n.mjs';
101
108
  import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
@@ -188,6 +195,8 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
188
195
  /** Streaming-card mode coalesces card renders behind one PATCH per interval —
189
196
  * patching the same message is far more rate sensitive than posting. */
190
197
  const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
198
+ /** Mux doesn't forward turn/end; this idle gap seals the mirror card. */
199
+ const MIRROR_IDLE_SEAL_MS = 90_000;
191
200
  /** One answer chunk inside the streaming card: small enough that the block
192
201
  * splitter can always distribute blocks across sealed/live cards. */
193
202
  const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
@@ -633,6 +642,19 @@ export class FeishuHarnessBridge {
633
642
  #observedCompletionEvents = new Map();
634
643
  /** Earliest completion that still needs delivery for each watch. */
635
644
  #failedWatchSeqs = new Map();
645
+ /** Host resolver: sessionId -> synced DM targets [{ openId, botId }]. */
646
+ #sessionSyncTargetsFor = null;
647
+ /** sessionId -> openId for live session-sync mirrors. */
648
+ #sessionSyncTargets = new Map();
649
+ /** In-flight adopt lookups, deduped per session. */
650
+ #sessionSyncAdopting = new Set();
651
+ /** Latest interim assistant text per mirrored session (folded on tool). */
652
+ #sessionSyncPendingStep = new Map();
653
+ /** Idle-seal timers: no events for a while = the turn ended (mux may
654
+ * not forward turn/end), so seal the mirror card with what it has. */
655
+ #sessionSyncIdleTimers = new Map();
656
+ /** Conversation keys with an IM ask in flight (set BEFORE the turn starts). */
657
+ #imTurnKeys = new Set();
636
658
  #cardDataTimeoutMs;
637
659
  /** When true, approval/question interactions render as Feishu cards (buttons). */
638
660
  #interactionCards = true;
@@ -660,6 +682,7 @@ export class FeishuHarnessBridge {
660
682
  cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
661
683
  replyTimeoutMs = 600_000,
662
684
  interactionCards = true,
685
+ sessionSyncTargetsFor = null,
663
686
  logger = console,
664
687
  signal,
665
688
  }) {
@@ -709,6 +732,9 @@ export class FeishuHarnessBridge {
709
732
  this.#cardDataTimeoutMs = cardDataTimeoutMs;
710
733
  this.#replyTimeoutMs = replyTimeoutMs;
711
734
  this.#interactionCards = interactionCards === true;
735
+ this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
736
+ ? sessionSyncTargetsFor
737
+ : null;
712
738
  this.#logger = logger;
713
739
  this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
714
740
  this.#signal = signal;
@@ -722,10 +748,85 @@ export class FeishuHarnessBridge {
722
748
  if (typeof this.#harness?.watchHarnessEvents === 'function') {
723
749
  queueMicrotask(() => {
724
750
  this.#ensureEventWatcher();
751
+ // Delay the recovery: a restart lands while healthy turns may still
752
+ // be streaming; sealing them at t=0 would wipe live cards. 90s gives
753
+ // the turn's own events a window to re-adopt and finish normally.
754
+ setTimeout(() => { void this.#sealOrphanMirrors(); }, 90_000);
725
755
  });
726
756
  }
727
757
  }
728
758
 
759
+ /**
760
+ * Seal mirrors left running by a previous process: a restart kills the
761
+ * turn without a turn/end event, so the persisted card would stay
762
+ * "running" forever. Mark each orphan sealed (stopped) on delivery.
763
+ */
764
+ async #sealOrphanMirrors() {
765
+ const entries = typeof this.#state.mirrorEntries === 'function'
766
+ ? this.#state.mirrorEntries()
767
+ : [];
768
+ // Only claim cards older than the threshold: a restart lands while a
769
+ // healthy turn may still be streaming, and sealing it would wipe the
770
+ // live card. Anything older than a turn could plausibly run is orphaned.
771
+ const ORPHAN_AFTER_MS = 3 * 60_000;
772
+ for (const [sessionId, entry] of entries) {
773
+ if (!entry?.chatId || !Array.isArray(entry.cardIds)) continue;
774
+ if (typeof entry.claimedAt === 'number' && Date.now() - entry.claimedAt < ORPHAN_AFTER_MS) {
775
+ this.#logger.warn?.('[dsh-feishu] mirror entry too fresh to be orphaned; leaving untouched:', sessionId);
776
+ continue;
777
+ }
778
+ // The live card's last delivered content is persisted with the mirror:
779
+ // re-patch the live card with a stopped status line, keeping every
780
+ // panel intact. Sealed earlier chunks already carry no status line and
781
+ // keep their original content untouched.
782
+ let sealContent = null;
783
+ if (typeof entry.lastContent === 'string' && entry.lastContent) {
784
+ try {
785
+ const parsed = JSON.parse(entry.lastContent);
786
+ const elements = parsed?.body?.elements;
787
+ if (Array.isArray(elements) && elements.length > 0) {
788
+ const last = elements[elements.length - 1];
789
+ if (last?.tag === 'markdown' && typeof last.content === 'string'
790
+ && last.content.startsWith('_') && last.content.endsWith('_')) {
791
+ last.content = `_${stepStatusText('stopped')}_`;
792
+ } else {
793
+ elements.push({ tag: 'markdown', content: `_${stepStatusText('stopped')}_` });
794
+ }
795
+ sealContent = parsed;
796
+ }
797
+ } catch { /* fall through to an empty stopped card */ }
798
+ }
799
+ for (let index = 0; index < entry.cardIds.length; index += 1) {
800
+ const content = index === entry.cardIds.length - 1 && sealContent
801
+ ? JSON.stringify(sealContent)
802
+ : null;
803
+ if (content === null) continue;
804
+ await this.#patchStepCard(entry.cardIds[index], content)
805
+ .catch((error) => {
806
+ this.#logger.warn?.('[dsh-feishu] orphan mirror seal failed:', error?.message ?? error);
807
+ });
808
+ }
809
+ for (let index = 0; index < entry.cardIds.length; index += 1) {
810
+ const isLive = index === entry.cardIds.length - 1;
811
+ const content = isLive
812
+ ? (sealContent
813
+ ? JSON.stringify({ ...sealContent, data: JSON.stringify({
814
+ ...(JSON.parse(entry.lastContent).data ? JSON.parse(entry.lastContent).data : {}),
815
+ }) })
816
+ : stepStreamCard([], { status: 'stopped' }))
817
+ : stepStreamCard([], { status: 'stopped' });
818
+ await this.#patchStepCard(entry.cardIds[index], content)
819
+ .catch((error) => {
820
+ this.#logger.warn?.('[dsh-feishu] orphan mirror seal failed:', error?.message ?? error);
821
+ });
822
+ }
823
+ await this.#state.clearMirror?.(sessionId);
824
+ this.#logger.warn?.(
825
+ `[dsh-feishu] sealed ${entry.cardIds.length} orphan mirror card(s) for ${sessionId}`,
826
+ );
827
+ }
828
+ }
829
+
729
830
  setGroupResponseMode(value) {
730
831
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
731
832
  }
@@ -2704,6 +2805,25 @@ export class FeishuHarnessBridge {
2704
2805
  const updateMessageId = nonEmptyString(options.updateMessageId);
2705
2806
  const replyTo = nonEmptyString(options.replyTo);
2706
2807
 
2808
+ // Session-sync cards target the user's openId (the synced DM is a user,
2809
+ // not a chat), delivered fresh without topic/thread handling.
2810
+ if (options.receiveIdType === 'open_id') {
2811
+ const response = await this.#client.im.v1.message.create({
2812
+ params: { receive_id_type: 'open_id' },
2813
+ data: {
2814
+ receive_id: chatId,
2815
+ msg_type: 'interactive',
2816
+ content: cardJson,
2817
+ },
2818
+ });
2819
+ if (response?.code && response.code !== 0) {
2820
+ throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
2821
+ }
2822
+ const sentId = nonEmptyString(response?.data?.message_id);
2823
+ if (!sentId) throw new Error('Feishu card send returned no message_id');
2824
+ return sentId;
2825
+ }
2826
+
2707
2827
  if (updateMessageId) {
2708
2828
  try {
2709
2829
  const response = await this.#client.im.v1.message.patch({
@@ -3224,13 +3344,19 @@ export class FeishuHarnessBridge {
3224
3344
 
3225
3345
  #ensureEventWatcher() {
3226
3346
  if (this.#eventWatcher) return;
3227
- if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
3347
+ if (typeof this.#harness?.watchHarnessEvents !== 'function') {
3348
+ this.#logger.warn?.('[dsh-feishu] harness lacks watchHarnessEvents; session-sync mirror disabled');
3349
+ return;
3350
+ }
3228
3351
  if (this.#signal?.aborted) return;
3229
3352
  const signal = this.#signal ?? new AbortController().signal;
3230
3353
  try {
3231
3354
  this.#eventWatcher = this.#harness.watchHarnessEvents({
3232
3355
  signal,
3233
- onSessionEvent: (payload) => this.#onHarnessEvent(payload),
3356
+ onSessionEvent: (payload) => {
3357
+ console.error('[ss-final] mux:', payload?.sessionId?.slice(-12), payload?.event?.type);
3358
+ this.#onHarnessEvent(payload);
3359
+ },
3234
3360
  onReconnect: () => {
3235
3361
  void this.#compensateMissedEvents();
3236
3362
  void this.#deferred.resume();
@@ -3573,13 +3699,206 @@ export class FeishuHarnessBridge {
3573
3699
  }
3574
3700
 
3575
3701
  /** Queue live turn completions behind any reconnect compensation. */
3702
+ /**
3703
+ * Mirror one Harness session event into the session-sync process card for
3704
+ * this session (a Web/CLI-initiated turn with a synced DM target). Events
3705
+ * are translated into the same update shapes the ask callbacks produce, so
3706
+ * the regular #stepCards ladder renders them identically: tool/call ->
3707
+ * tool block, assistant/message -> live answer draft, turn/end -> sealed
3708
+ * terminal card with the final answer.
3709
+ */
3710
+ /** True when this session's running turn was opened by one of OUR asks. */
3711
+ #isImTurn(sessionId) {
3712
+ for (const imKey of this.#imTurnKeys) {
3713
+ if (this.#state.sessionFor?.(imKey) === sessionId) return true;
3714
+ }
3715
+ return false;
3716
+ }
3717
+
3718
+ #beginImTurn(key) {
3719
+ this.#imTurnKeys.add(key);
3720
+ }
3721
+
3722
+ #endImTurn(key) {
3723
+ this.#imTurnKeys.delete(key);
3724
+ }
3725
+
3726
+ /**
3727
+ * The session-event mux forwards surfaced events (user/tool/assistant) but
3728
+ * NOT turn/start|turn/end, so the mirror cannot observe the turn boundary
3729
+ * directly. Instead, arm an idle timer on every event: when no event
3730
+ * arrives for MIRROR_IDLE_SEAL_MS, the turn is over — seal the card with
3731
+ * its current content (answer draft included) as completed.
3732
+ */
3733
+ #armSessionSyncIdleTimer(sessionId, key, openId) {
3734
+ const previous = this.#sessionSyncIdleTimers.get(sessionId);
3735
+ if (previous) clearTimeout(previous);
3736
+ const timer = setTimeout(() => {
3737
+ this.#sessionSyncIdleTimers.delete(sessionId);
3738
+ const card = this.#stepCards.get(key);
3739
+ if (!card || card.broken) return;
3740
+ void this.#finishStepCard(key, { stopped: false, answerText: null })
3741
+ .then(() => this.#state.clearMirror?.(sessionId))
3742
+ .catch((error) => {
3743
+ console.error('[ss-final] idle seal failed:', error?.message ?? error);
3744
+ });
3745
+ }, MIRROR_IDLE_SEAL_MS);
3746
+ this.#sessionSyncIdleTimers.set(sessionId, timer);
3747
+ }
3748
+
3749
+ async #feedSessionSyncTurn(sessionId, event) {
3750
+ if (this.#signal?.aborted) return;
3751
+ const key = `session-sync\0${sessionId}`;
3752
+ const type = event?.type;
3753
+ const target = this.#sessionSyncTargets.get(sessionId);
3754
+ const openId = typeof target === 'string' ? target : target?.openId;
3755
+ this.#armSessionSyncIdleTimer(sessionId, key, openId);
3756
+
3757
+ if (type === 'turn/start') {
3758
+ if (this.#stepCards.has(key)) {
3759
+ return;
3760
+ }
3761
+ if (this.#isImTurn(sessionId)) {
3762
+ return;
3763
+ }
3764
+ const targets = await this.#sessionSyncTargetsFor?.(sessionId);
3765
+ const owned = (Array.isArray(targets) ? targets : [])
3766
+ .find((target) => target.botId === this.#botId);
3767
+ if (!owned?.openId) {
3768
+ return;
3769
+ }
3770
+ this.#sessionSyncTargets.set(sessionId, owned);
3771
+ // chatId carries the openId; #sendCard branches on the delivery marker.
3772
+ this.#ensureStepCard(key, owned.openId, null);
3773
+ this.#stepCards.get(key).deliveryViaOpenId = true;
3774
+ this.#stepCards.get(key).sessionSyncSessionId = sessionId;
3775
+ this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
3776
+ // Persist the mirror so a restart can seal an orphaned running card.
3777
+ await this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
3778
+ // Claim only THIS target: the coordinator suppresses its plain text for
3779
+ // the mirrored target while other synced targets keep their delivery.
3780
+ claimSessionSyncMirror(sessionId, owned.targetId ?? '');
3781
+ return;
3782
+ }
3783
+
3784
+ const card = this.#stepCards.get(key);
3785
+ if (!card) {
3786
+ // The bridge (re)started mid-turn: adopt the running turn on its first
3787
+ // visible event so the mirror still renders from here on.
3788
+ if ((type === 'assistant/message' || type === 'tool/call')
3789
+ && !this.#sessionSyncAdopting.has(sessionId)
3790
+ && !this.#isImTurn(sessionId)) {
3791
+ this.#sessionSyncAdopting.add(sessionId);
3792
+ Promise.resolve()
3793
+ .then(() => this.#sessionSyncTargetsFor?.(sessionId))
3794
+ .then((targets) => {
3795
+ const owned = (Array.isArray(targets) ? targets : [])
3796
+ .find((target) => target.botId === this.#botId);
3797
+ if (!owned?.openId) return null;
3798
+ this.#sessionSyncTargets.set(sessionId, owned);
3799
+ // Adopt = open the mirror card NOW, then handle this event.
3800
+ this.#ensureStepCard(key, owned.openId, null);
3801
+ this.#stepCards.get(key).deliveryViaOpenId = true;
3802
+ this.#stepCards.get(key).sessionSyncSessionId = sessionId;
3803
+ this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
3804
+ void this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
3805
+ claimSessionSyncMirror(sessionId, owned.targetId ?? '');
3806
+ return this.#feedSessionSyncTurn(sessionId, event);
3807
+ })
3808
+ .catch((error) => {
3809
+ this.#logger.warn?.('[dsh-feishu] session-sync adopt failed:', error?.message ?? error);
3810
+ })
3811
+ .finally(() => this.#sessionSyncAdopting.delete(sessionId));
3812
+ }
3813
+ return;
3814
+ }
3815
+ // A broken card must not swallow the turn boundary: turn/end still needs
3816
+ // to release the mirror claim and clear the state so later turns and the
3817
+ // plain-text fallback work again.
3818
+ if (card.broken && type !== 'turn/end') return;
3819
+
3820
+ if (type === 'user/message' && event?.surfaceOp === 'append') {
3821
+ // The coordinator's plain-text user echo is suppressed for mirrored
3822
+ // turns, so the card carries the question itself: a quoted block at
3823
+ // the top keeps the DM self-contained and readable in history.
3824
+ const text = textFromHarnessContent(event?.data?.content);
3825
+ if (text.trim()) {
3826
+ const excerpt = text.length > 400 ? `${text.slice(0, 399)}…` : text;
3827
+ await this.#appendStepCardUpdate(
3828
+ key, openId, null,
3829
+ { kind: 'message', text: `> 👤 **我问:**${excerpt.replaceAll('\n', '\n> ')}` },
3830
+ { billable: false },
3831
+ );
3832
+ }
3833
+ return;
3834
+ }
3835
+ if (type === 'tool/call') {
3836
+ // Align with the ask-callback semantics: a draft proven interim by a
3837
+ // tool call folds into the thinking panel instead of being overwritten
3838
+ // by the next draft.
3839
+ if (this.#sessionSyncPendingStep.has(sessionId)) {
3840
+ this.#morphStepCardAnswerToNote(key, this.#sessionSyncPendingStep.get(sessionId));
3841
+ this.#sessionSyncPendingStep.delete(sessionId);
3842
+ }
3843
+ await this.#appendStepCardUpdate(
3844
+ key, openId, null,
3845
+ this.#stepCardToolBlock({
3846
+ name: event?.data?.name ?? '',
3847
+ arguments: typeof event?.data?.arguments === 'string' ? event.data.arguments : '',
3848
+ }),
3849
+ { billable: false },
3850
+ );
3851
+ return;
3852
+ }
3853
+ if (type === 'assistant/message') {
3854
+ const text = textFromHarnessContent(event?.data?.message?.content);
3855
+ if (text.trim()) {
3856
+ this.#sessionSyncPendingStep.set(sessionId, text);
3857
+ this.#streamStepCardAnswer(key, openId, null, text);
3858
+ }
3859
+ return;
3860
+ }
3861
+ if (type === 'turn/end') {
3862
+ this.#sessionSyncTargets.delete(sessionId);
3863
+ this.#sessionSyncPendingStep.delete(sessionId);
3864
+ releaseSessionSyncMirror(sessionId, card?.sessionSyncTargetId ?? '');
3865
+ await this.#finishStepCard(key, {
3866
+ stopped: event?.data?.reason?.kind === 'aborted',
3867
+ answerText: null,
3868
+ });
3869
+ // Clear AFTER the seal: the render chain may still write mirror state
3870
+ // while it finishes, so clearing earlier would be resurrected by the
3871
+ // trailing setMirror from the last successful render.
3872
+ await this.#state.clearMirror?.(sessionId);
3873
+ return;
3874
+ }
3875
+ }
3876
+
3576
3877
  #onHarnessEvent({ sessionId, event }) {
3577
3878
  if (this.#signal?.aborted
3578
3879
  || !sessionId
3579
3880
  || !event
3580
3881
  || typeof event !== 'object'
3581
- || event.type !== 'turn/end'
3582
3882
  || !validEventSeq(event.seq)) return;
3883
+
3884
+ // Session-sync mirror: turns opened OUTSIDE the IM (DSH Web / CLI) are
3885
+ // rendered into the synced DM with the same #stepCards ladder as IM
3886
+ // turns. The mirror consumes EVERY event type (turn/start opens the
3887
+ // card, tool/call and assistant/message feed it, turn/end seals it);
3888
+ // IM-opened turns are skipped — they already own their card via the ask
3889
+ // callbacks. turn/end ALSO continues below for watch completions.
3890
+ if (this.#sessionSyncTargetsFor) {
3891
+ if (event.type === 'turn/end') {
3892
+ console.error('[ss-final] turn/end reached dispatcher:', sessionId);
3893
+ }
3894
+ void this.#queueEventTask(`session-sync\0${sessionId}`, async () => {
3895
+ await this.#feedSessionSyncTurn(sessionId, event);
3896
+ }).catch((error) => {
3897
+ console.error('[ss-final] mirror task failed:', event?.type, error?.message ?? error);
3898
+ });
3899
+ if (event.type !== 'turn/end') return;
3900
+ }
3901
+ if (event.type !== 'turn/end') return;
3583
3902
  // Record before consulting state: /watch may still be resolving its target
3584
3903
  // or waiting for setWatch persistence and therefore have no visible entry.
3585
3904
  this.#recordObservedCompletion(sessionId, event);
@@ -4045,6 +4364,23 @@ export class FeishuHarnessBridge {
4045
4364
  });
4046
4365
  }
4047
4366
 
4367
+ /**
4368
+ * Persist the mirror state for a session-sync card after a successful
4369
+ * render. Plain IM cards (no sessionSyncSessionId) are never recorded —
4370
+ * their lifecycle is owned by the ask path, not the mirror recovery.
4371
+ * lastContent keeps stepStreamCard's raw JSON string (single-encoded).
4372
+ */
4373
+ #persistMirrorState(card, liveBlocks, status) {
4374
+ const sessionId = card.sessionSyncSessionId;
4375
+ if (!sessionId || typeof this.#state.setMirror !== 'function') return;
4376
+ void this.#state.setMirror(sessionId, {
4377
+ chatId: card.chatId,
4378
+ cardIds: card.cardIds,
4379
+ claimedAt: Date.now(),
4380
+ lastContent: stepStreamCard(liveBlocks, { status }),
4381
+ });
4382
+ }
4383
+
4048
4384
  async #renderStepCardNow(chatId, card) {
4049
4385
  if (card.broken) return;
4050
4386
  const chunks = splitStepStreamCardBlocks(card.blocks);
@@ -4061,12 +4397,15 @@ export class FeishuHarnessBridge {
4061
4397
  const id = await this.#sendCard(
4062
4398
  chatId,
4063
4399
  stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
4064
- { replyTo: card.replyToMessageId },
4400
+ card.deliveryViaOpenId
4401
+ ? { receiveIdType: 'open_id' }
4402
+ : { replyTo: card.replyToMessageId },
4065
4403
  );
4066
4404
  card.cardIds.push(id);
4067
4405
  if (isLive) card.messageId = id;
4068
4406
  }
4069
4407
  card.chunkCount = chunks.length;
4408
+ this.#persistMirrorState(card, live, 'running');
4070
4409
  card.lastRenderAt = this.#stepPushClock.now();
4071
4410
  card.renderedAnswerVersion = card.answerVersion ?? 0;
4072
4411
  return;
@@ -4084,12 +4423,15 @@ export class FeishuHarnessBridge {
4084
4423
  const id = await this.#sendCard(
4085
4424
  chatId,
4086
4425
  stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
4087
- { replyTo: card.replyToMessageId },
4426
+ card.deliveryViaOpenId
4427
+ ? { receiveIdType: 'open_id' }
4428
+ : { replyTo: card.replyToMessageId },
4088
4429
  );
4089
4430
  card.cardIds.push(id);
4090
4431
  if (isLive) card.messageId = id;
4091
4432
  }
4092
4433
  card.chunkCount = chunks.length;
4434
+ this.#persistMirrorState(card, live, 'running');
4093
4435
  } else {
4094
4436
  await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
4095
4437
  }
@@ -4101,6 +4443,19 @@ export class FeishuHarnessBridge {
4101
4443
  '[dsh-feishu] step streaming card render failed; the turn continues without it:',
4102
4444
  error?.message ?? String(error),
4103
4445
  );
4446
+ if (card.deliveryViaOpenId) {
4447
+ this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
4448
+ error?.message ?? String(error));
4449
+ // Release the mirror claim at the FIRST failure so the plain-text
4450
+ // coordinator takes over delivery for the rest of the turn (its
4451
+ // user-echo suppression lifts immediately, and its recipients stay
4452
+ // usable for the final answer fallback).
4453
+ const sessionId = card.sessionSyncSessionId;
4454
+ if (sessionId) {
4455
+ releaseSessionSyncMirror(sessionId, card.sessionSyncTargetId ?? '');
4456
+ void this.#state.clearMirror?.(sessionId);
4457
+ }
4458
+ }
4104
4459
  }
4105
4460
  }
4106
4461
 
@@ -4141,7 +4496,9 @@ export class FeishuHarnessBridge {
4141
4496
  const id = await this.#sendCard(
4142
4497
  card.chatId,
4143
4498
  stepStreamCard(groups[index], { status: isLive ? status : 'sealed' }),
4144
- { replyTo: card.replyToMessageId },
4499
+ card.deliveryViaOpenId
4500
+ ? { receiveIdType: 'open_id' }
4501
+ : { replyTo: card.replyToMessageId },
4145
4502
  );
4146
4503
  card.cardIds.push(id);
4147
4504
  }
@@ -4161,7 +4518,9 @@ export class FeishuHarnessBridge {
4161
4518
  const id = await this.#sendCard(
4162
4519
  card.chatId,
4163
4520
  stepStreamCard(chunks[index], { status: isLast ? status : 'sealed' }),
4164
- { replyTo: card.replyToMessageId },
4521
+ card.deliveryViaOpenId
4522
+ ? { receiveIdType: 'open_id' }
4523
+ : { replyTo: card.replyToMessageId },
4165
4524
  );
4166
4525
  card.cardIds.push(id);
4167
4526
  if (isLast) card.messageId = id;
@@ -4499,6 +4858,7 @@ export class FeishuHarnessBridge {
4499
4858
  * (工具参数折叠为代码块);post 失败走既有纯文本降级。
4500
4859
  */
4501
4860
  async #answerWithStepPush(event, key, message, { onAskComplete } = {}) {
4861
+ this.#beginImTurn(key);
4502
4862
  const chatId = event.message.chat_id;
4503
4863
  const messageId = event.message.message_id;
4504
4864
  const text = message.content;
@@ -4506,6 +4866,7 @@ export class FeishuHarnessBridge {
4506
4866
  const markAskComplete = () => {
4507
4867
  if (askCompleted) return;
4508
4868
  askCompleted = true;
4869
+ this.#endImTurn(key);
4509
4870
  onAskComplete?.();
4510
4871
  };
4511
4872
  // 与流式分支一致的提示内容构造:图片与回复引用展开为富提示内容,已接受
@@ -4811,6 +5172,7 @@ export class FeishuHarnessBridge {
4811
5172
  }
4812
5173
 
4813
5174
  async #answerWithStream(event, key, message, { onAskComplete } = {}) {
5175
+ this.#beginImTurn(key);
4814
5176
  const chatId = event.message.chat_id;
4815
5177
  const messageId = event.message.message_id;
4816
5178
  const text = message.content;
@@ -4818,6 +5180,7 @@ export class FeishuHarnessBridge {
4818
5180
  const markAskComplete = () => {
4819
5181
  if (askCompleted) return;
4820
5182
  askCompleted = true;
5183
+ this.#endImTurn(key);
4821
5184
  onAskComplete?.();
4822
5185
  };
4823
5186
  // 分步直推:开关开启且通道支持流式卡时,在构造提示内容之前分流到完整替
@@ -1003,7 +1003,7 @@ function stepPanel(lines, { title, expanded }) {
1003
1003
  };
1004
1004
  }
1005
1005
 
1006
- function stepStatusText(status) {
1006
+ export function stepStatusText(status) {
1007
1007
  if (status === 'completed') return t('已完成');
1008
1008
  if (status === 'stopped') return t('已停止');
1009
1009
  return t('运行中');
@@ -114,6 +114,7 @@ export class FeishuRuntime {
114
114
  #groupTopicReply;
115
115
  #stepPush;
116
116
  #stepPushMode;
117
+ #sessionSyncTargetsFor;
117
118
  #ownerOpenIds;
118
119
  #harness;
119
120
  #state;
@@ -146,6 +147,7 @@ export class FeishuRuntime {
146
147
  groupTopicReply = false,
147
148
  stepPush = false,
148
149
  stepPushMode = 'post',
150
+ sessionSyncTargetsFor = null,
149
151
  ownerOpenId,
150
152
  ownerOpenIds,
151
153
  harness,
@@ -184,6 +186,9 @@ export class FeishuRuntime {
184
186
  this.#groupTopicReply = groupTopicReply === true;
185
187
  this.#stepPush = stepPush === true;
186
188
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
189
+ this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
190
+ ? sessionSyncTargetsFor
191
+ : null;
187
192
  this.#ownerOpenIds = normalizedOwners;
188
193
  this.#harness = harness;
189
194
  this.#state = state;
@@ -314,6 +319,7 @@ export class FeishuRuntime {
314
319
  groupTopicReply: this.#groupTopicReply,
315
320
  stepPush: this.#stepPush,
316
321
  stepPushMode: this.#stepPushMode,
322
+ sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
317
323
  repair: this.#repair,
318
324
  replyTimeoutMs: this.#replyTimeoutMs,
319
325
  // Interaction cards (approval/question buttons) are on by default.
@@ -10,6 +10,7 @@ const EMPTY_STATE = Object.freeze({
10
10
  deferred: {},
11
11
  includeArchivedSessions: false,
12
12
  topics: {},
13
+ mirrors: {},
13
14
  });
14
15
 
15
16
  /** One conversation key may watch at most this many sessions. */
@@ -46,6 +47,7 @@ export class StateStore {
46
47
  ? parsed.includeArchivedSessions
47
48
  : false,
48
49
  topics: parsed.topics && typeof parsed.topics === 'object' ? parsed.topics : {},
50
+ mirrors: parsed.mirrors && typeof parsed.mirrors === 'object' ? parsed.mirrors : {},
49
51
  };
50
52
  } catch (error) {
51
53
  if (error?.code !== 'ENOENT') throw error;
@@ -59,6 +61,21 @@ export class StateStore {
59
61
  patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
60
62
  removeDeferred(id) { return this.#deferred.remove(id); }
61
63
 
64
+ // ── Mirrors (persisted: open session-sync cards, recovered at startup) ──
65
+ setMirror(sessionId, entry) {
66
+ this.#state.mirrors[sessionId] = entry;
67
+ return this.#persist();
68
+ }
69
+
70
+ clearMirror(sessionId) {
71
+ delete this.#state.mirrors[sessionId];
72
+ return this.#persist();
73
+ }
74
+
75
+ mirrorEntries() {
76
+ return Object.entries(this.#state.mirrors ?? {});
77
+ }
78
+
62
79
  sessionFor(key) {
63
80
  return this.#state.sessions[key] ?? null;
64
81
  }
@@ -0,0 +1,29 @@
1
+ export const SET_ALIAS_ENDPOINT = 'bot.alias.set';
2
+ export const MAX_BOT_ALIAS_LENGTH = 80;
3
+
4
+ export function validateBotAlias(value) {
5
+ if (typeof value !== 'string' || value.trim().length > MAX_BOT_ALIAS_LENGTH
6
+ || /[\u0000-\u001f\u007f]/u.test(value)) {
7
+ throw new TypeError('别名不能包含换行或控制字符,且最多 80 个字符。');
8
+ }
9
+ return value.trim();
10
+ }
11
+
12
+ export function normalizeBotAlias(bot) {
13
+ try {
14
+ const alias = validateBotAlias(bot?.alias);
15
+ return alias && typeof bot.originalName === 'string'
16
+ ? { alias, originalName: bot.originalName }
17
+ : {};
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ export function withBotAlias(bot, alias) {
24
+ if (!bot) return bot;
25
+ const { originalName = bot.name, alias: _alias, ...rest } = bot;
26
+ return alias
27
+ ? { ...rest, originalName, alias, name: alias }
28
+ : { ...rest, name: originalName };
29
+ }