@qzsy/dsh-bridge 0.0.0-stage → 2.18.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 (77) hide show
  1. package/CHANGELOG.md +1107 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +488 -0
  4. package/README.md +509 -2
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +9864 -0
  7. package/client/host-families.js +442 -0
  8. package/client/index.js +6883 -0
  9. package/client/locales.mjs +242 -0
  10. package/client/mobile-styles.js +1299 -0
  11. package/client/mobile-yield.js +219 -0
  12. package/client/panel-locales.mjs +777 -0
  13. package/client/picker-yield.js +77 -0
  14. package/client/resource-url-compat.js +90 -0
  15. package/client/settings-nav-icon.js +105 -0
  16. package/client/theme-tokens.js +49 -0
  17. package/client/tunnel-guard.js +46 -0
  18. package/client/unlock-manager.js +144 -0
  19. package/cordis.patch.yml +4 -0
  20. package/lib/auth/dsh-native-cookie.js +148 -0
  21. package/lib/auth/login-template.js +516 -0
  22. package/lib/auth/manager.js +596 -0
  23. package/lib/bridge-rpc-constants.js +66 -0
  24. package/lib/bridge-rpc.js +722 -0
  25. package/lib/cloudflared-manager.mjs +1277 -0
  26. package/lib/cloudflared-network-args.js +99 -0
  27. package/lib/cloudflared-process-scan.js +153 -0
  28. package/lib/compat.js +172 -0
  29. package/lib/connection-compat.js +115 -0
  30. package/lib/feishu/comment.js +50 -0
  31. package/lib/feishu/gateway.js +644 -0
  32. package/lib/feishu/index.js +432 -0
  33. package/lib/feishu/lark-bundled.mjs +131929 -0
  34. package/lib/feishu/markdown.js +148 -0
  35. package/lib/feishu/node.js +1152 -0
  36. package/lib/feishu/slash-commands.js +255 -0
  37. package/lib/index.js +3554 -0
  38. package/lib/platform/account-ops.js +248 -0
  39. package/lib/platform/activity.js +169 -0
  40. package/lib/platform/ask-user.js +293 -0
  41. package/lib/platform/base.js +147 -0
  42. package/lib/platform/changes-digest.js +93 -0
  43. package/lib/platform/commands.js +267 -0
  44. package/lib/platform/config-restore.js +78 -0
  45. package/lib/platform/conversation-bridge.js +1544 -0
  46. package/lib/platform/dsh-storage.js +222 -0
  47. package/lib/platform/index.js +10 -0
  48. package/lib/platform/instances.js +458 -0
  49. package/lib/platform/manager.js +130 -0
  50. package/lib/platform/message-split.js +229 -0
  51. package/lib/platform/session-catalog.js +405 -0
  52. package/lib/platform/session-config.js +52 -0
  53. package/lib/platform/stream-slices.js +21 -0
  54. package/lib/qq/gateway.js +1107 -0
  55. package/lib/qq/index.js +561 -0
  56. package/lib/qq/node.js +1153 -0
  57. package/lib/qq/qqbot-bundled.mjs +8273 -0
  58. package/lib/restart-helper.mjs +133 -0
  59. package/lib/saas/device-auth.mjs +366 -0
  60. package/lib/saas/tunnel-client.mjs +695 -0
  61. package/lib/security/log-sanitize.js +50 -0
  62. package/lib/security/path-validator.js +195 -0
  63. package/lib/security/rate-limiter.js +93 -0
  64. package/lib/session-strip.js +100 -0
  65. package/lib/sse-keepalive.js +115 -0
  66. package/lib/telegram/gateway.js +643 -0
  67. package/lib/telegram/index.js +255 -0
  68. package/lib/telegram/node.js +410 -0
  69. package/lib/tunnel-client.mjs +537 -0
  70. package/lib/wechat/gateway.js +1000 -0
  71. package/lib/wechat/index.js +282 -0
  72. package/lib/wechat/media.js +302 -0
  73. package/lib/wechat/node.js +360 -0
  74. package/llms.txt +36 -0
  75. package/locale/en.json +6 -0
  76. package/locale/zh.json +6 -0
  77. package/package.json +135 -4
@@ -0,0 +1,1544 @@
1
+ // dsh-bridge 平台无关的会话桥(核心类)
2
+ //
3
+ // 本模块只保留 ConversationBridge 类本身(白名单、会话生命周期、审批桥、
4
+ // 出站 digest/心跳、入站路由入口)。拆分出去的模块:
5
+ // - message-split.js 出站分块 + [SEND_FILE] 指令解析(纯函数)
6
+ // - dsh-storage.js DSH 私有存储读取兜底(workspace.json / projcache)
7
+ // - session-catalog.js 会话目录组织 / 渲染 / 格式化
8
+ // - commands.js 斜杠命令解释器(routeCommand)
9
+ //
10
+ // 平台相关的部分由子类(或组合)提供:
11
+ // - sendText(text, opts) / sendTyping(state):向当前 peer 发送
12
+ // - extractTextFrom(message):从平台消息提取文本
13
+ // - isGroupMessage(message):判断群消息
14
+ // - handlePlatformInbound(message):消息解析(返回 { senderId, text, isGroup } 或 null)
15
+ //
16
+ // 本类不直接依赖任何 IM 协议,只消费 DSH 官方服务:
17
+ // ctx.sessions / ctx.agents / ctx.approval / ctx.sessionPersistence / ctx.workspaceRegistry
18
+
19
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
20
+ import { randomUUID } from 'node:crypto'
21
+ import os from 'node:os'
22
+ import fsp from 'node:fs/promises'
23
+ import {
24
+ formatChangesDigest,
25
+ buildUnifiedPatch,
26
+ changesPatchFileName,
27
+ } from './changes-digest.js'
28
+ import { stat } from 'node:fs/promises'
29
+ import path, { normalize, isAbsolute } from 'node:path'
30
+ import { resolveFilePath } from './message-split.js'
31
+ import { splitForIM, textOfAssistantMessage, extractAndStripSendFileDirectives, extractFilePathsFromText, isPathAllowedForSend } from './message-split.js'
32
+ import { routeCommand } from './commands.js'
33
+ import { peekCtxProperty } from './dsh-storage.js'
34
+ import {
35
+ formatAskPrompt,
36
+ formatAnswerAck,
37
+ parseAskReply,
38
+ validateAnswerBatch,
39
+ canRenderAsButtons,
40
+ } from './ask-user.js'
41
+ import { formatActivityLine, activityParts } from './activity.js'
42
+ import { listSessions, listWorkspaces, validateWorkspacePath, renderSessions, sessionsInDisplayOrder, describeTurnEnd, helpText, fmtTime, fmtSessionId, sessionLabel, sessionHeaderOf } from './session-catalog.js'
43
+
44
+ export { textOfAssistantMessage } from './message-split.js'
45
+
46
+ /**
47
+ * 读取本轮改动摘要的最大尝试次数。
48
+ *
49
+ * 宿主的 dsh-workspace-changes 顺序是:
50
+ * const event = session.append('workspace/changes', ...) ← append 同步广播给我们
51
+ * this.records.set(event.seq, { summary }) ← 广播之后才登记
52
+ * 因此第一次(微任务后)通常就命中;留几次 setImmediate 重试,
53
+ * 用于防御宿主未来在中间插入异步步骤。
54
+ */
55
+ const CHANGES_SUMMARY_MAX_ATTEMPTS = 4
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // 出站分块(通用:按平台 maxMessageChars 硬分块 + 保留 fenced code block)
59
+ // ---------------------------------------------------------------------------
60
+
61
+
62
+ function digestLine(session) {
63
+ const open = describeOpenTurn(session?.events)
64
+ if (!open) return null
65
+ const steps = open.tools > 0 ? `${open.tools} 次工具调用` : '思考中'
66
+ const last = open.lastTool ? ` | 最近: ${open.lastTool}` : ''
67
+ return `[处理中] 第 ${open.turn} 轮 | ${steps}${last}`
68
+ }
69
+
70
+ /**
71
+ * 判断会话是否有**未闭合**的 turn(即正在跑任务),返回结构化描述或 null。
72
+ * 依据:事件流里最后一次 turn/start 之后没有配对的 turn/end。
73
+ * 这是 Web/IM「正在思考 / 工具调用中」的同一判据;消息摘要与重启前探测共用它,
74
+ * 避免两处各写一套导致结论不一致。
75
+ *
76
+ * @param {Array} events 会话事件流(session.events)
77
+ * @returns {{turn:number, tools:number, lastTool:(string|undefined)}|null}
78
+ */
79
+ export function describeOpenTurn(events) {
80
+ let turn = 0
81
+ let tools = 0
82
+ let lastTool = undefined
83
+ let inTurn = false
84
+ for (const event of events ?? []) {
85
+ if (event.type === 'turn/start') {
86
+ turn = event.data?.turn ?? 0
87
+ inTurn = true
88
+ tools = 0
89
+ lastTool = undefined
90
+ } else if (event.type === 'turn/end') {
91
+ inTurn = false
92
+ } else if (event.type === 'tool/call' && inTurn) {
93
+ tools += 1
94
+ lastTool = event.data?.name
95
+ }
96
+ }
97
+ if (!inTurn || turn === 0) return null
98
+ return { turn, tools, lastTool }
99
+ }
100
+
101
+ function summarizeError(error) {
102
+ if (error && typeof error === 'object' && 'message' in error) {
103
+ return String(error.message).slice(0, 200)
104
+ }
105
+ return String(error).slice(0, 200)
106
+ }
107
+
108
+ function sleep(ms) {
109
+ return new Promise((resolve) => setTimeout(resolve, ms))
110
+ }
111
+
112
+ /**
113
+ * 判断某个路径是否为可用的绝对目录。
114
+ * 用于校验从配置恢复出来的 cwd:DSH 只拒绝非绝对路径,不会替调用方创建目录,
115
+ * 不校验就会把会话建在一个不存在的 cwd 上。
116
+ * @param {unknown} path
117
+ * @returns {Promise<boolean>}
118
+ */
119
+ async function isUsableDirectory(path) {
120
+ if (typeof path !== 'string' || path.length === 0 || !isAbsolute(path)) return false
121
+ try {
122
+ return (await stat(path)).isDirectory()
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // 会话桥基类
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export class ConversationBridge {
133
+ /**
134
+ * @param {object} opts
135
+ * @param {object} opts.ctx Cordis 上下文
136
+ * @param {object} opts.logger 日志器
137
+ * @param {object} [opts.config] 已持久化配置(allowFrom/间隔/活动会话等)
138
+ * @param {object} opts.platform 所属 Platform 实例(提供 accountId/capabilities)—— 必需
139
+ * @param {(senderId: string) => void} [opts.onFirstSender]
140
+ * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
141
+ */
142
+ constructor({ ctx, logger, config = {}, platform, onFirstSender, onActiveSessionChange } = {}) {
143
+ if (!platform) {
144
+ throw new Error('ConversationBridge requires a platform instance')
145
+ }
146
+
147
+ this.ctx = ctx
148
+ this.logger = logger
149
+ this.platform = platform
150
+ this.onFirstSender = onFirstSender
151
+ this.onActiveSessionChange = onActiveSessionChange
152
+
153
+ const maxChars = platform.capabilities?.maxMessageChars ?? 2000
154
+ const rawMax = Number(config.maxMessageChars)
155
+ const safeMaxChars = (Number.isFinite(rawMax) && rawMax > 0) ? rawMax : maxChars
156
+ this.config = {
157
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
158
+ digestIntervalSec: config.digestIntervalSec ?? 300,
159
+ approvalTimeoutSec: config.approvalTimeoutSec ?? 600,
160
+ // 轮次中"活动状态"推送的最小间隔(秒)。默认 **1 秒**:
161
+ // - 状态是**原地更新卡片**(不产生新消息),"刷屏"风险本就很小;
162
+ // - 飞书 SDK 自身还按 100ms 限流(DEFAULT_THROTTLE_MS),底层已有保护;
163
+ // - 早先默认 5 秒过于激进:一轮里密集的工具调用会被合并成首尾两条,
164
+ // 用户反馈"只有第一句有状态,中间没有"(2026-10-16 实测:5 次调用只推 1 次)。
165
+ // 设为 0 则每次变化都推(仍受平台/ SDK 限流保护)。
166
+ activityIntervalSec: config.activityIntervalSec ?? 1,
167
+ maxMessageChars: safeMaxChars,
168
+ sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
169
+ cwd: config.cwd,
170
+ agentPreset: config.agentPreset,
171
+ agentProvider: config.agentProvider,
172
+ agentModel: config.agentModel,
173
+ // 群聊自动授权开关:默认关闭,防止任意陌生群 @机器人 即获得访问权(T2.5)
174
+ groupAutoApprove: config.groupAutoApprove === true,
175
+ // 本轮改动摘要:默认开启——IM 用户也能看到"这轮改了哪些文件"(数据来自宿主 workspaceChanges)
176
+ changesDigest: config.changesDigest !== false,
177
+ // 实时活动状态(卡片顶部显示「正在运行命令…」):**默认关闭**。
178
+ // 用户反馈(2026-10-17):多轮调试后仍觉观感不佳,明确要求"移出状态显示,只要正文"。
179
+ // 因此改为 opt-in:默认不推送任何状态,卡片只有正文;想用可在设置页勾选。
180
+ showActivity: config.showActivity === true,
181
+ // 实时插话模式(Agent 忙碌时新消息在下一步边界注入):默认关闭(保持既有排队行为,按需开启)。
182
+ // 语义以 DSH 的 agent.steer 为准——它是「提交给最近的 step」,不中止正在执行的
183
+ // 步骤或工具调用;空闲时同样会起一轮(runtime-types.d.ts 的 AgentInbox.steer 注释)。
184
+ steerMode: config.steerMode === true,
185
+ // 预热状态卡的建卡延迟(issue #77 ②):仅飞书卡片用;默认值定义在 feishu/node.js,
186
+ // 这里透传是为了让上层/测试能显式覆盖(0 = 立即建卡)。
187
+ warmCardDelayMs: config.warmCardDelayMs,
188
+ // 单聊流式输出(QQ 的 stream_messages):**默认开启**,只有显式 false 才关闭。
189
+ // 必须在这里显式透传——this.config 是白名单构造,漏一个字段上层就永远改不动它
190
+ // (此前 streamReply 就是这样被静默丢弃的,导致设置页开关完全无效)。
191
+ streamReply: config.streamReply !== false,
192
+ }
193
+
194
+ // 从配置恢复活动会话(v0.2.1:重启后保持会话)
195
+ // 注意:不在构造函数里调用 _pickDefaultSession(),由 loadConfig 回调负责恢复,避免竞态覆盖
196
+ this.activeSessionId = config.activeSessionId ?? null
197
+ this.peerId = null
198
+ this.pending = new Map() // number -> PendingApproval
199
+ this.approvalCounter = 0
200
+ this.disposers = []
201
+ // 可选服务 workspaceChanges 的句柄(由 _bindChangesService 动态注入后写入)
202
+ this._changesSvc = null
203
+
204
+ // 配置恢复状态追踪(用于防止 handleInbound 在配置加载前处理消息)
205
+ this._restoringConfig = null
206
+ this._restoringSessionMap = new Map() // sessionId -> Promise<Agent|null>
207
+
208
+ // T2.3:sessionId -> { outboundPeer, senderId }
209
+ // 记录每轮对话的发起者:出站事件流(assistant/turn/end/心跳/SEND_FILE/审批)绑定到
210
+ // 发起轮次的 peer,而不是"最近一条入站的 peer",防止 A 任务进行中 B 来消息把回复串到 B 的窗口
211
+ this._turnPeers = new Map()
212
+ // 本轮改动摘要(workspace/changes):sessionId → 最近一次的 { seq, turn }
213
+ this._lastChanges = new Map()
214
+ // 已播报过的 turn(宿主对同一轮可能追加多次事件)
215
+ this._changesAnnounced = new Map()
216
+ // turn/end 之后 _turnPeers 会被清空,而 workspace/changes 事件在其后到达 → 为摘要保留出站绑定
217
+ this._lastTurnPeers = new Map()
218
+ // 入站串行队列:handleInbound 内部读写 this.peerId 等共享状态,并发入站会造成串扰
219
+ this._inboundQueue = Promise.resolve()
220
+ // Agent 决策问答(ask_user_question)待答登记:编号 → entry。
221
+ // 与审批 pending 分开:问答超时后问题仍可迟答,条目要留着。
222
+ this._askPending = new Map()
223
+ this._askCounter = 0
224
+
225
+ this._attachOutbound()
226
+ this._attachApprovalBridge()
227
+ this._attachAskUserBridge()
228
+ }
229
+
230
+ get gatewayAccountId() {
231
+ return this.platform?.accountId ?? ''
232
+ }
233
+
234
+ activeSession() {
235
+ if (!this.activeSessionId) return undefined
236
+ return this.ctx.sessions?.get(this.activeSessionId)
237
+ }
238
+
239
+ activeAgent() {
240
+ if (!this.activeSessionId) return undefined
241
+ return this.ctx.agents?.get(this.activeSessionId)
242
+ }
243
+
244
+ ownsAgent(agent) {
245
+ return this.activeSessionId !== null && agent?.session?.id === this.activeSessionId
246
+ }
247
+
248
+ isAllowed(senderId) {
249
+ if (!Array.isArray(this.config.allowFrom)) return false
250
+ if (this.config.allowFrom.length === 0) return false
251
+ return this.config.allowFrom.includes(senderId)
252
+ }
253
+
254
+ setActiveSession(session) {
255
+ this.stopAllHeartbeats()
256
+ this._dropDigestState(this.activeSessionId)
257
+ this.activeSessionId = session.id
258
+ try { this.onActiveSessionChange?.(session.id) } catch { /* 持久化失败不致命 */ }
259
+ }
260
+
261
+ // 仅按 ID 设置活动会话(持久化会话可能没有内存 session 对象),
262
+ // 发消息时通过 re-attach 逻辑拉起 agent。
263
+ setActiveSessionById(id) {
264
+ if (!id) return
265
+ this.stopAllHeartbeats()
266
+ this._dropDigestState(this.activeSessionId)
267
+ this.activeSessionId = id
268
+ try { this.onActiveSessionChange?.(id) } catch { /* 持久化失败不致命 */ }
269
+ }
270
+
271
+ // 释放指定会话的 digest 状态(活动会话切换后旧状态不再使用)
272
+ _dropDigestState(sessionId) {
273
+ if (sessionId && this._digestState) this._digestState.delete(sessionId)
274
+ }
275
+
276
+ async _pickDefaultSession() {
277
+ const sessions = await listSessions(this)
278
+ if (sessions.length > 0) this.setActiveSessionById(sessions[0].id)
279
+ }
280
+
281
+ // ---- Agent preset 组装(DSH ≥0.1.5 的 agent plane 挂载)----
282
+ //
283
+ // DSH 0.1.5 起把 agent plane(工具、提示词段、技能目录)整体挪到了 agent preset 后面:
284
+ // 宿主 composition 里 tool-fs / tool-bash / tool-jobs / tool-skill 等行被显式禁用,
285
+ // 改由每个会话在创建时挂载 preset 提供。挂载只发生在 agents.create / agents.resume 的
286
+ // setup 回调里——DSH 自己的会话控制器就是 `resolve()` + `mount(agentCtx, id)`;
287
+ // 只往 meta 里写 agentPreset 不会挂载任何东西,该会话会落在"空 preset 层",
288
+ // 表现为工具集残缺(dsh-agent-presets 会打 "published without joining an agent preset" 警告)。
289
+ //
290
+ // 旧版 DSH(agent plane 仍在宿主层、无 agentPresets 服务)保持原行为,不传 setup。
291
+ // 配置或历史里记着一个本机不存在的 preset 时,回退到 DSH 默认 preset 并告警,
292
+ // 绝不在空层上建会话,也不把 not-found 错误直接甩给用户。
293
+ async _composeAgentPreset(presetId) {
294
+ // 空串/纯空白视为"未配置"(设置页清除该字段后就是空串),交给 DSH 默认预设
295
+ const wanted = typeof presetId === 'string' && presetId.trim().length > 0 ? presetId.trim() : undefined
296
+ const presets = this.ctx.get?.('agentPresets')
297
+ if (!presets?.resolve || !presets?.mount) {
298
+ return { agentPreset: wanted, setup: undefined, fallbackFrom: undefined }
299
+ }
300
+ let resolvedId
301
+ let fallbackFrom
302
+ try {
303
+ resolvedId = (await presets.resolve(wanted)).id
304
+ } catch (err) {
305
+ if (!wanted) throw err
306
+ const reason = err instanceof Error ? err.message : String(err)
307
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] agent preset "${wanted}" unavailable (${reason}); falling back to the default preset`)
308
+ resolvedId = (await presets.resolve(undefined)).id
309
+ fallbackFrom = wanted
310
+ }
311
+ return {
312
+ agentPreset: resolvedId,
313
+ fallbackFrom,
314
+ setup: async (agentCtx) => { await presets.mount(agentCtx, resolvedId) },
315
+ }
316
+ }
317
+
318
+ async createSession(prompt, cwdOverride) {
319
+ // 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
320
+ const sessionId = `session-${randomUUID()}`
321
+ try {
322
+ const workspaces = await listWorkspaces(this)
323
+ const defaultWsPath = workspaces.length > 0 ? workspaces[0].path : process.cwd()
324
+ let cwd = cwdOverride || this.config.cwd || defaultWsPath
325
+ let cwdNotice = ''
326
+ // 校验指定目录存在且是目录,防止路径遍历
327
+ if (cwdOverride) {
328
+ const validation = await validateWorkspacePath(this, cwdOverride)
329
+ if (!validation.valid) {
330
+ await this.sendText(validation.error)
331
+ return
332
+ }
333
+ } else if (this.config.cwd && !(await isUsableDirectory(this.config.cwd))) {
334
+ // 配置里恢复出来的工作区不可用(换机器、目录被删、写成相对路径等):
335
+ // 回落首个已注册工作区并明确告知——DSH 只拒绝非绝对路径,不会替我们建目录,
336
+ // 静默沿用会让会话建在一个不存在的 cwd 上
337
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] configured cwd "${this.config.cwd}" is not a usable directory; falling back to ${defaultWsPath}`)
338
+ cwdNotice = `\n- ⚠️ 配置的工作区 \`${this.config.cwd}\` 不可用,本次会话改用 \`${defaultWsPath}\``
339
+ cwd = defaultWsPath
340
+ }
341
+ const composition = await this._composeAgentPreset(this.config.agentPreset)
342
+ const meta = { cwd }
343
+ if (composition.agentPreset) meta.agentPreset = composition.agentPreset
344
+ const agentOptions = {}
345
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
346
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
347
+ if (!agentOptions.provider || !agentOptions.model) {
348
+ try {
349
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
350
+ if (def) {
351
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
352
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
353
+ }
354
+ } catch { /* 默认模型服务不可用则忽略,交由 DSH 自行处理 */ }
355
+ }
356
+ // 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
357
+ // 旧版 DSH 没有 setup 概念,仅在确有 preset 组装时才带上该键
358
+ const createOptions = { sessionId, meta, agentOptions }
359
+ if (composition.setup) createOptions.setup = composition.setup
360
+ const handle = await this.ctx.agents.create(createOptions)
361
+ this.setActiveSession(handle.agent.session)
362
+
363
+ // 同步挂载进 DSH 工作区账本 (workspaceRegistry),使新会话在 Web 侧边栏和 /list 中精准归组
364
+ try {
365
+ const reg = this.ctx.workspaceRegistry
366
+ const entities = reg?.list ? (await reg.list()) : []
367
+ const normCwd = normalize(cwd).toLowerCase()
368
+ const match = (entities || []).find((ws) => ws?.path && normalize(ws.path).toLowerCase() === normCwd)
369
+ if (match?.attachSession) await match.attachSession(sessionId)
370
+ } catch { /* 账本写入失败不影响会话创建 */ }
371
+
372
+ if (prompt) {
373
+ const platformName = this.platform?.name || 'IM客户端'
374
+ const promptWithContext = `${prompt}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${platformName}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
375
+ handle.agent.followup(createUserMessage({
376
+ content: [{ type: 'text', text: promptWithContext }],
377
+ source: { kind: 'user' },
378
+ }))
379
+ }
380
+ const wsDetail = cwd ? `\n- **工作区**:\`${cwd}\`${cwdNotice}` : ''
381
+ // 配置的 preset 在本机不存在时,回退必须让用户看得见(否则又会变成"配了不生效")
382
+ const presetNotice = composition.fallbackFrom
383
+ ? `\n- ⚠️ 配置的 Agent 预设 \`${composition.fallbackFrom}\` 在本机不存在,本次会话使用默认预设 \`${composition.agentPreset}\``
384
+ : ''
385
+ const hint = prompt ? '' : '\n\n> 💡 发送任意消息即可直接与 Agent 对话。'
386
+ await this.sendText(`✓ **已创建新会话**\n- **会话 ID**:\`${fmtSessionId(handle.agent.session.id)}\`${wsDetail}${presetNotice}${hint}`)
387
+ } catch (error) {
388
+ await this.sendText(`❌ **创建会话失败**:${error instanceof Error ? error.message : String(error)}`)
389
+ }
390
+ }
391
+
392
+ // ---- 审批 ----
393
+
394
+ nextApprovalNumber() {
395
+ this.approvalCounter += 1
396
+ return this.approvalCounter
397
+ }
398
+
399
+ registerApproval(number, approval) {
400
+ this.pending.set(number, approval)
401
+ }
402
+
403
+ clearApproval(number) {
404
+ const entry = this.pending.get(number)
405
+ if (entry) {
406
+ clearTimeout(entry.timer)
407
+ this.pending.delete(number)
408
+ }
409
+ }
410
+
411
+ // 取消并拒绝审批(用于 dispose 清理)
412
+ cancelApproval(number) {
413
+ const entry = this.pending.get(number)
414
+ if (entry) {
415
+ clearTimeout(entry.timer)
416
+ this.pending.delete(number)
417
+ entry.resolve('rejected') // 触发 Promise,防止泄漏
418
+ }
419
+ }
420
+
421
+ resolveApproval(text, senderId = null) { const entries = [...this.pending.entries()]
422
+ if (entries.length === 0) return false
423
+ let outcome
424
+ if (text === '/yes') outcome = 'allowed-once'
425
+ else if (text === '/no') outcome = 'rejected'
426
+ if (outcome) {
427
+ const [number, entry] = entries[entries.length - 1]
428
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
429
+ if (!this.pending.has(number)) return false
430
+ if (!this._approvalAllowed(entry, senderId)) return false
431
+ this.clearApproval(number)
432
+ entry.resolve(outcome)
433
+ return true
434
+ }
435
+ if ((text === '1' || text === '2') && entries.length === 1) {
436
+ const [number, entry] = entries[0]
437
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
438
+ if (!this.pending.has(number)) return false
439
+ if (!this._approvalAllowed(entry, senderId)) return false
440
+ this.clearApproval(number)
441
+ entry.resolve(text === '1' ? 'allowed-once' : 'rejected')
442
+ return true
443
+ }
444
+ return false
445
+ }
446
+
447
+ // 审批发起者校验:仅允许发起审批时的 peer(handleInbound 的 sender)决议,
448
+ // 防止群聊/多用户场景下其他成员代批工具执行。senderId 为 null 时放行(内部路径)。
449
+ _approvalAllowed(entry, senderId) {
450
+ if (senderId == null) return true
451
+ if (entry.peerId && entry.peerId !== senderId) {
452
+ this.logger?.warn?.(`[dsh-bridge ${this.platform?.id}] approval #${entry.number} blocked: sender ${senderId} is not the initiator ${entry.peerId}`)
453
+ return false
454
+ }
455
+ return true
456
+ }
457
+
458
+ // ---- 决策问答(ask_user_question)待答登记 ----
459
+ //
460
+ // 与审批的 pending 分开存:两者的生命周期语义不同——审批超时即拒绝并清除;
461
+ // 问答超时后问题**仍可迟答**,条目要留着(宿主侧的 askTimed 也是这个语义)。
462
+
463
+ nextAskNumber() {
464
+ this._askCounter = (this._askCounter ?? 0) + 1
465
+ return this._askCounter
466
+ }
467
+
468
+ registerAsk(number, entry) {
469
+ this._askPending.set(number, entry)
470
+ }
471
+
472
+ clearAsk(number) {
473
+ // 无自建定时器:超时由宿主驱动(见 _attachAskUserBridge 的说明),这里只移除条目
474
+ this._askPending.delete(number)
475
+ }
476
+
477
+ /** 当前是否有待作答的问题(供指令层与帮助文案判断)。 */
478
+ hasPendingAsk() {
479
+ return this._askPending.size > 0
480
+ }
481
+
482
+ /**
483
+ * 用文字回复解答待答问题(`/ask …`)。
484
+ *
485
+ * 支持两种时机:
486
+ * 1. 前台仍在等(未超时):直接 resolve 桥内 Promise,走正常返回路径;
487
+ * 2. 前台已超时(宿主已收到 pending):改用宿主 `userQuestions.answer()` 把答案
488
+ * 作为"迟到的回复"注入会话(这是宿主为 pending 场景提供的正式通道)。
489
+ *
490
+ * @param {string} text 用户消息原文
491
+ * @param {string|null} senderId 发送者(校验只允许发起者作答)
492
+ * @returns {Promise<boolean>} 是否命中并处理了待答问题
493
+ */
494
+ async resolveAsk(text, senderId = null) {
495
+ if (this._askPending.size === 0) return false
496
+ // 取最近一条(与审批一致:多待答时按后进先出,便于用户就近作答)
497
+ const [number, entry] = [...this._askPending.entries()].pop()
498
+ // 作答者校验与审批同模型:单聊限定发起者本人;群聊允许群内成员
499
+ if (!entry.isGroup && senderId != null && entry.peerId && entry.peerId !== senderId) {
500
+ this.logger?.warn?.(`[dsh-bridge ${this.platform?.id}] ask #${number} blocked: sender ${senderId} is not the initiator ${entry.peerId}`)
501
+ return false
502
+ }
503
+ const parsed = parseAskReply(text, entry.questions)
504
+ if (!parsed.ok) {
505
+ // 解析失败时明确反馈,避免用户以为"发出去了"(手机端尤其需要)。
506
+ // 这里**await**(而非 void):指令层依赖"已消费该消息"的确定性,
507
+ // 且测试/排障需要反馈已落地的时序保证。
508
+ await this.sendText(`⚠️ 没能理解你的回复:${parsed.error}\n\n> 发送 \`/ask\` 可重新查看当前问题与作答方式。`, entry.sendOpts).catch(() => {})
509
+ return true // 已消费该消息,不再当成普通聊天喂给模型
510
+ }
511
+
512
+ if (!entry.detached) {
513
+ // 路径 1:前台仍在等 → 直接 resolve,由桥的返回值把答案交回宿主
514
+ entry.resolve({ answers: parsed.answers })
515
+ return true
516
+ }
517
+
518
+ // 路径 2:前台已结束(超时变 pending / 调用被中止)→ 用宿主的**迟到回复**通道
519
+ // userQuestions.answer(agent, callId, batch):宿主会把它作为 user-question-reply
520
+ // 注入会话。注意宿主对"超时后 pending"正是为此提供的入口(见其 answer() 实现对
521
+ // continued 问题的校验)。
522
+ const uq = peekCtxProperty(this.ctx, 'userQuestions') ?? this.ctx.get?.('userQuestions')
523
+ if (!uq?.answer || !entry.agent || !entry.callId) {
524
+ void this.sendText('⚠️ 该问题已超时,且当前宿主不支持补答;请在 Web 面板作答。', entry.sendOpts)
525
+ return true
526
+ }
527
+ try {
528
+ const stillOpen = uq.answer(entry.agent, entry.callId, { answers: parsed.answers })
529
+ if (stillOpen === false) {
530
+ this.clearAsk(number)
531
+ void this.sendText('⚠️ 该问题已结束(可能已在别处作答),本次回复未提交。', entry.sendOpts)
532
+ return true
533
+ }
534
+ this.clearAsk(number)
535
+ void this.sendText(formatAnswerAck(parsed.answers, entry.questions, { number }), entry.sendOpts)
536
+ } catch (err) {
537
+ // 宿主会抛 BAD_ANSWER(批次不合法)、REPLY_QUEUED(已有回复在队列)等,明确告知
538
+ void this.sendText(`⚠️ 提交答案失败:${err?.message ?? err}`, entry.sendOpts)
539
+ }
540
+ return true
541
+ }
542
+
543
+ /** 待答问题编号 → 条目(供平台卡片按钮回调用)。 */
544
+ getAsk(number) {
545
+ return this._askPending.get(Number(number)) ?? null
546
+ }
547
+
548
+ /** 待答问题数量(指令层用它决定是否走"便利作答"路径)。 */
549
+ pendingAskCount() {
550
+ return this._askPending.size
551
+ }
552
+
553
+ /** 重新展示当前待答问题(`/ask` 无参数;用户忘了问题内容时用)。 */
554
+ async resendAsk(senderId = null) {
555
+ if (this._askPending.size === 0) {
556
+ await this.sendText('ℹ️ 当前没有待你回答的问题。')
557
+ return
558
+ }
559
+ const [, entry] = [...this._askPending.entries()].pop()
560
+ if (senderId != null && entry.peerId && entry.peerId !== senderId) {
561
+ await this.sendText('⚠️ 这个问题不是由你发起的,无法作答。')
562
+ return
563
+ }
564
+ const prompt = formatAskPrompt(entry.questions, { number: entry.number })
565
+ const single = entry.questions.length === 1 ? entry.questions[0] : null
566
+ if (entry.useButtons && single && typeof this.sendAskCard === 'function') {
567
+ try {
568
+ const sent = await this.sendAskCard({ peerId: entry.peerId }, single, entry, prompt)
569
+ if (sent) return
570
+ } catch { /* 卡片失败则退回文字 */ }
571
+ }
572
+ await this.sendText(prompt, entry.sendOpts)
573
+ }
574
+
575
+ stopAllHeartbeats() {
576
+ if (this._digestState) {
577
+ for (const state of this._digestState.values()) {
578
+ if (state && state.heartbeat) {
579
+ clearInterval(state.heartbeat)
580
+ state.heartbeat = undefined
581
+ }
582
+ }
583
+ }
584
+ }
585
+
586
+ dispose() {
587
+ this.stopAllHeartbeats()
588
+ for (const disposer of this.disposers) {
589
+ try { disposer() } catch { /* 忽略 */ }
590
+ }
591
+ this.disposers = []
592
+ for (const number of [...this.pending.keys()]) this.cancelApproval(number)
593
+ // 待答问题:resolve 掉桥内 await 的 Promise(否则 dispose 时永久悬挂)
594
+ for (const [number, entry] of [...this._askPending.entries()]) {
595
+ try { entry?.resolve?.({ aborted: true }) } catch { /* 忽略 */ }
596
+ this._askPending.delete(number)
597
+ }
598
+ // 清理所有引用,防止内存泄漏
599
+ this._turnPeers?.clear()
600
+ this._lastChanges?.clear()
601
+ this._changesAnnounced?.clear()
602
+ this._lastTurnPeers?.clear()
603
+ this._inboundQueue = Promise.resolve()
604
+ this.peerId = null
605
+ this.activeSessionId = null
606
+ this._restoringConfig = null
607
+ }
608
+
609
+ // ---- 入站核心(平台无关)----
610
+ //
611
+ // 子类解析出平台消息后调用本方法:
612
+ // await bridge.handleInbound({ senderId, text, isGroup, outboundPeer })
613
+ // - outboundPeer(可选):该平台的"会话级发送目标"(如 QQ 群 { peerId, scope }、
614
+ // Telegram { peerId: chatId }),用于把本轮的出站事件流绑定回发起会话;
615
+ // 不传时出站回退到 this.peerId。
616
+ //
617
+ // 入站按 bridge 串行执行:处理过程会读写共享的 peer/会话状态,并发入站会互相踩踏。
618
+ // 消息处理本身轻量(重活是异步的 turn 事件流),串行代价可忽略。
619
+ //
620
+ // 返回:
621
+ // 'ignored' 消息被忽略(未授权/群消息/空消息)
622
+ // 'routed' 消息已路由到 agent
623
+ handleInbound(message) {
624
+ const task = this._inboundQueue.catch(() => {}).then(() => this._handleInboundSerialized(message))
625
+ this._inboundQueue = task.catch(() => {})
626
+ return task
627
+ }
628
+
629
+ async _handleInboundSerialized({ senderId, text, isGroup = false, outboundPeer, senderLabel = '' }) {
630
+ try {
631
+ // 等待配置恢复完成(防止启动时竞态)
632
+ if (this._restoringConfig) {
633
+ await this._restoringConfig
634
+ }
635
+
636
+ const sender = String(senderId ?? '').trim()
637
+ if (!sender) return 'ignored'
638
+
639
+ if (!this.isAllowed(sender)) {
640
+ // 自动授权:
641
+ // - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
642
+ // - 群聊:白名单为空时随首条消息自动授权;白名单非空时仅在
643
+ // config.groupAutoApprove 显式开启后才授权新群(默认关闭,
644
+ // 防止任意陌生群 @机器人 一次即整群获得访问权)
645
+ const shouldAutoApprove = Boolean(text?.trim()) && (
646
+ this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
647
+ (isGroup && this.config.groupAutoApprove === true)
648
+ )
649
+ if (shouldAutoApprove) {
650
+ this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
651
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
652
+ try {
653
+ await this.onFirstSender?.(sender)
654
+ } catch (err) {
655
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
656
+ }
657
+ } else {
658
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
659
+ }
660
+
661
+ // 如果仍未通过白名单,拒绝处理(防止绕过白名单)
662
+ if (!this.isAllowed(sender)) {
663
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
664
+ return 'ignored'
665
+ }
666
+ }
667
+
668
+ // 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
669
+ if (isGroup && !this.platform?.capabilities?.supportsGroup) {
670
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
671
+ return 'ignored'
672
+ }
673
+
674
+ const fullText = text?.trim() ?? ''
675
+ if (!fullText) {
676
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
677
+ return 'ignored'
678
+ }
679
+
680
+ this.peerId = sender
681
+
682
+ // 记录本轮发起者:出站事件流绑定回发起会话(T2.3)。outboundPeer 由平台节点传入
683
+ // 其"会话级发送目标"(QQ 群/Telegram chat 等),未传则不记录(出站回退 this.peerId)。
684
+ if (this.activeSessionId && outboundPeer) {
685
+ this._turnPeers.set(this.activeSessionId, { outboundPeer, senderId: sender, senderLabel })
686
+ }
687
+
688
+ if (await routeCommand(this, fullText, sender)) return 'routed'
689
+
690
+ let agent = this.activeAgent()
691
+ if (!agent && this.activeSessionId) {
692
+ const sessionId = this.activeSessionId
693
+ if (this._restoringSessionMap.has(sessionId)) {
694
+ try {
695
+ await this._restoringSessionMap.get(sessionId)
696
+ } catch { /* 错误已在原始 Promise 中捕获 */ }
697
+ agent = this.activeAgent()
698
+ } else {
699
+ const restorePromise = (async () => {
700
+ // agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
701
+ try {
702
+ const agentOptions = {}
703
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
704
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
705
+ if (!agentOptions.provider || !agentOptions.model) {
706
+ try {
707
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
708
+ if (def) {
709
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
710
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
711
+ }
712
+ } catch { /* ignore */ }
713
+ }
714
+
715
+ // 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
716
+ // 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
717
+ // 注意:list() 在新版 DSH 返回 `{ header, revision, sizeBytes }` 快照,必须经
718
+ // sessionHeaderOf 取 header;旧写法直接读 entry.id 会恒为 undefined,把已存在
719
+ // 的会话误判成"未持久化",随后 create 抛 `session "..." already exists`(issue #39)。
720
+ let persisted = false
721
+ let storedPreset
722
+ try {
723
+ const entries = await this.ctx.sessionPersistence?.list?.()
724
+ const matched = Array.isArray(entries)
725
+ ? sessionHeaderOf(entries.find((entry) => sessionHeaderOf(entry)?.id === sessionId))
726
+ : undefined
727
+ persisted = Boolean(matched)
728
+ storedPreset = matched?.agentPreset
729
+ } catch { /* 读取失败则按未持久化处理 */ }
730
+
731
+ // 恢复会话按其自身记录的 preset 组装(与 DSH 会话控制器一致);未记录则用桥配置,
732
+ // 都为空时交给 DSH 默认 preset。记录值失效时 _composeAgentPreset 会回退默认并告警。
733
+ const composition = await this._composeAgentPreset(persisted ? (storedPreset ?? this.config.agentPreset) : this.config.agentPreset)
734
+
735
+ let handle
736
+ if (persisted) {
737
+ handle = await this.ctx.agents.resume({
738
+ resumeSessionId: sessionId,
739
+ agentOptions,
740
+ setup: composition.setup,
741
+ })
742
+ } else {
743
+ const meta = { cwd: this.config.cwd || process.cwd() }
744
+ if (composition.agentPreset) meta.agentPreset = composition.agentPreset
745
+ handle = await this.ctx.agents.create({
746
+ sessionId,
747
+ meta,
748
+ agentOptions,
749
+ setup: composition.setup,
750
+ })
751
+ }
752
+ const resumedAgent = handle?.agent
753
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
754
+ return resumedAgent
755
+ } catch (err) {
756
+ const reason = err instanceof Error ? err.message : String(err)
757
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
758
+ // 清除失效的 activeSessionId,避免用户误以为还在旧会话中
759
+ if (this.activeSessionId === sessionId) {
760
+ this.activeSessionId = null
761
+ }
762
+ await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
763
+ return null
764
+ }
765
+ })().finally(() => {
766
+ this._restoringSessionMap.delete(sessionId)
767
+ })
768
+
769
+ this._restoringSessionMap.set(sessionId, restorePromise)
770
+ agent = await restorePromise
771
+ }
772
+ }
773
+ if (!agent) {
774
+ await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
775
+ return 'routed'
776
+ }
777
+
778
+ // 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
779
+ const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
780
+
781
+ const messageValue = createUserMessage({
782
+ content: [{ type: 'text', text: promptWithContext }],
783
+ source: { kind: 'user' },
784
+ })
785
+ const useSteer = this.config.steerMode === true
786
+ if (useSteer && typeof agent.steer === 'function') {
787
+ agent.steer(messageValue)
788
+ } else {
789
+ agent.followup(messageValue)
790
+ }
791
+ await this.sendTyping(1).catch(() => {})
792
+ return 'routed'
793
+ } catch (err) {
794
+ const errMsg = err instanceof Error ? err.message : String(err)
795
+ this.logger?.error?.(`[dsh-bridge ${this.platform.id}] unhandled error in handleInbound: ${errMsg}\n${err instanceof Error ? err.stack : ''}`)
796
+ await this.sendText(`❌ **执行出错**:${errMsg}`).catch(() => {})
797
+ return 'ignored'
798
+ }
799
+ }
800
+
801
+ // ---- 发送(子类覆盖 _sendTextNow,不要覆盖 sendText)----
802
+
803
+ // 出站发送串行队列:所有 sendText 依次执行,杜绝并发分块交错乱序,
804
+ // 以及 QQ 流式 replace 共享 _msgSeq 的并发冲突
805
+ _enqueueSend(task) {
806
+ const queue = (this._sendQueue ??= Promise.resolve())
807
+ const run = queue.then(task, task)
808
+ this._sendQueue = run.catch(() => {})
809
+ return run
810
+ }
811
+
812
+ /** 向当前 peer 发送文本(自动分块 + typing 指示)。经串行队列执行。
813
+ * opts.outboundPeer(可选):覆盖发送目标(T2.3,绑定到发起轮次的会话 peer)。 */
814
+ sendText(text, opts = {}) {
815
+ return this._enqueueSend(() => this._sendTextNow(text, opts))
816
+ }
817
+
818
+ /** 出站目标解析;子类可覆盖(例:飞书单聊必须发 chat_id 而非发送者 open_id) */
819
+ resolveSendTarget(opts = {}) {
820
+ return opts?.outboundPeer?.peerId ?? this.peerId ?? this.config.allowFrom?.[0]
821
+ }
822
+
823
+ async _sendTextNow(text, opts = {}) {
824
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
825
+ const peer = this.resolveSendTarget(opts)
826
+ if (!peer) return
827
+ const chunks = splitForIM(text, this.config.maxMessageChars)
828
+ if (chunks.length === 0) return
829
+ await this.sendTyping(1).catch(() => {})
830
+ try {
831
+ for (let i = 0; i < chunks.length; i++) {
832
+ let result
833
+ try {
834
+ // opts 必须透传给平台适配器:飞书/Telegram 会用 opts.standalone 决定是否
835
+ // 绕过轮次流式卡片(摘要这类轮次级通知必须独立成条)。此前这里丢掉了 opts,
836
+ // 导致适配层永远收不到该标记。
837
+ result = await this.platform.sendText(peer, chunks[i], opts)
838
+ } catch (err) {
839
+ // 适配器可能直接透传 gateway 抛出的异常(telegram/feishu),统一按失败分块处理,
840
+ // 避免调用方 `void this.sendText(...)` 逃逸成 unhandled rejection
841
+ result = { success: false, error: err?.message ?? String(err) }
842
+ }
843
+ if (result && result.success === false) {
844
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] outbound chunk ${i + 1}/${chunks.length} failed: ${result.error}`)
845
+ break
846
+ }
847
+ if (i < chunks.length - 1 && this.config.sendChunkDelayMs > 0) {
848
+ await sleep(this.config.sendChunkDelayMs)
849
+ }
850
+ }
851
+ } finally {
852
+ await this.sendTyping(2).catch(() => {})
853
+ }
854
+ }
855
+
856
+ /** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
857
+ async sendTyping(state) {
858
+ const peer = this.peerId || this.config.allowFrom?.[0]
859
+ if (!this.platform?.sendTyping || peer == null) return
860
+ return this.platform.sendTyping(peer, state)
861
+ }
862
+
863
+ /** 有界 Map 写入(防止长跑/多会话下无限增长) */
864
+ _rememberBounded(map, key, value, max = 32) {
865
+ if (!map || key == null) return
866
+ map.set(key, value)
867
+ while (map.size > max) map.delete(map.keys().next().value)
868
+ }
869
+
870
+ /**
871
+ * 播报"本轮改动"摘要(宿主 workspaceChanges 数据)。
872
+ * 无改动不说话;同一轮重复事件只播一次;宿主没有该服务时静默跳过(兼容老宿主)。
873
+ */
874
+ /**
875
+ * 读取某一轮的改动摘要(宿主 `workspaceChanges.summary(sessionId, seq)`)。
876
+ *
877
+ * 宿主在**广播 `workspace/changes` 之后**才 `records.set(...)` 登记摘要,
878
+ * 而 `session.append()` 是同步广播 → 我们的回调先跑、宿主后登记,
879
+ * 所以同步读必然 undefined。这里「延后读 + 有界重试」:
880
+ * 1. `await Promise.resolve()` 让宿主同 tick 的同步登记先完成(当前实现即可命中);
881
+ * 2. 若仍未命中,用 `setImmediate` 再试至多 CHANGES_SUMMARY_MAX_ATTEMPTS 次
882
+ * (防御宿主未来插入异步;不阻塞 I/O,总耗时有界)。
883
+ *
884
+ * @param {object} svc 宿主 workspaceChanges 服务
885
+ * @param {string} sessionId 会话 id
886
+ * @param {number} seq `workspace/changes` 事件的 seq
887
+ * @returns {Promise<object|undefined>} 摘要;读不到为 undefined(调用方静默跳过本轮)
888
+ */
889
+ async _readChangesSummary(svc, sessionId, seq) {
890
+ await Promise.resolve()
891
+ for (let attempt = 0; attempt < CHANGES_SUMMARY_MAX_ATTEMPTS; attempt++) {
892
+ const summary = svc.summary(sessionId, seq)
893
+ // 命中即返回;已登记但 total=0(本轮确实无改动)也返回,交由调用方判定
894
+ if (summary !== undefined) return summary
895
+ await new Promise((resolve) => {
896
+ if (typeof setImmediate === 'function') setImmediate(resolve)
897
+ else setTimeout(resolve, 0)
898
+ })
899
+ }
900
+ this.logger?.debug?.(
901
+ `[dsh-bridge ${this.platform.id}] workspaceChanges 在 ${CHANGES_SUMMARY_MAX_ATTEMPTS} 次重试内未就绪(seq=${seq}),本轮摘要跳过`,
902
+ )
903
+ return undefined
904
+ }
905
+
906
+ async _announceChanges(session, event, sendOpts = {}) {
907
+ if (this.config.changesDigest !== true) return
908
+ // 服务句柄由 _bindChangesService()(用宿主官方的 ctx.get 读可选服务)在 attach 时取得,
909
+ // 存在 this._changesSvc;拿不到时为 null,本条静默跳过(老宿主属正常)。
910
+ const svc = this._changesSvc ?? peekCtxProperty(this.ctx, 'workspaceChanges')
911
+ const seq = event?.seq
912
+ if (!svc?.summary || !session?.id || seq == null) return
913
+
914
+ // ⚠️ 时序关键(2026-10-24 真机定位):宿主「先广播、后登记」——
915
+ // append() 同步广播 → 我们的回调先跑;records.set() 在广播之后才执行。
916
+ // 因此同步调用 summary() 必然拿到 undefined(改动摘要长期完全不出的根因)。
917
+ // 读取策略见 _readChangesSummary:先让出微任务,再按需 setImmediate 有界重试。
918
+ let summary
919
+ try {
920
+ summary = await this._readChangesSummary(svc, session.id, seq)
921
+ } catch (err) {
922
+ this.logger?.debug?.(`[dsh-bridge ${this.platform.id}] workspaceChanges.summary failed: ${err?.message ?? err}`)
923
+ return
924
+ }
925
+ if (!summary?.total) return
926
+ this._rememberBounded(this._lastChanges, session.id, { seq, turn: summary.turn })
927
+ if (this._changesAnnounced.get(session.id) === summary.turn) return
928
+ this._rememberBounded(this._changesAnnounced, session.id, summary.turn)
929
+
930
+ const text = formatChangesDigest(summary)
931
+ if (text) void this.sendText(text, sendOpts)
932
+ }
933
+
934
+ /**
935
+ * 把当前会话最近一轮改动导出成 .patch 附件(`/diff` 指令)。
936
+ * 数据全部来自宿主 workspaceChanges(读的是宿主缓存的快照),不扫描工作区以外的任何文件。
937
+ */
938
+ async sendSessionDiff(opts = {}) {
939
+ const sessionId = this.activeSessionId
940
+ // 同 _announceChanges:走动态注入拿到的句柄(未 inject 的服务直接取属性会抛错,
941
+ // 且该错误会从宿主事件回调冒泡成 fatal load failure)
942
+ const svc = this._changesSvc ?? peekCtxProperty(this.ctx, 'workspaceChanges')
943
+ const last = sessionId ? this._lastChanges.get(sessionId) : null
944
+ if (!svc?.summary || !svc?.diff || !sessionId || !last) {
945
+ await this.sendText('ℹ️ 当前会话还没有可导出的改动记录(本轮无文件改动,或宿主未提供 changes 服务)。', opts)
946
+ return
947
+ }
948
+ const summary = svc.summary(sessionId, last.seq)
949
+ if (!summary?.total) {
950
+ await this.sendText('ℹ️ 本轮没有文件改动,暂无 diff 可导出。', opts)
951
+ return
952
+ }
953
+
954
+ const diffs = []
955
+ for (let i = 0; i < summary.files.length; i++) {
956
+ const ctl = new AbortController()
957
+ const timer = setTimeout(() => ctl.abort(), 10_000)
958
+ if (typeof timer.unref === 'function') timer.unref()
959
+ try {
960
+ diffs.push(await svc.diff(sessionId, last.seq, i, ctl.signal))
961
+ } catch (err) {
962
+ this.logger?.debug?.(`[dsh-bridge ${this.platform.id}] workspaceChanges.diff(${i}) failed: ${err?.message ?? err}`)
963
+ diffs.push(undefined)
964
+ } finally {
965
+ clearTimeout(timer)
966
+ }
967
+ }
968
+
969
+ const fileName = changesPatchFileName(summary.turn)
970
+ let filePath
971
+ let tempDir
972
+ try {
973
+ // 不往共享临时目录里写**可预测文件名**(CWE-377 / CodeQL js/insecure-temporary-file):
974
+ // os.tmpdir() 通常是 1777 的公共目录,攻击者(同机其它用户或本地低权限进程)可以预先
975
+ // 建好同名文件/符号链接,让我们写到别人的目标上(覆盖文件或借链接写任意路径)。
976
+ // 改为 mkdtemp 建一个 0700 的私有随机目录,patch 写在其内,用完连目录一起删。
977
+ tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-bridge-diff-'))
978
+ filePath = path.join(tempDir, fileName)
979
+ await fsp.writeFile(filePath, buildUnifiedPatch(summary, diffs), { encoding: 'utf8', mode: 0o600 })
980
+ } catch (err) {
981
+ if (tempDir) await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => {})
982
+ await this.sendText(`❌ 导出改动补丁失败(写入临时文件):${err?.message ?? err}`, opts)
983
+ return
984
+ }
985
+
986
+ const target = this.resolveSendTarget(opts)
987
+ try {
988
+ if (typeof this.platform?.sendMediaFile !== 'function' || !target) throw new Error('当前平台不支持发送文件')
989
+ const res = await this.platform.sendMediaFile(target, filePath, opts)
990
+ if (res && res.success === false) throw new Error(res.error || 'sendMediaFile failed')
991
+ await this.sendText(
992
+ `📎 已导出本轮改动补丁:\`${fileName}\`(${summary.total} 个文件,+${Number(summary.added) || 0}/-${Number(summary.deleted) || 0})`,
993
+ opts,
994
+ )
995
+ } catch (err) {
996
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] sendSessionDiff failed: ${err?.message ?? err}`)
997
+ await this.sendText(`❌ 导出改动补丁失败:${err?.message ?? err}`, opts)
998
+ } finally {
999
+ // 附件已上传完成,清理临时文件与私有临时目录。
1000
+ // 必须 await:此前是 `void fsp.rm(...)` 火忘式清理,sendSessionDiff 返回时删除动作
1001
+ // 尚未落地;单测在返回后立刻断言「文件已不存在」,高负载下(全量并发跑测试 /
1002
+ // CI 上磁盘慢)会偶发失败——实测全量 794 例连跑时约 1/3 概率红一次。
1003
+ // 清理失败不应把「已成功导出」变成失败,故 catch 兜住并仅记 debug 日志。
1004
+ try {
1005
+ if (tempDir) await fsp.rm(tempDir, { recursive: true, force: true })
1006
+ else await fsp.unlink(filePath)
1007
+ } catch (err) {
1008
+ this.logger?.debug?.(`[dsh-bridge ${this.platform.id}] 清理 /diff 临时文件失败: ${err?.message ?? err}`)
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ // ---- 出站事件绑定 ----
1014
+
1015
+ /**
1016
+ * 取得可选的 `workspaceChanges` 服务句柄(改动摘要 / `/diff` 的数据源)。
1017
+ *
1018
+ * 三条路的取舍(都有实测依据):
1019
+ * 1. 直接 `this.ctx.workspaceChanges`:未 inject 的服务在跨 realm 读取时 cordis **抛错**
1020
+ * (cannot get property "..." without inject)。该异常发生在宿主 session 事件回调里,
1021
+ * 会冒泡成 `dsh: fatal load failure` → **整个宿主进程退出**(实测 2026-10-16 03:02 / 03:20
1022
+ * 连崩两次)。绝不可用。
1023
+ * 2. `ctx.inject(deps, cb)`:能拿到服务,但它是"注册一个子 fiber"的**装配期** API,
1024
+ * 在构造函数里调用会把子 fiber 的生命周期挂在调用方,且多一层异步回调时序,
1025
+ * 为此付出复杂度不划算(本仓也没别处这么用)。
1026
+ * 3. `ctx.get(name)`:**宿主官方为"可选服务"提供的读取接口**(cordis 内建,
1027
+ * 服务缺失时安全返回 undefined、不抛错)。本仓已有三处这么用
1028
+ * (`this.ctx.get?.('agentPresets')`、`agentDefaultModel`),风格一致。
1029
+ *
1030
+ * 故优先用 `ctx.get`;个别夹具/老宿主没有 `get` 时,退回安全探测;两者都不行则保持 null,
1031
+ * 摘要与 `/diff` 静默降级(绝不外抛——本函数在构造函数路径上,抛错会让插件加载失败)。
1032
+ */
1033
+ _bindChangesService() {
1034
+ const ctx = this.ctx
1035
+ if (!ctx) return
1036
+ let svc
1037
+ try {
1038
+ svc = typeof ctx.get === 'function' ? ctx.get('workspaceChanges') : peekCtxProperty(ctx, 'workspaceChanges')
1039
+ } catch (err) {
1040
+ svc = peekCtxProperty(ctx, 'workspaceChanges')
1041
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] 读取 workspaceChanges 失败,将静默降级: ${err?.message ?? err}`)
1042
+ }
1043
+ this._changesSvc = svc ?? null
1044
+ // 留痕:真机上"摘要 / `/diff` 不工作"最常见的原因就是这个可选服务没绑上,
1045
+ // 而没有日志时用户只能看到"什么也没发生",无法区分"本轮确实没改动"与"服务没绑上"。
1046
+ if (this._changesSvc?.summary) {
1047
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] workspaceChanges 已就绪(改动摘要 / /diff 可用)`)
1048
+ } else {
1049
+ this.logger?.info?.(
1050
+ `[dsh-bridge ${this.platform.id}] 宿主未提供 workspaceChanges:改动摘要与 /diff 将静默跳过(老宿主属正常)`,
1051
+ )
1052
+ }
1053
+ }
1054
+
1055
+ /**
1056
+ * 把"当前活动"状态推给平台(**前缘 + 后缘**节流)。
1057
+ *
1058
+ * 为什么需要:Web 端会显示「正在分析请求 / 正在运行命令 / 正在编辑文件」这类过程状态,
1059
+ * IM 侧此前只有 5 分钟一次的心跳 → 长任务期间用户不知道 agent 在干什么
1060
+ * (2026-10-16 用户反馈,附 Web 端截图对照)。
1061
+ *
1062
+ * 节流策略(**关键修正**):窗口内的变化**延迟补发,绝不丢弃**。
1063
+ * 早先实现是"窗口未到就 return",导致一轮里密集的工具调用只有**第一条**状态被推送、
1064
+ * 中间全部丢失 —— 真机反馈正是"只有第一句有状态,中间没有"
1065
+ * (实测:5 次工具调用 / 共 2.5s,只推送了 1 次)。现改为:
1066
+ * - 距上次推送 ≥ 窗口 → 立即推送(**前缘**);
1067
+ * - 窗口内再有新状态 → 记为"待补发的最新状态",窗口到点补发(**后缘**)。
1068
+ * 期间多次变化合并为一次,既保证"最后的状态一定可见",又不会逐条刷屏。
1069
+ *
1070
+ * @param {object} state 轮次状态(承载节流窗口与待补发状态)
1071
+ * @param {{turn?:number, toolName?:string, args?:object|string}} info
1072
+ * @param {object} sendOpts
1073
+ */
1074
+ _maybePublishActivity(state, info, sendOpts = {}) {
1075
+ if (typeof this.platform?.setActivityStatus !== 'function') return
1076
+ // 开关:**默认关闭**(opt-in)。用户多轮反馈后明确要求"移出状态显示,只要正文"。
1077
+ // 关闭时不推送任何状态、卡片顶部状态行始终为空;正文与其它修复均不受影响。
1078
+ if (this.config.showActivity !== true) return
1079
+ const line = formatActivityLine(info)
1080
+ if (!line) return
1081
+ if (state.lastActivity === line && !state.pendingActivity) return
1082
+
1083
+ const windowMs = Math.max(0, Number(this.config.activityIntervalSec ?? 0) * 1000)
1084
+ const now = Date.now()
1085
+ const elapsed = state.lastActivityAt ? now - state.lastActivityAt : Infinity
1086
+
1087
+ const push = (payload) => {
1088
+ state.lastActivity = payload.line
1089
+ state.lastActivityAt = Date.now()
1090
+ // info 级记录:状态推送此前**完全没有日志**,真机排查"看不到状态"时无从判断
1091
+ // 是"桥没推"还是"平台没收"。这行很轻(每轮至多几次,受节流限制),保留。
1092
+ this.logger?.info?.('[dsh-bridge %s] 活动状态:%s', this.platform?.id ?? '?', payload.line)
1093
+ // 结构化版本:平台可据此安全地做样式(只对受控文案加样式、detail 转义),
1094
+ // 不必解析那行纯文本。老平台忽略第三个参数即可。
1095
+ Promise.resolve(this.platform.setActivityStatus(payload.line, sendOpts, activityParts(payload.info))).catch((err) => {
1096
+ this.logger?.warn?.('[dsh-bridge %s] 活动状态推送失败:%s', this.platform?.id ?? '?', err?.message ?? err)
1097
+ })
1098
+ }
1099
+
1100
+ // 窗口外(或从未推送):立即发(前缘)
1101
+ if (elapsed >= windowMs) {
1102
+ if (state.activityTimer) { clearTimeout(state.activityTimer); state.activityTimer = undefined }
1103
+ state.pendingActivity = null
1104
+ push({ line, info })
1105
+ return
1106
+ }
1107
+
1108
+ // 窗口内:记为**待补发**(只保留最新),窗口到点时补发 —— 绝不丢状态
1109
+ state.pendingActivity = { line, info }
1110
+ if (state.activityTimer) return
1111
+ const wait = Math.max(0, windowMs - elapsed)
1112
+ state.activityTimer = setTimeout(() => {
1113
+ state.activityTimer = undefined
1114
+ const pending = state.pendingActivity
1115
+ state.pendingActivity = null
1116
+ if (pending) push(pending)
1117
+ }, wait)
1118
+ if (typeof state.activityTimer.unref === 'function') state.activityTimer.unref()
1119
+ }
1120
+
1121
+ _attachOutbound() {
1122
+ this._digestState = new Map()
1123
+ this._bindChangesService()
1124
+ const stopHeartbeat = (state) => {
1125
+ if (state && state.heartbeat) {
1126
+ clearInterval(state.heartbeat)
1127
+ state.heartbeat = undefined
1128
+ }
1129
+ }
1130
+ const startHeartbeat = (session, state) => {
1131
+ stopHeartbeat(state)
1132
+ if (this.config.digestIntervalSec <= 0) return
1133
+ state.heartbeat = setInterval(() => {
1134
+ // 1. 必须依然是当前活动会话
1135
+ if (this.activeSessionId !== session.id) {
1136
+ stopHeartbeat(state)
1137
+ return
1138
+ }
1139
+ // 2. 检查会话当前是否真正处于 inTurn 状态中
1140
+ const line = digestLine(session)
1141
+ if (!line) {
1142
+ stopHeartbeat(state)
1143
+ return
1144
+ }
1145
+ // 心跳时同时刷新 typing 状态(微信 typing 只维持 15 秒)
1146
+ if (this.peerId) this.sendTyping(1).catch(() => {})
1147
+ const heartbeatTurn = this._turnPeers.get(session.id)
1148
+ // 评论类会话(飞书云文档评论:无流式、无卡片)不发进度摘要,避免把评论刷成多条
1149
+ if (heartbeatTurn?.outboundPeer?.comment) return
1150
+ void this.sendText(line, heartbeatTurn ? { outboundPeer: heartbeatTurn.outboundPeer } : {})
1151
+ }, this.config.digestIntervalSec * 1000)
1152
+ if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
1153
+ }
1154
+ const onEvent = async (session, event) => {
1155
+ // 仅为活动会话创建 digest 状态:非活动会话即使有心跳残留,也会被心跳回调里的
1156
+ // 失活检查在下一个周期自行停止。若不过滤,任意会话事件都会在此堆积 Map entry(内存泄漏)
1157
+ if (session.id !== this.activeSessionId) return
1158
+ const state = this._digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
1159
+ this._digestState.set(session.id, state)
1160
+
1161
+ if (event.type === 'turn/end') {
1162
+ stopHeartbeat(state)
1163
+ // 本轮已结束,清空轮次集合防止长期运行下无限累积
1164
+ state.startedTurns.clear()
1165
+ }
1166
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
1167
+
1168
+ // 出站事件绑定到发起本轮的会话 peer(T2.3)
1169
+ const turn = this._turnPeers.get(session.id)
1170
+ const sendOpts = turn ? { outboundPeer: turn.outboundPeer } : {}
1171
+
1172
+ if (event.type === 'turn/start') {
1173
+ const turn = event.data?.turn
1174
+ state.createdFiles = new Set()
1175
+ if (turn != null && !state.startedTurns.has(turn)) {
1176
+ state.startedTurns.add(turn)
1177
+ // 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
1178
+ if (this.peerId) this.sendTyping(1).catch(() => {})
1179
+ }
1180
+ startHeartbeat(session, state)
1181
+ return
1182
+ }
1183
+ // DSH 会话对象的工作目录在 header 内(SessionHeader.cwd),顶层没有 cwd;
1184
+ // 此前只读 sess.cwd → 白名单根回退到 config.cwd/process.cwd(),导致会话工作区内的附件
1185
+ // 被判为"工作区外"而静默丢弃(issue #72 补充报告)。保留 sess.cwd 兜底以兼容 mock/旧宿主。
1186
+ const getSessionCwd = (sess) => sess?.header?.cwd || sess?.cwd || this.config.cwd || process.cwd()
1187
+
1188
+ if (event.type === 'tool/call') {
1189
+ // 状态行:把"agent 正在干什么"实时反映到 IM。
1190
+ // Web 端会显示「正在运行命令 / 正在编辑文件」这类提示,而 IM 此前只有 5 分钟一次
1191
+ // 的心跳,长任务期间体感像卡住。这里复用宿主同口径的工具→活动映射
1192
+ // (见 lib/platform/activity.js),并做节流,避免每条工具调用都刷屏。
1193
+ // 文件直发仍由 AI 回复中的 [SEND_FILE: ...] 指令显式驱动,杜绝误判。
1194
+ const info = event.data ?? {}
1195
+ this._maybePublishActivity(
1196
+ state,
1197
+ { turn: info.turn, toolName: info.name, args: info.arguments },
1198
+ sendOpts,
1199
+ )
1200
+ return
1201
+ }
1202
+ if (event.type === 'assistant/message') {
1203
+ const rawText = textOfAssistantMessage(event.data.message)
1204
+ if (rawText.trim()) {
1205
+ const cwd = getSessionCwd(session)
1206
+ const { cleanText, files } = extractAndStripSendFileDirectives(rawText, cwd)
1207
+ for (const f of files) {
1208
+ state.createdFiles.add(f)
1209
+ }
1210
+ // 仅向聊天窗口发送过滤掉 [SEND_FILE: ...] 控制指令后的纯净正文
1211
+ if (cleanText) {
1212
+ void this.sendText(cleanText, sendOpts)
1213
+ }
1214
+ }
1215
+ return
1216
+ }
1217
+ if (event.type === 'workspace/changes') {
1218
+ this.logger?.info?.('[dsh-bridge %s] 收到 workspace/changes 事件 session=%s seq=%s', this.platform.id, session?.id ?? '(none)', event?.seq ?? '(none)')
1219
+ // 宿主在"完成的顶层轮次"后追加该事件:摘要留在宿主,用 (sessionId, seq) 取。
1220
+ //
1221
+ // standalone: 摘要必须在**流式卡片之外**独立成条。宿主的事件顺序是
1222
+ // workspace/changes(seq 2945) → turn/end(2946),摘要发送时飞书节点的 _inTurn
1223
+ // 仍为 true;不声明就会被并入正在流式输出的卡片,随即被 turn/end 的收尾覆盖,
1224
+ // 用户侧表现为"改动摘要一条都收不到"(2026-10-16 真机实测即如此)。
1225
+ const peerOpts = { ...(turn ? sendOpts : (this._lastTurnPeers.get(session.id) ?? {})), standalone: true }
1226
+ void this._announceChanges(session, event, peerOpts)
1227
+ return
1228
+ }
1229
+ if (event.type === 'turn/end') {
1230
+ stopHeartbeat(state)
1231
+ // 供 turn/end 之后到达的 workspace/changes 事件复用本轮出站绑定
1232
+ this._rememberBounded(this._lastTurnPeers, session.id, sendOpts)
1233
+ if (this.peerId) this.sendTyping(2).catch(() => {})
1234
+ const reason = event.data?.reason || {}
1235
+ if (reason.kind === 'error') {
1236
+ void this.sendText(`❌ **处理出错**:${summarizeError(reason.error)}`, sendOpts)
1237
+ } else if (reason.kind === 'aborted') {
1238
+ void this.sendText(`⏹ **任务已停止**`, sendOpts)
1239
+ } else if (reason.kind === 'max-tokens') {
1240
+ void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`, sendOpts)
1241
+ }
1242
+
1243
+ // 如果本轮 AI 显式指定了 [SEND_FILE: ...] 文件发送指令,直接上传并发送给用户
1244
+ if (state.createdFiles && state.createdFiles.size > 0) {
1245
+ const rawFiles = Array.from(state.createdFiles)
1246
+ const cwd = getSessionCwd(session)
1247
+ const targetPeer = sendOpts.outboundPeer?.peerId ?? this.peerId ?? this.config.allowFrom?.[0]
1248
+
1249
+ if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
1250
+ const uniqueFilesToSend = []
1251
+ for (const f of rawFiles) {
1252
+ const resolved = resolveFilePath(f, cwd)
1253
+ if (!resolved || uniqueFilesToSend.includes(resolved)) continue
1254
+ // 发送白名单:仅允许会话 cwd(及其子目录)内、且不命中敏感路径的文件,
1255
+ // 防止模型被提示注入后借 [SEND_FILE] 外发 .ssh/.credentials/.env 等任意本地文件。
1256
+ if (!isPathAllowedForSend(resolved, cwd)) {
1257
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] blocked SEND_FILE outside allowed workspace: ${resolved} (allowed root: ${cwd})`)
1258
+ continue
1259
+ }
1260
+ uniqueFilesToSend.push(resolved)
1261
+ }
1262
+ for (const resolved of uniqueFilesToSend) {
1263
+ try {
1264
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile to peer: ${resolved}`)
1265
+ const res = await this.platform.sendMediaFile(targetPeer, resolved, sendOpts)
1266
+ if (res && res.success === false) {
1267
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, res.error)
1268
+ }
1269
+ } catch (err) {
1270
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, err?.message ?? err)
1271
+ }
1272
+ }
1273
+ }
1274
+ state.createdFiles.clear()
1275
+ }
1276
+ // 本轮结束,释放轮次绑定(审批/心跳等已不再需要)
1277
+ this._turnPeers.delete(session.id)
1278
+ return
1279
+ }
1280
+ }
1281
+ const listener = (session, event) => { void onEvent(session, event) }
1282
+ const disposer = this.ctx.on('session/event', listener)
1283
+ this.disposers.push(() => {
1284
+ for (const state of this._digestState.values()) stopHeartbeat(state)
1285
+ disposer()
1286
+ })
1287
+ }
1288
+
1289
+ // ---- 审批桥 ----
1290
+ //
1291
+ // DSH 的审批分发是 cordis waterfall(顺序链):监听器按注册序执行,
1292
+ // 不调用 next() 的监听器否决整条链。宿主 apiproxy 注册在先的 GUI 认领监听器
1293
+ // 会认领 approval/asked 事件并 veto 等待网页回答——若不 prepend,本桥永远没有
1294
+ // 机会执行,IM 端永远收不到审批卡片(工具调用最终以 unavailable 失败)。
1295
+ // 因此以 { prepend: true } 注册到链条最外层。
1296
+ //
1297
+ // 归属模型:IM 发起的轮次审批**只在 IM 决议**——不调用 next(),宿主 GUI 通道
1298
+ // 根本不打开。此前曾让 GUI 弹窗与 IM 卡片并行 race,但宿主的 pending 认领没有
1299
+ // 插件可用的收尾接口(只有 Web /api/respond 或 signal abort),IM 决议后 Web
1300
+ // 弹窗会永久残留(用户实测报告)。各通道只决议自己发起的轮次。
1301
+
1302
+ _attachApprovalBridge() {
1303
+ const listener = async (req, next) => {
1304
+ // 只有"本轮由本桥发起"(_turnPeers 有该会话的轮次记录)时才拦截审批。
1305
+ // 仅凭 activeSessionId 匹配是不够的:重启恢复/默认挑选后它可能指向一个
1306
+ // Web 端发起的会话——那会让 Web 轮次的审批被劫持发去 IM(GUI 不弹窗、
1307
+ // 无人响应即自动拒绝)。Web 轮次必须直接放行给宿主 GUI 处理。
1308
+ const sessionId = req.agent?.session?.id
1309
+ const turn = sessionId ? this._turnPeers.get(sessionId) : null
1310
+ if (!turn || !this.ownsAgent(req.agent)) {
1311
+ // info 级别:这是用户可自诊的关键判定点(IM 没收到卡片时先看这行)
1312
+ this.logger?.info?.('[dsh-bridge %s] approval falls through to GUI: not an IM-initiated turn (session=%s, activeSession=%s, turnTracked=%s)', this.platform?.id, sessionId ?? '(none)', this.activeSessionId ?? '(none)', Boolean(turn))
1313
+ return next?.()
1314
+ }
1315
+ const peer = turn.outboundPeer?.peerId
1316
+ const initiator = turn.senderId
1317
+ if (!peer) {
1318
+ this.logger?.debug?.('[dsh-bridge %s] approval/request ignored: no active peer', this.platform?.id)
1319
+ return next?.()
1320
+ }
1321
+ const sendOpts = turn ? { outboundPeer: turn.outboundPeer } : {}
1322
+
1323
+ const number = this.nextApprovalNumber()
1324
+ const timeoutSec = this.config.approvalTimeoutSec
1325
+ const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
1326
+ const prompt = [
1327
+ `## ⚠️ 操作权限确认 (#${number})`,
1328
+ '',
1329
+ '| 项目 | 详情 |',
1330
+ '| :--- | :--- |',
1331
+ `| **调用工具** | \`${req.toolName}\` |`,
1332
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
1333
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
1334
+ '',
1335
+ `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
1336
+ `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
1337
+ ].join('\n')
1338
+
1339
+ // 平台若提供审批卡片(QQ 的按钮式审批),优先用卡片;失败自动回退纯文字,
1340
+ // 保证"卡片发不出去"时用户仍能用 /yes /no 决议。
1341
+ let cardSent = false
1342
+ if (typeof this.sendApprovalCard === 'function') {
1343
+ try {
1344
+ cardSent = await this.sendApprovalCard(turn.outboundPeer ?? { peerId: peer }, { number, request: req }, prompt)
1345
+ } catch (err) {
1346
+ this.logger?.warn?.('[dsh-bridge %s] approval card failed (%s), fallback to text', this.platform?.id, err?.message ?? err)
1347
+ cardSent = false
1348
+ }
1349
+ }
1350
+ if (!cardSent) void this.sendText(prompt, sendOpts)
1351
+
1352
+ let settled = false
1353
+ let timeoutFired = false
1354
+ let resolveIm
1355
+ const imPromise = new Promise((resolve) => { resolveIm = resolve })
1356
+ const settleIm = (outcome) => {
1357
+ if (settled) return
1358
+ settled = true
1359
+ this.clearApproval(number)
1360
+ resolveIm(outcome)
1361
+ }
1362
+
1363
+ const timer = setTimeout(() => {
1364
+ timeoutFired = true
1365
+ settleIm('rejected')
1366
+ }, timeoutSec * 1000)
1367
+ if (typeof timer.unref === 'function') timer.unref()
1368
+
1369
+ // turn 被停止/工具调用中止时 DSH 会 abort req.signal:同步取消 IM 侧待决审批
1370
+ const onSignalAbort = () => {
1371
+ timeoutFired = true
1372
+ settleIm('cancelled')
1373
+ }
1374
+ req.signal?.addEventListener('abort', onSignalAbort, { once: true })
1375
+
1376
+ this.registerApproval(number, { number, request: req, resolve: resolveIm, timer, peerId: initiator })
1377
+
1378
+ let outcome
1379
+ try {
1380
+ outcome = await imPromise
1381
+ } finally {
1382
+ req.signal?.removeEventListener('abort', onSignalAbort)
1383
+ clearTimeout(timer)
1384
+ settled = true
1385
+ }
1386
+
1387
+ this.logger?.info?.('[dsh-bridge %s] approval #%d resolved: outcome=%s', this.platform?.id, number, outcome)
1388
+
1389
+ // 仅在非超时/非中止路径发送确认消息(那些路径 resolve 已发生在定时器/abort 回调)
1390
+ if (!timeoutFired) {
1391
+ const label = outcome === 'allowed-once' ? `✓ **已批准执行**` : outcome === 'rejected' ? `❌ **已拒绝执行**` : `**[${outcome}]**`
1392
+ void this.sendText(`${label}(#${number})`, sendOpts)
1393
+ }
1394
+ return outcome
1395
+ }
1396
+ const disposer = this.ctx.on('approval/request', listener, { prepend: true })
1397
+ this.disposers.push(disposer)
1398
+ }
1399
+
1400
+ // ---- Agent 决策问答桥(ask_user_question / 计划审批) ----
1401
+ //
1402
+ // 宿主把"Agent 需要用户做个决定"统一收敛到 `user-questions/request` **waterfall**:
1403
+ // 应答者接管后渲染问题、等用户作答、返回答案批次;无人接管则 reject NO_PROVIDER
1404
+ // (IM 用户完全看不到提问)。此前本插件没有注册该钩子,即属于后者。
1405
+ //
1406
+ // 归属模型与审批桥**完全一致**(这是本仓已验证过的边界):
1407
+ // - 只接管"本轮由本 IM 发起"的提问(_turnPeers 有该会话记录 + ownsAgent);
1408
+ // 否则调用 next() 让 Web GUI 处理,绝不抢 Web 端的问题;
1409
+ // - 接管后不调用 next():IM 发起的问题只在 IM 作答,避免两边同时弹窗。
1410
+ //
1411
+ // 与审批的关键差异:问答**可能带超时**(askTimed 会在到点后让 Agent 先继续,
1412
+ // 但问题仍可迟答)。故这里不能像审批那样"超时即拒绝",而是:
1413
+ // - 到点后停止等待、把结果交回宿主(由宿主决定 pending 语义),同时**保留**
1414
+ // 待答条目,让用户稍后仍能用 `/ask` 补答(走 delayed reply 通道)。
1415
+ _attachAskUserBridge() {
1416
+ const listener = async (req, next) => {
1417
+ const sessionId = req?.agent?.session?.id
1418
+ const turn = sessionId ? this._turnPeers.get(sessionId) : null
1419
+ if (!turn || !this.ownsAgent(req.agent)) {
1420
+ this.logger?.info?.('[dsh-bridge %s] user-questions falls through to GUI: not an IM-initiated turn (session=%s)', this.platform?.id, sessionId ?? '(none)')
1421
+ return next?.()
1422
+ }
1423
+ const peer = turn.outboundPeer
1424
+ if (!peer?.peerId) return next?.()
1425
+ if (peer.comment) {
1426
+ // 评论会话没有可点的卡片/输入框,交回宿主(用户可从 Web 面板处理)
1427
+ this.logger?.info?.('[dsh-bridge %s] 评论发起的提问:交回宿主 GUI (session=%s)', this.platform?.id, sessionId ?? '(none)')
1428
+ return next?.()
1429
+ }
1430
+ const questions = Array.isArray(req.questions) ? req.questions : []
1431
+ if (questions.length === 0) return next?.()
1432
+
1433
+ const sendOpts = { outboundPeer: peer }
1434
+ const number = this.nextAskNumber()
1435
+
1436
+ // 渲染:优先平台按钮(单问单选且选项不多),否则文字回退
1437
+ const single = questions.length === 1 ? questions[0] : null
1438
+ const useButtons = Boolean(single) && canRenderAsButtons(single) && typeof this.sendAskCard === 'function'
1439
+ const promptText = formatAskPrompt(questions, { number, header: useButtons ? '请直接在卡片上选择' : '' })
1440
+
1441
+ let settled = false
1442
+ let resolveIm
1443
+ const imPromise = new Promise((resolve) => { resolveIm = resolve })
1444
+ const settleIm = (payload) => {
1445
+ if (settled) return
1446
+ settled = true
1447
+ resolveIm(payload)
1448
+ }
1449
+
1450
+ // 超时/中止都由**宿主**驱动:askTimed 到点会 abort request.signal,
1451
+ // 我们只需在 abort 时把等待交还宿主(抛错),由宿主把 ASK_TIMED_OUT 翻成
1452
+ // { pending: true }。此处刻意**不**自建定时器——宿主才是超时的唯一权威
1453
+ // (超时值由工具调用参数决定,桥这边看不到,自建只会与之打架)。
1454
+ const onSignalAbort = () => settleIm({ aborted: true })
1455
+ req.signal?.addEventListener('abort', onSignalAbort, { once: true })
1456
+
1457
+ const entry = {
1458
+ number,
1459
+ questions,
1460
+ // 统一取 answer 批次({ answers: [...] });平台按钮回调与文字回复都走这里
1461
+ resolve: (payload) => settleIm(payload),
1462
+ detached: false,
1463
+ peerId: turn.senderId,
1464
+ // 群聊允许群内成员作答(与审批同一模型);单聊限定发起者本人
1465
+ isGroup: peer.isGroup === true,
1466
+ sessionId,
1467
+ agent: req.agent,
1468
+ callId: req.wait?.callId,
1469
+ timed: req.wait?.timed === true,
1470
+ useButtons,
1471
+ sendOpts,
1472
+ promptText,
1473
+ platform: this,
1474
+ }
1475
+ // 已经是"超时/中止后交还宿主"状态的条目:resolve 通道已死,作答要走宿主
1476
+ // 的迟到回复 API(userQuestions.answer)。见 resolveAsk 的两条路径。
1477
+ this.registerAsk(number, entry)
1478
+
1479
+ // 发问(先发卡片;卡片失败自动回退文字)
1480
+ if (useButtons) {
1481
+ try {
1482
+ const sent = await this.sendAskCard(peer, single, entry, promptText)
1483
+ if (!sent) await this.sendText(promptText, sendOpts)
1484
+ } catch (err) {
1485
+ this.logger?.warn?.('[dsh-bridge %s] ask card failed (%s), fallback to text', this.platform?.id, err?.message ?? err)
1486
+ await this.sendText(promptText, sendOpts).catch(() => {})
1487
+ }
1488
+ } else {
1489
+ await this.sendText(promptText, sendOpts).catch(() => {})
1490
+ }
1491
+
1492
+ let payload
1493
+ try {
1494
+ payload = await imPromise
1495
+ } finally {
1496
+ req.signal?.removeEventListener('abort', onSignalAbort)
1497
+ }
1498
+
1499
+ if (payload?.aborted) {
1500
+ // 交还宿主:宿主据 signal.aborted 判定是"超时(pending)"还是"被中止"。
1501
+ // 条目**保留**:若是超时,问题在宿主侧变为 continued,用户稍后仍可 /ask 补答
1502
+ // (resolveAsk 会走 userQuestions.answer 的迟到回复通道)。
1503
+ entry.detached = true
1504
+ this.logger?.info?.('[dsh-bridge %s] ask #%d 等待结束(超时或中止),交还宿主处理;用户仍可 /ask 补答', this.platform?.id, number)
1505
+ throw new Error('ask_user_question wait ended (timed out or aborted)')
1506
+ }
1507
+
1508
+ // 已作答
1509
+ this.clearAsk(number)
1510
+ const check = validateAnswerBatch(payload.answers, questions)
1511
+ if (!check.ok) {
1512
+ this.logger?.warn?.('[dsh-bridge %s] ask #%d 答案非法:%s', this.platform?.id, number, check.error)
1513
+ throw new Error(check.error)
1514
+ }
1515
+ this.logger?.info?.('[dsh-bridge %s] ask #%d resolved: %d answers', this.platform?.id, number, payload.answers.length)
1516
+ void this.sendText(formatAnswerAck(payload.answers, questions, { number }), sendOpts)
1517
+ return payload
1518
+ }
1519
+
1520
+ // prepend:与审批同理——宿主 apiproxy 的 GUI 认领监听器注册在先,
1521
+ // 不抢到链首就没有机会接管(Web 端永远先响应)。
1522
+ const disposer = this.ctx.on('user-questions/request', listener, { prepend: true })
1523
+ this.disposers.push(disposer)
1524
+ }
1525
+ }
1526
+
1527
+ // ---------------------------------------------------------------------------
1528
+ // 辅助
1529
+ // ---------------------------------------------------------------------------
1530
+
1531
+ // 导出,便于测试与复用
1532
+ export const conversationBridgeHelpers = {
1533
+ splitForIM,
1534
+ digestLine,
1535
+ textOfAssistantMessage,
1536
+ resolveFilePath,
1537
+ extractFilePathsFromText,
1538
+ extractAndStripSendFileDirectives,
1539
+ sessionsInDisplayOrder,
1540
+ listSessions,
1541
+ renderSessions,
1542
+ listWorkspaces,
1543
+ sessionHeaderOf,
1544
+ }