dsh-bridge-gateway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +408 -0
  4. package/README.md +430 -0
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +5209 -0
  7. package/client/index.js +4967 -0
  8. package/cordis.patch.yml +4 -0
  9. package/docs/banner.jpg +0 -0
  10. package/docs/custom-tunnel.md +220 -0
  11. package/docs/feishu-usage.md +116 -0
  12. package/docs/fix-tunnel-sse-and-session-list.md +134 -0
  13. package/docs/platform-abstraction-design.md +473 -0
  14. package/docs/qq-usage.md +387 -0
  15. package/docs/screenshots/admin-lock-screen.jpg +0 -0
  16. package/docs/screenshots/feishu-bot-config.jpg +0 -0
  17. package/docs/screenshots/feishu-chat.jpg +0 -0
  18. package/docs/screenshots/lan-access.jpg +0 -0
  19. package/docs/screenshots/mobile-chat.jpg +0 -0
  20. package/docs/screenshots/mobile-drawer.jpg +0 -0
  21. package/docs/screenshots/mobile-remote-settings.jpg +0 -0
  22. package/docs/screenshots/mobile-settings-im.jpg +0 -0
  23. package/docs/screenshots/mobile-settings-lan.jpg +0 -0
  24. package/docs/screenshots/mobile-settings-security.jpg +0 -0
  25. package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
  26. package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
  27. package/docs/screenshots/qq-bot-config.jpg +0 -0
  28. package/docs/screenshots/qq-chat.jpg +0 -0
  29. package/docs/screenshots/qq-group.jpg +0 -0
  30. package/docs/screenshots/qr-scan.jpg +0 -0
  31. package/docs/screenshots/remote-auth-login.jpg +0 -0
  32. package/docs/screenshots/remote-web-mobile.jpg +0 -0
  33. package/docs/screenshots/security-auth-config.jpg +0 -0
  34. package/docs/screenshots/telegram-bot-config.jpg +0 -0
  35. package/docs/screenshots/tunnel-access.jpg +0 -0
  36. package/docs/screenshots/wechat-bot-config.jpg +0 -0
  37. package/docs/screenshots/wechat-chat.jpg +0 -0
  38. package/docs/telegram-usage.md +93 -0
  39. package/docs/wechat-usage.md +104 -0
  40. package/lib/auth/login-template.js +382 -0
  41. package/lib/auth/manager.js +470 -0
  42. package/lib/bridge-rpc-constants.js +55 -0
  43. package/lib/bridge-rpc.js +553 -0
  44. package/lib/cloudflared-manager.mjs +345 -0
  45. package/lib/feishu/gateway.js +550 -0
  46. package/lib/feishu/index.js +222 -0
  47. package/lib/feishu/lark-bundled.mjs +125547 -0
  48. package/lib/feishu/node.js +409 -0
  49. package/lib/gateway/cert.js +214 -0
  50. package/lib/index.js +1963 -0
  51. package/lib/platform/base.js +156 -0
  52. package/lib/platform/conversation-bridge.js +1570 -0
  53. package/lib/platform/index.js +10 -0
  54. package/lib/platform/manager.js +74 -0
  55. package/lib/qq/gateway.js +616 -0
  56. package/lib/qq/index.js +309 -0
  57. package/lib/qq/node.js +533 -0
  58. package/lib/security/path-validator.js +208 -0
  59. package/lib/security/rate-limiter.js +93 -0
  60. package/lib/telegram/gateway.js +630 -0
  61. package/lib/telegram/index.js +213 -0
  62. package/lib/telegram/node.js +351 -0
  63. package/lib/tunnel-client.mjs +402 -0
  64. package/lib/wechat/gateway.js +960 -0
  65. package/lib/wechat/index.js +241 -0
  66. package/lib/wechat/media.js +281 -0
  67. package/lib/wechat/node.js +350 -0
  68. package/package.json +102 -0
@@ -0,0 +1,1570 @@
1
+ // dsh-bridge 平台无关的会话桥
2
+ //
3
+ // 承担所有 IM 平台共享的核心逻辑:
4
+ // - 白名单(allowFrom)与首条自动授权
5
+ // - DSH 会话生命周期(创建/切换/停止/恢复 re-attach)
6
+ // - 审批问答(approval request/response,超时自动拒绝)
7
+ // - 出站 digest 摘要 + 心跳进度
8
+ // - 命令路由(/sessions /use /new /workspaces /stop /status /help …)
9
+ // - 会话列表 / 工作区渲染(DSH 官方 API)
10
+ //
11
+ // 平台相关的部分由子类(或组合)提供:
12
+ // - sendText(text) / sendTyping(state):向当前 peer 发送
13
+ // - extractTextFrom(message):从平台消息提取文本
14
+ // - isGroupMessage(message):判断群消息
15
+ // - handlePlatformInbound(message):消息解析(返回 { senderId, text, isGroup } 或 null)
16
+ //
17
+ // 本类不直接依赖任何 IM 协议,只消费 DSH 官方服务:
18
+ // ctx.sessions / ctx.agents / ctx.approval / ctx.sessionPersistence / ctx.workspaceRegistry
19
+
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
+ import { randomUUID } from 'node:crypto'
22
+ import { statSync, existsSync, readFileSync } from 'node:fs'
23
+ import { stat } from 'node:fs/promises'
24
+ import { join, resolve, normalize, basename, extname, isAbsolute } from 'node:path'
25
+ import { homedir } from 'node:os'
26
+ import { isSafeWorkspacePath } from '../security/path-validator.js'
27
+
28
+ // 纯文本标记(用户偏好不用 emoji)
29
+ export const BRIDGE_MARK = {
30
+ ok: '[OK]',
31
+ err: '[错误]',
32
+ stop: '[已停止]',
33
+ idle: '[空闲]',
34
+ turn: '[新会话]',
35
+ ask: '[待确认]',
36
+ welcome: '[IM Bot]',
37
+ list: '[会话列表]',
38
+ status: '[状态]',
39
+ warn: '[注意]',
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // 出站分块(通用:按平台 maxMessageChars 硬分块 + 保留 fenced code block)
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const FENCE_RE = /^```([^\n`]*)\s*$/
47
+
48
+ function normalizeMarkdownBlocks(content) {
49
+ const lines = content.split('\n')
50
+ const out = []
51
+ let blankRun = 0
52
+ let inCode = false
53
+ for (const raw of lines) {
54
+ const line = raw.replace(/\s+$/, '')
55
+ if (FENCE_RE.test(line.trim())) {
56
+ inCode = !inCode
57
+ out.push(line)
58
+ blankRun = 0
59
+ continue
60
+ }
61
+ if (inCode) {
62
+ out.push(line)
63
+ continue
64
+ }
65
+ if (!line.trim()) {
66
+ blankRun += 1
67
+ if (blankRun <= 1) out.push('')
68
+ continue
69
+ }
70
+ blankRun = 0
71
+ out.push(line)
72
+ }
73
+ return out.join('\n').trim()
74
+ }
75
+
76
+ function splitMarkdownBlocks(content) {
77
+ const blocks = []
78
+ let current = []
79
+ let inCode = false
80
+ const flush = () => {
81
+ const block = current.join('\n').trim()
82
+ if (block) blocks.push(block)
83
+ current = []
84
+ }
85
+ for (const raw of content.split('\n')) {
86
+ const line = raw.replace(/\s+$/, '')
87
+ if (FENCE_RE.test(line.trim())) {
88
+ if (!inCode && current.length) flush()
89
+ current.push(line)
90
+ inCode = !inCode
91
+ if (!inCode) flush()
92
+ continue
93
+ }
94
+ if (inCode) {
95
+ current.push(line)
96
+ continue
97
+ }
98
+ if (!line.trim()) {
99
+ flush()
100
+ continue
101
+ }
102
+ current.push(line)
103
+ }
104
+ flush()
105
+ return blocks
106
+ }
107
+
108
+ function hardSplit(text, max) {
109
+ const chunks = []
110
+ let rest = text
111
+ while (rest.length > max) {
112
+ chunks.push(rest.slice(0, max))
113
+ rest = rest.slice(max)
114
+ }
115
+ if (rest) chunks.push(rest)
116
+ return chunks
117
+ }
118
+
119
+ function packBlocks(blocks, max) {
120
+ const units = []
121
+ let current = ''
122
+ for (const block of blocks) {
123
+ const candidate = current ? `${current}\n\n${block}` : block
124
+ if (candidate.length <= max) {
125
+ current = candidate
126
+ continue
127
+ }
128
+ if (current) units.push(current)
129
+ if (block.length <= max) {
130
+ current = block
131
+ } else {
132
+ units.push(...hardSplit(block, max))
133
+ current = ''
134
+ }
135
+ }
136
+ if (current) units.push(current)
137
+ return units
138
+ }
139
+
140
+ function splitForIM(content, max = 2000) {
141
+ // 安全检查:防止畸形输入导致无限循环或崩溃
142
+ if (typeof content !== 'string' || content.length === 0) return []
143
+ if (content.length > 1_000_000) {
144
+ content = content.slice(0, 1_000_000) + '\n\n[已截断:内容过长]'
145
+ }
146
+ const normalized = normalizeMarkdownBlocks(content)
147
+ if (!normalized) return []
148
+ if (normalized.length <= max) return [normalized]
149
+ return packBlocks(splitMarkdownBlocks(normalized), max)
150
+ }
151
+
152
+ export function textOfAssistantMessage(message) {
153
+ return (message.content ?? [])
154
+ .filter((block) => block?.type === 'text')
155
+ .map((block) => block.text)
156
+ .join('\n')
157
+ }
158
+
159
+ /**
160
+ * 尝试将任意路径(绝对或相对当前工作区)解析为真实存在的本地文件绝对路径
161
+ */
162
+ export function resolveFilePath(rawPath, cwd = process.cwd()) {
163
+ if (typeof rawPath !== 'string') return null
164
+ let p = rawPath.trim()
165
+ .replace(/^["'`]|["'`]$/g, '')
166
+ .replace(/^file:\/\/\/?/, '')
167
+ .replace(/^[📁📄📦\s]+/, '')
168
+ if (!p) return null
169
+ // 排除 HTTP/HTTPS 网址
170
+ if (/^https?:\/\//i.test(p)) return null
171
+ const resolved = isAbsolute(p) ? normalize(p) : resolve(cwd, p)
172
+ try {
173
+ if (statSync(resolved).isFile()) {
174
+ return resolved
175
+ }
176
+ } catch {}
177
+ return null
178
+ }
179
+
180
+ /**
181
+ * 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
182
+ * 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
183
+ * @param {string} text - 原始助手回复文本
184
+ * @param {string} cwd - 会话当前工作目录
185
+ * @returns {{ cleanText: string, files: string[] }}
186
+ */
187
+ export function extractAndStripSendFileDirectives(text, cwd = process.cwd()) {
188
+ if (typeof text !== 'string' || !text.trim()) {
189
+ return { cleanText: text || '', files: [] }
190
+ }
191
+
192
+ const files = []
193
+ const directiveRegex = /\[(?:SEND_FILE|SEND-FILE|send_file|send-file|SEND_MEDIA|send_media):\s*[`"']?([^\]`"'\r\n]+?)[`"']?\s*\]/gi
194
+
195
+ let m
196
+ const re = new RegExp(directiveRegex)
197
+ while ((m = re.exec(text)) !== null) {
198
+ const rawPath = m[1].trim()
199
+ const resolved = resolveFilePath(rawPath, cwd)
200
+ if (resolved && !files.includes(resolved)) {
201
+ files.push(resolved)
202
+ }
203
+ }
204
+
205
+ // 从聊天正文中彻底剔除控制指令(保持 IM 聊天气泡的干净整洁)
206
+ const cleanText = text.replace(directiveRegex, '').replace(/\n{3,}/g, '\n\n').trim()
207
+
208
+ return { cleanText, files }
209
+ }
210
+
211
+ /**
212
+ * 提取文本中的产物文件路径(基于显式指令)
213
+ */
214
+ export function extractFilePathsFromText(text, cwd = process.cwd()) {
215
+ return extractAndStripSendFileDirectives(text, cwd).files
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // digest 摘要
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function digestLine(session) {
223
+ let turn = 0
224
+ let tools = 0
225
+ let lastTool = undefined
226
+ let inTurn = false
227
+ for (const event of session.events ?? []) {
228
+ if (event.type === 'turn/start') {
229
+ turn = event.data.turn
230
+ inTurn = true
231
+ tools = 0
232
+ lastTool = undefined
233
+ } else if (event.type === 'turn/end') {
234
+ inTurn = false
235
+ } else if (event.type === 'tool/call' && inTurn) {
236
+ tools += 1
237
+ lastTool = event.data.name
238
+ }
239
+ }
240
+ if (!inTurn || turn === 0) return null
241
+ const steps = tools > 0 ? `${tools} 次工具调用` : '思考中'
242
+ const last = lastTool ? ` | 最近: ${lastTool}` : ''
243
+ return `[处理中] 第 ${turn} 轮 | ${steps}${last}`
244
+ }
245
+
246
+ function summarizeError(error) {
247
+ if (error && typeof error === 'object' && 'message' in error) {
248
+ return String(error.message).slice(0, 200)
249
+ }
250
+ return String(error).slice(0, 200)
251
+ }
252
+
253
+ function sleep(ms) {
254
+ return new Promise((resolve) => setTimeout(resolve, ms))
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // 会话桥基类
259
+ // ---------------------------------------------------------------------------
260
+
261
+ export class ConversationBridge {
262
+ /**
263
+ * @param {object} opts
264
+ * @param {object} opts.ctx Cordis 上下文
265
+ * @param {object} opts.logger 日志器
266
+ * @param {object} [opts.config] 已持久化配置(allowFrom/间隔/活动会话等)
267
+ * @param {object} opts.platform 所属 Platform 实例(提供 accountId/capabilities)—— 必需
268
+ * @param {(senderId: string) => void} [opts.onFirstSender]
269
+ * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
270
+ */
271
+ constructor({ ctx, logger, config = {}, platform, onFirstSender, onActiveSessionChange } = {}) {
272
+ if (!platform) {
273
+ throw new Error('ConversationBridge requires a platform instance')
274
+ }
275
+
276
+ this.ctx = ctx
277
+ this.logger = logger
278
+ this.platform = platform
279
+ this.onFirstSender = onFirstSender
280
+ this.onActiveSessionChange = onActiveSessionChange
281
+
282
+ const maxChars = platform.capabilities?.maxMessageChars ?? 2000
283
+ const rawMax = Number(config.maxMessageChars)
284
+ const safeMaxChars = (Number.isFinite(rawMax) && rawMax > 0) ? rawMax : maxChars
285
+ this.config = {
286
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
287
+ digestIntervalSec: config.digestIntervalSec ?? 300,
288
+ approvalTimeoutSec: config.approvalTimeoutSec ?? 600,
289
+ maxMessageChars: safeMaxChars,
290
+ sendChunkDelayMs: config.sendChunkDelayMs ?? 1500,
291
+ cwd: config.cwd,
292
+ agentPreset: config.agentPreset,
293
+ agentProvider: config.agentProvider,
294
+ agentModel: config.agentModel,
295
+ }
296
+
297
+ // 从配置恢复活动会话(v0.2.1:重启后保持会话)
298
+ // 注意:不在构造函数里调用 _pickDefaultSession(),由 loadConfig 回调负责恢复,避免竞态覆盖
299
+ this.activeSessionId = config.activeSessionId ?? null
300
+ this.peerId = null
301
+ this.pending = new Map() // number -> PendingApproval
302
+ this.approvalCounter = 0
303
+ this.disposers = []
304
+
305
+ // 配置恢复状态追踪(用于防止 handleInbound 在配置加载前处理消息)
306
+ this._configRestored = false
307
+ this._restoringConfig = null
308
+ this._restoringSessionMap = new Map() // sessionId -> Promise<Agent|null>
309
+
310
+ this.mark = BRIDGE_MARK
311
+ this._attachOutbound()
312
+ this._attachApprovalBridge()
313
+ }
314
+
315
+ get gatewayAccountId() {
316
+ return this.platform?.accountId ?? ''
317
+ }
318
+
319
+ activeSession() {
320
+ if (!this.activeSessionId) return undefined
321
+ return this.ctx.sessions?.get(this.activeSessionId)
322
+ }
323
+
324
+ activeAgent() {
325
+ if (!this.activeSessionId) return undefined
326
+ return this.ctx.agents?.get(this.activeSessionId)
327
+ }
328
+
329
+ ownsAgent(agent) {
330
+ return this.activeSessionId !== null && agent?.session?.id === this.activeSessionId
331
+ }
332
+
333
+ isAllowed(senderId) {
334
+ if (!Array.isArray(this.config.allowFrom)) return false
335
+ if (this.config.allowFrom.length === 0) return false
336
+ return this.config.allowFrom.includes(senderId)
337
+ }
338
+
339
+ setActiveSession(session) {
340
+ this.stopAllHeartbeats()
341
+ this.activeSessionId = session.id
342
+ try { this.onActiveSessionChange?.(session.id) } catch { /* 持久化失败不致命 */ }
343
+ }
344
+
345
+ // 仅按 ID 设置活动会话(持久化会话可能没有内存 session 对象),
346
+ // 发消息时通过 re-attach 逻辑拉起 agent。
347
+ setActiveSessionById(id) {
348
+ if (!id) return
349
+ this.stopAllHeartbeats()
350
+ this.activeSessionId = id
351
+ try { this.onActiveSessionChange?.(id) } catch { /* 持久化失败不致命 */ }
352
+ }
353
+
354
+ async _pickDefaultSession() {
355
+ const sessions = await listSessions(this)
356
+ if (sessions.length > 0) this.setActiveSessionById(sessions[0].id)
357
+ }
358
+
359
+ async createSession(prompt, cwdOverride) {
360
+ // 使用 DSH 原生格式 session-${uuid},与 ctx.sessions 持久化系统兼容
361
+ const sessionId = `session-${randomUUID()}`
362
+ try {
363
+ const workspaces = await listWorkspaces(this)
364
+ const defaultWsPath = workspaces.length > 0 ? workspaces[0].path : process.cwd()
365
+ const cwd = cwdOverride || this.config.cwd || defaultWsPath
366
+ // 校验指定目录存在且是目录,防止路径遍历
367
+ if (cwdOverride) {
368
+ const validation = await validateWorkspacePath(this, cwdOverride)
369
+ if (!validation.valid) {
370
+ await this.sendText(validation.error)
371
+ return
372
+ }
373
+ }
374
+ const meta = {
375
+ cwd,
376
+ agentPreset: this.config.agentPreset || 'routing-suite',
377
+ }
378
+ const agentOptions = {}
379
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
380
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
381
+ if (!agentOptions.provider || !agentOptions.model) {
382
+ try {
383
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
384
+ if (def) {
385
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
386
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
387
+ }
388
+ } catch { /* 默认模型服务不可用则忽略,交由 DSH 自行处理 */ }
389
+ }
390
+ // 注意:不预创建 ctx.sessions——agents.create 会自己 prepare+enter session
391
+ const handle = await this.ctx.agents.create({ sessionId, meta, agentOptions })
392
+ this.setActiveSession(handle.agent.session)
393
+
394
+ // 同步挂载进 DSH 工作区账本 (workspaceRegistry),使新会话在 Web 侧边栏和 /list 中精准归组
395
+ try {
396
+ const reg = this.ctx.workspaceRegistry
397
+ const entities = reg?.list ? (await reg.list()) : []
398
+ const normCwd = normalize(cwd).toLowerCase()
399
+ const match = (entities || []).find((ws) => ws?.path && normalize(ws.path).toLowerCase() === normCwd)
400
+ if (match?.attachSession) await match.attachSession(sessionId)
401
+ } catch { /* 账本写入失败不影响会话创建 */ }
402
+
403
+ if (prompt) {
404
+ const platformName = this.platform?.name || 'IM客户端'
405
+ const promptWithContext = `${prompt}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${platformName}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
406
+ handle.agent.followup(createUserMessage({
407
+ content: [{ type: 'text', text: promptWithContext }],
408
+ source: { kind: 'user' },
409
+ }))
410
+ }
411
+ const wsDetail = cwd ? `\n- **工作区**:\`${cwd}\`` : ''
412
+ const hint = prompt ? '' : '\n\n> 💡 发送任意消息即可直接与 Agent 对话。'
413
+ await this.sendText(`✓ **已创建新会话**\n- **会话 ID**:\`${fmtSessionId(handle.agent.session.id)}\`${wsDetail}${hint}`)
414
+ } catch (error) {
415
+ await this.sendText(`❌ **创建会话失败**:${error instanceof Error ? error.message : String(error)}`)
416
+ }
417
+ }
418
+
419
+ // ---- 审批 ----
420
+
421
+ nextApprovalNumber() {
422
+ this.approvalCounter += 1
423
+ return this.approvalCounter
424
+ }
425
+
426
+ registerApproval(number, approval) {
427
+ this.pending.set(number, approval)
428
+ }
429
+
430
+ clearApproval(number) {
431
+ const entry = this.pending.get(number)
432
+ if (entry) {
433
+ clearTimeout(entry.timer)
434
+ this.pending.delete(number)
435
+ }
436
+ }
437
+
438
+ // 取消并拒绝审批(用于 dispose 清理)
439
+ cancelApproval(number) {
440
+ const entry = this.pending.get(number)
441
+ if (entry) {
442
+ clearTimeout(entry.timer)
443
+ this.pending.delete(number)
444
+ entry.resolve('rejected') // 触发 Promise,防止泄漏
445
+ }
446
+ }
447
+
448
+ resolveApproval(text) {
449
+ const entries = [...this.pending.entries()]
450
+ if (entries.length === 0) return false
451
+ let outcome
452
+ if (text === '/yes') outcome = 'allowed-once'
453
+ else if (text === '/no') outcome = 'rejected'
454
+ if (outcome) {
455
+ const [number, entry] = entries[entries.length - 1]
456
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
457
+ if (!this.pending.has(number)) return false
458
+ this.clearApproval(number)
459
+ entry.resolve(outcome)
460
+ return true
461
+ }
462
+ if ((text === '1' || text === '2') && entries.length === 1) {
463
+ const [number, entry] = entries[0]
464
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
465
+ if (!this.pending.has(number)) return false
466
+ this.clearApproval(number)
467
+ entry.resolve(text === '1' ? 'allowed-once' : 'rejected')
468
+ return true
469
+ }
470
+ return false
471
+ }
472
+
473
+ stopAllHeartbeats() {
474
+ if (this._digestState) {
475
+ for (const state of this._digestState.values()) {
476
+ if (state && state.heartbeat) {
477
+ clearInterval(state.heartbeat)
478
+ state.heartbeat = undefined
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ dispose() {
485
+ this.stopAllHeartbeats()
486
+ for (const disposer of this.disposers) {
487
+ try { disposer() } catch { /* 忽略 */ }
488
+ }
489
+ this.disposers = []
490
+ for (const number of [...this.pending.keys()]) this.cancelApproval(number)
491
+ // 清理所有引用,防止内存泄漏
492
+ this.peerId = null
493
+ this.activeSessionId = null
494
+ this._restoringConfig = null
495
+ }
496
+
497
+ // ---- 入站核心(平台无关)----
498
+ //
499
+ // 子类解析出平台消息后调用本方法:
500
+ // await bridge.handleInbound({ senderId, text, isGroup })
501
+ //
502
+ // 返回:
503
+ // 'ignored' 消息被忽略(未授权/群消息/空消息)
504
+ // 'routed' 消息已路由到 agent
505
+ async handleInbound({ senderId, text, isGroup = false }) {
506
+ try {
507
+ // 等待配置恢复完成(防止启动时竞态)
508
+ if (this._restoringConfig) {
509
+ await this._restoringConfig
510
+ }
511
+
512
+ const sender = String(senderId ?? '').trim()
513
+ if (!sender) return 'ignored'
514
+
515
+ if (!this.isAllowed(sender)) {
516
+ // 自动授权:
517
+ // - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
518
+ // - 群聊:首次 @机器人 的群自动纳入(群维度授权,群内成员均可使用)
519
+ // 这是"登录后第一条消息/首次被 @即完成授权"的一步到位体验。
520
+ const shouldAutoApprove = Boolean(text?.trim()) && (
521
+ this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
522
+ isGroup // 群聊:始终自动授权群
523
+ )
524
+ if (shouldAutoApprove) {
525
+ this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
526
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
527
+ try {
528
+ await this.onFirstSender?.(sender)
529
+ } catch (err) {
530
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
531
+ }
532
+ } else {
533
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
534
+ }
535
+
536
+ // 如果仍未通过白名单,拒绝处理(防止绕过白名单)
537
+ if (!this.isAllowed(sender)) {
538
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
539
+ return 'ignored'
540
+ }
541
+ }
542
+
543
+ // 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
544
+ if (isGroup && !this.platform?.capabilities?.supportsGroup) {
545
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
546
+ return 'ignored'
547
+ }
548
+
549
+ const fullText = text?.trim() ?? ''
550
+ if (!fullText) {
551
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore empty message from ${sender}`)
552
+ return 'ignored'
553
+ }
554
+
555
+ this.peerId = sender
556
+
557
+ if (await routeCommand(this, fullText)) return 'routed'
558
+
559
+ let agent = this.activeAgent()
560
+ if (!agent && this.activeSessionId) {
561
+ const sessionId = this.activeSessionId
562
+ if (this._restoringSessionMap.has(sessionId)) {
563
+ try {
564
+ await this._restoringSessionMap.get(sessionId)
565
+ } catch { /* 错误已在原始 Promise 中捕获 */ }
566
+ agent = this.activeAgent()
567
+ } else {
568
+ const restorePromise = (async () => {
569
+ // agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
570
+ try {
571
+ const agentOptions = {}
572
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
573
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
574
+ if (!agentOptions.provider || !agentOptions.model) {
575
+ try {
576
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
577
+ if (def) {
578
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
579
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
580
+ }
581
+ } catch { /* ignore */ }
582
+ }
583
+
584
+ // 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
585
+ // 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
586
+ let persisted = false
587
+ try {
588
+ const headers = await this.ctx.sessionPersistence?.list?.()
589
+ persisted = Array.isArray(headers) && headers.some((h) => h?.id === sessionId)
590
+ } catch { /* 读取失败则按未持久化处理 */ }
591
+
592
+ let handle
593
+ if (persisted) {
594
+ handle = await this.ctx.agents.resume({
595
+ resumeSessionId: sessionId,
596
+ agentOptions,
597
+ })
598
+ } else {
599
+ handle = await this.ctx.agents.create({
600
+ sessionId,
601
+ meta: { cwd: this.config.cwd || process.cwd() },
602
+ agentOptions,
603
+ })
604
+ }
605
+ const resumedAgent = handle?.agent
606
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
607
+ return resumedAgent
608
+ } catch (err) {
609
+ const reason = err instanceof Error ? err.message : String(err)
610
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
611
+ // 清除失效的 activeSessionId,避免用户误以为还在旧会话中
612
+ if (this.activeSessionId === sessionId) {
613
+ this.activeSessionId = null
614
+ }
615
+ await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
616
+ return null
617
+ }
618
+ })().finally(() => {
619
+ this._restoringSessionMap.delete(sessionId)
620
+ })
621
+
622
+ this._restoringSessionMap.set(sessionId, restorePromise)
623
+ agent = await restorePromise
624
+ }
625
+ }
626
+ if (!agent) {
627
+ await this.sendText(`> 💤 **当前没有活动会话**\n> 发送 \`/new <提示词>\` 开始新会话,或发送 \`/sessions\` 查看已有会话。`)
628
+ return 'routed'
629
+ }
630
+
631
+ // 针对微信/IM客户端用户,注入上下文提示,规范 Agent 仅在需要向用户发送文件附件时输出 [SEND_FILE: <文件绝对路径>]
632
+ const promptWithContext = `${fullText}\n\n<!-- [dsh-bridge 提示] 当前用户正通过【${this.platform.name}】与你对话。若用户明确要求发送、导出或传送文件/图片/报表/代码脚本产物,请在本地生成/准备好文件后,在回复正文中附带明确发送指令:\n[SEND_FILE: <本地文件绝对路径>]\n例如:[SEND_FILE: C:\\path\\to\\report.xlsx]\n网关会自动解析该指令并将该文件直传至用户的聊天窗口,且在聊天文本中自动隐藏该指令。在日常编写代码、回复普通文本或未请求发送文件时,请勿输出此指令。 -->`
633
+
634
+ const messageValue = createUserMessage({
635
+ content: [{ type: 'text', text: promptWithContext }],
636
+ source: { kind: 'user' },
637
+ })
638
+ agent.followup(messageValue)
639
+ await this.sendTyping(1).catch(() => {})
640
+ return 'routed'
641
+ } catch (err) {
642
+ const errMsg = err instanceof Error ? err.message : String(err)
643
+ this.logger?.error?.(`[dsh-bridge ${this.platform.id}] unhandled error in handleInbound: ${errMsg}`)
644
+ await this.sendText(`❌ **执行出错**:${errMsg}`).catch(() => {})
645
+ return 'ignored'
646
+ }
647
+ }
648
+
649
+ // ---- 发送(子类必须实现)----
650
+
651
+ /** 向当前 peer 发送文本(自动分块 + typing 指示)。 */
652
+ async sendText(text) {
653
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
654
+ const peer = this.peerId || this.config.allowFrom?.[0]
655
+ if (!peer) return
656
+ const chunks = splitForIM(text, this.config.maxMessageChars)
657
+ if (chunks.length === 0) return
658
+ await this.sendTyping(1).catch(() => {})
659
+ try {
660
+ for (let i = 0; i < chunks.length; i++) {
661
+ const result = await this.platform.sendText(peer, chunks[i])
662
+ if (result && result.success === false) {
663
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] outbound chunk ${i + 1}/${chunks.length} failed: ${result.error}`)
664
+ break
665
+ }
666
+ if (i < chunks.length - 1 && this.config.sendChunkDelayMs > 0) {
667
+ await sleep(this.config.sendChunkDelayMs)
668
+ }
669
+ }
670
+ } finally {
671
+ await this.sendTyping(2).catch(() => {})
672
+ }
673
+ }
674
+
675
+ /** 发送 typing 状态(1=开始,2=停止)。子类可覆盖。 */
676
+ async sendTyping(state) {
677
+ const peer = this.peerId || this.config.allowFrom?.[0]
678
+ if (!this.platform?.sendTyping || peer == null) return
679
+ return this.platform.sendTyping(peer, state)
680
+ }
681
+
682
+ // ---- 出站事件绑定 ----
683
+
684
+ _attachOutbound() {
685
+ this._digestState = new Map()
686
+ const stopHeartbeat = (state) => {
687
+ if (state && state.heartbeat) {
688
+ clearInterval(state.heartbeat)
689
+ state.heartbeat = undefined
690
+ }
691
+ }
692
+ const startHeartbeat = (session, state) => {
693
+ stopHeartbeat(state)
694
+ if (this.config.digestIntervalSec <= 0) return
695
+ state.heartbeat = setInterval(() => {
696
+ // 1. 必须依然是当前活动会话
697
+ if (this.activeSessionId !== session.id) {
698
+ stopHeartbeat(state)
699
+ return
700
+ }
701
+ // 2. 检查会话当前是否真正处于 inTurn 状态中
702
+ const line = digestLine(session)
703
+ if (!line) {
704
+ stopHeartbeat(state)
705
+ return
706
+ }
707
+ // 心跳时同时刷新 typing 状态(微信 typing 只维持 15 秒)
708
+ if (this.peerId) this.sendTyping(1).catch(() => {})
709
+ void this.sendText(line)
710
+ }, this.config.digestIntervalSec * 1000)
711
+ if (typeof state.heartbeat.unref === 'function') state.heartbeat.unref()
712
+ }
713
+ const onEvent = async (session, event) => {
714
+ const state = this._digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
715
+ this._digestState.set(session.id, state)
716
+
717
+ // 无论是否为当前活动会话,一旦收到 turn/end,立即停止该 session 的心跳定时器
718
+ if (event.type === 'turn/end') {
719
+ stopHeartbeat(state)
720
+ }
721
+ if (session.id !== this.activeSessionId) return
722
+ if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
723
+
724
+ if (event.type === 'turn/start') {
725
+ const turn = event.data?.turn
726
+ state.createdFiles = new Set()
727
+ if (turn != null && !state.startedTurns.has(turn)) {
728
+ state.startedTurns.add(turn)
729
+ // 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
730
+ if (this.peerId) this.sendTyping(1).catch(() => {})
731
+ }
732
+ startHeartbeat(session, state)
733
+ return
734
+ }
735
+ const getSessionCwd = (sess) => sess?.cwd || this.config.cwd || process.cwd()
736
+
737
+ if (event.type === 'tool/call') {
738
+ // 工具执行仅在终端/状态中展示,文件直发由 AI 回复中的 [SEND_FILE: ...] 指令显式驱动,杜绝误判
739
+ return
740
+ }
741
+ if (event.type === 'assistant/message') {
742
+ const rawText = textOfAssistantMessage(event.data.message)
743
+ if (rawText.trim()) {
744
+ const cwd = getSessionCwd(session)
745
+ const { cleanText, files } = extractAndStripSendFileDirectives(rawText, cwd)
746
+ for (const f of files) {
747
+ state.createdFiles.add(f)
748
+ }
749
+ // 仅向聊天窗口发送过滤掉 [SEND_FILE: ...] 控制指令后的纯净正文
750
+ if (cleanText) {
751
+ void this.sendText(cleanText)
752
+ }
753
+ }
754
+ return
755
+ }
756
+ if (event.type === 'turn/end') {
757
+ stopHeartbeat(state)
758
+ if (this.peerId) this.sendTyping(2).catch(() => {})
759
+ const reason = event.data?.reason || {}
760
+ if (reason.kind === 'error') {
761
+ void this.sendText(`❌ **处理出错**:${summarizeError(reason.error)}`)
762
+ } else if (reason.kind === 'aborted') {
763
+ void this.sendText(`⏹ **任务已停止**`)
764
+ } else if (reason.kind === 'max-tokens') {
765
+ void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
766
+ }
767
+
768
+ // 如果本轮 AI 显式指定了 [SEND_FILE: ...] 文件发送指令,直接上传并发送给用户
769
+ if (state.createdFiles && state.createdFiles.size > 0) {
770
+ const rawFiles = Array.from(state.createdFiles)
771
+ const cwd = getSessionCwd(session)
772
+ const targetPeer = this.peerId || this.config.allowFrom?.[0]
773
+
774
+ if (typeof this.platform?.sendMediaFile === 'function' && targetPeer) {
775
+ const uniqueFilesToSend = []
776
+ for (const f of rawFiles) {
777
+ const resolved = resolveFilePath(f, cwd)
778
+ if (resolved && !uniqueFilesToSend.includes(resolved)) {
779
+ uniqueFilesToSend.push(resolved)
780
+ }
781
+ }
782
+ for (const resolved of uniqueFilesToSend) {
783
+ try {
784
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile to peer: ${resolved}`)
785
+ const res = await this.platform.sendMediaFile(targetPeer, resolved)
786
+ if (res && res.success === false) {
787
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, res.error)
788
+ }
789
+ } catch (err) {
790
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] auto sendMediaFile ${resolved} failed: %s`, err?.message ?? err)
791
+ }
792
+ }
793
+ }
794
+ state.createdFiles.clear()
795
+ }
796
+ return
797
+ }
798
+ }
799
+ const listener = (session, event) => { void onEvent(session, event) }
800
+ const disposer = this.ctx.on('session/event', listener)
801
+ this.disposers.push(() => {
802
+ for (const state of digestState.values()) stopHeartbeat(state)
803
+ disposer()
804
+ })
805
+ }
806
+
807
+ // ---- 审批桥 ----
808
+
809
+ _attachApprovalBridge() {
810
+ const listener = async (req, next) => {
811
+ if (!this.ownsAgent(req.agent)) return next?.()
812
+ const peer = this.peerId
813
+ if (!peer) return next?.()
814
+
815
+ const number = this.nextApprovalNumber()
816
+ const timeoutSec = this.config.approvalTimeoutSec
817
+ const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
818
+ const prompt = [
819
+ `## ⚠️ 操作权限确认 (#${number})`,
820
+ '',
821
+ '| 项目 | 详情 |',
822
+ '| :--- | :--- |',
823
+ `| **调用工具** | \`${req.toolName}\` |`,
824
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
825
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
826
+ '',
827
+ `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
828
+ `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
829
+ ].join('\n')
830
+
831
+ void this.sendText(prompt)
832
+
833
+ // 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
834
+ let nextPromise = null
835
+ if (typeof next === 'function') {
836
+ try {
837
+ const res = next()
838
+ if (res && typeof res.then === 'function') {
839
+ nextPromise = res
840
+ }
841
+ } catch {}
842
+ }
843
+
844
+ let timeoutFired = false
845
+ let winner = 'im'
846
+ const imPromise = new Promise((resolve) => {
847
+ const timer = setTimeout(() => {
848
+ timeoutFired = true
849
+ this.clearApproval(number)
850
+ resolve('rejected')
851
+ }, timeoutSec * 1000)
852
+ if (typeof timer.unref === 'function') timer.unref()
853
+ this.registerApproval(number, { number, request: req, resolve, timer })
854
+ })
855
+
856
+ const outcome = await (nextPromise
857
+ ? Promise.race([
858
+ imPromise.then(res => { winner = 'im'; return res }),
859
+ nextPromise.then(res => { winner = 'web'; return res }),
860
+ ])
861
+ : imPromise)
862
+
863
+ // 如果 Web 端先决议,清除 IM 端 pending 记录
864
+ if (winner === 'web') {
865
+ this.clearApproval(number)
866
+ }
867
+
868
+ // 仅在非超时路径发送确认消息(超时时 resolve 已经发生在 timer 回调)
869
+ if (!timeoutFired) {
870
+ const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
871
+ const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
872
+ void this.sendText(`${label}(#${number})`)
873
+ }
874
+ return outcome
875
+ }
876
+ const disposer = this.ctx.on('approval/request', listener)
877
+ this.disposers.push(disposer)
878
+ }
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // 辅助
883
+ // ---------------------------------------------------------------------------
884
+
885
+ function sessionLabel(session) {
886
+ for (const event of session.events ?? []) {
887
+ if (event.type === 'user/message') {
888
+ const blocks = event.data.content ?? []
889
+ const text = blocks
890
+ .filter((block) => block.type === 'text')
891
+ .map((block) => block.text ?? '')
892
+ .join(' ')
893
+ .trim()
894
+ if (text) return text.length > 28 ? `${text.slice(0, 28)}…` : text
895
+ }
896
+ }
897
+ return '新会话 (待输入)'
898
+ }
899
+
900
+ // 校验工作区路径(防止路径遍历攻击)
901
+ async function validateWorkspacePath(node, sel) {
902
+ const normalized = normalize(resolve(sel))
903
+ const workspaces = await listWorkspaces(node)
904
+ const allowedPaths = workspaces.map(w => normalize(resolve(w.path)))
905
+
906
+ if (!allowedPaths.includes(normalized)) {
907
+ const wsDisplay = allowedPaths.slice(0, 5).map((p, i) => `- \`[${i + 1}]\` \`${p}\``).join('\n')
908
+ const more = allowedPaths.length > 5 ? `\n- *…等共 ${allowedPaths.length} 个工作区*` : ''
909
+ return {
910
+ valid: false,
911
+ error: `❌ **路径不在已注册工作区列表中**:\`${normalized}\`\n\n**可用工作区:**\n${wsDisplay}${more}\n\n> 提示:发送 \`/workspaces\` 查看完整列表`
912
+ }
913
+ }
914
+
915
+ // 校验目录存在
916
+ let ok = false
917
+ try { ok = statSync(normalized).isDirectory() } catch { ok = false }
918
+ if (!ok) {
919
+ return {
920
+ valid: false,
921
+ error: `❌ **工作区目录不存在**:\`${normalized}\``
922
+ }
923
+ }
924
+
925
+ return { valid: true, path: normalized }
926
+ }
927
+
928
+ // 从事件日志折叠会话标题(本地实现,等价 DSH foldSessionTitle):优先取最后的
929
+ // session/title 事件(DSH 生成的会话名),否则回退到第一条用户消息文本。
930
+ function foldTitle(events) {
931
+ const evts = events ?? []
932
+ for (let i = evts.length - 1; i >= 0; i--) {
933
+ const e = evts[i]
934
+ if (e && e.type === 'session/title' && e.data?.title) return String(e.data.title)
935
+ }
936
+ return null
937
+ }
938
+
939
+ /** 获取所有已归档会话 ID 集合(支持 ctx.workspaceRegistry 内存服务 + workspace.json 文件存储双重兜底) */
940
+ function getArchivedSessionIds(ctx) {
941
+ const archived = new Set()
942
+ // 1. 尝试从 ctx.workspaceRegistry 内存服务读取
943
+ try {
944
+ const list = ctx?.workspaceRegistry?.archivedSessionIds
945
+ if (Array.isArray(list)) {
946
+ for (const id of list) {
947
+ if (id) archived.add(String(id))
948
+ }
949
+ return archived
950
+ }
951
+ } catch { /* ignore */ }
952
+
953
+ // 2. 尝试从 DSH workspace 存储文件($DSH_HOME/storages/workspace.json)读取兜底
954
+ if (!ctx?._mock) {
955
+ try {
956
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
957
+ const wsFile = join(home, 'storages', 'workspace.json')
958
+ if (existsSync(wsFile)) {
959
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
960
+ const fileArchived = data?.global?.archivedSessionIds
961
+ if (Array.isArray(fileArchived)) {
962
+ for (const id of fileArchived) {
963
+ if (id) archived.add(String(id))
964
+ }
965
+ }
966
+ }
967
+ } catch { /* ignore */ }
968
+ }
969
+
970
+ return archived
971
+ }
972
+
973
+ /** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
974
+ function getSessionProjCache(ctx) {
975
+ if (ctx?._mock) return {}
976
+ try {
977
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
978
+ const cacheFile = join(home, 'storages', 'session_projcache.json')
979
+ if (existsSync(cacheFile)) {
980
+ const data = JSON.parse(readFileSync(cacheFile, 'utf8'))
981
+ return data?.tables?.sessions || {}
982
+ }
983
+ } catch { /* ignore */ }
984
+ return {}
985
+ }
986
+
987
+ /** 读取 DSH 官方注册的工作区列表及各自绑定的 sessionIds 列表 */
988
+ async function getRegisteredWorkspaces(ctx) {
989
+ const workspaces = []
990
+
991
+ // 优先从内存服务获取
992
+ if (ctx?.workspaceRegistry) {
993
+ try {
994
+ const list = await ctx.workspaceRegistry.list?.()
995
+ if (Array.isArray(list)) {
996
+ for (const w of list) {
997
+ if (w && w.path) {
998
+ workspaces.push({
999
+ id: w.id || w.path,
1000
+ path: w.path,
1001
+ title: w.title || basename(w.path),
1002
+ sessionIds: Array.isArray(w.sessionIds) ? [...w.sessionIds] : [],
1003
+ })
1004
+ }
1005
+ }
1006
+ return workspaces
1007
+ }
1008
+ } catch { /* ignore */ }
1009
+ }
1010
+
1011
+ // 兜底从 workspace.json 存储文件读取
1012
+ if (!ctx?._mock) {
1013
+ try {
1014
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1015
+ const wsFile = join(home, 'storages', 'workspace.json')
1016
+ if (existsSync(wsFile)) {
1017
+ const data = JSON.parse(readFileSync(wsFile, 'utf8'))
1018
+ const wsIds = data?.global?.workspaceIds || Object.keys(data?.tables?.workspaces || {})
1019
+ const table = data?.tables?.workspaces || {}
1020
+ for (const wId of wsIds) {
1021
+ const ws = table[wId]
1022
+ if (ws && ws.path) {
1023
+ workspaces.push({
1024
+ id: wId,
1025
+ path: ws.path,
1026
+ title: ws.title || basename(ws.path),
1027
+ sessionIds: Array.isArray(ws.sessionIds) ? [...ws.sessionIds] : [],
1028
+ })
1029
+ }
1030
+ }
1031
+ }
1032
+ } catch { /* ignore */ }
1033
+ }
1034
+
1035
+ return workspaces
1036
+ }
1037
+
1038
+ function isSubagentSession(cacheRow, liveSession) {
1039
+ if (liveSession?.origin === 'subagent' || liveSession?.header?.origin === 'subagent') return true
1040
+ const subVal = cacheRow?.rows?.subagent?.val
1041
+ if (subVal && typeof subVal === 'object' && Object.keys(subVal).length > 0) return true
1042
+ return false
1043
+ }
1044
+
1045
+ function formatGoalTitle(raw) {
1046
+ if (typeof raw === 'string' && raw) return raw
1047
+ if (raw && typeof raw === 'object') {
1048
+ const obj = raw.objective ?? raw.goal?.objective ?? raw.title
1049
+ if (typeof obj === 'string' && obj) return obj
1050
+ }
1051
+ return ''
1052
+ }
1053
+
1054
+ // 列出会话:严格对齐 DSH Web 端侧边栏会话树逻辑。
1055
+ // 1. 过滤已归档会话 (archivedSessionIds)
1056
+ // 2. 过滤未发起提问的空白会话 (blank: true)
1057
+ // 3. 过滤子代理内部会话 (subagent origin)
1058
+ // 4. 严格按工作区账本 (workspace.sessionIds) 组织
1059
+ async function listSessions(node) {
1060
+ const archived = getArchivedSessionIds(node.ctx)
1061
+ const projCache = getSessionProjCache(node.ctx)
1062
+ const workspaces = await getRegisteredWorkspaces(node.ctx)
1063
+
1064
+ // 内存活跃会话(按 id 索引)
1065
+ const liveList = [...(node.ctx.sessions?.list?.() ?? [])].filter(
1066
+ (s) => s && s.id && !archived.has(s.id) && !s.archived && !s.header?.archived
1067
+ )
1068
+ const liveById = new Map(liveList.map((s) => [s.id, s]))
1069
+
1070
+ const accounted = new Set()
1071
+ const result = []
1072
+
1073
+ // 1. 如果存在已注册的工作区,严格按工作区及其 sessionIds 账本组织(与 Web 端完全一致)
1074
+ if (workspaces.length > 0) {
1075
+ for (const ws of workspaces) {
1076
+ const normWsPath = ws.path ? normalize(ws.path).toLowerCase() : ''
1077
+
1078
+ // 优先将当前工作区下新创建但在内存里的 live 会话追加到头部
1079
+ for (const s of liveList) {
1080
+ const sCwd = s.header?.cwd || s.cwd
1081
+ if (sCwd && normalize(sCwd).toLowerCase() === normWsPath && !accounted.has(s.id)) {
1082
+ if (isSubagentSession(projCache[s.id], s)) continue
1083
+ accounted.add(s.id)
1084
+ let title = s.title || (s.events ? foldTitle(s.events) : '')
1085
+ if (!title) {
1086
+ const cache = projCache[s.id]
1087
+ title = cache?.rows?.title?.val || formatGoalTitle(cache?.rows?.goal?.val)
1088
+ }
1089
+ result.push({
1090
+ id: s.id,
1091
+ createdAt: s.header?.createdAt || Date.now(),
1092
+ cwd: ws.path,
1093
+ workspaceTitle: ws.title,
1094
+ title: title || '新会话',
1095
+ events: s.events,
1096
+ seq: s.seq ?? 0,
1097
+ })
1098
+ }
1099
+ }
1100
+
1101
+ // 按工作区账本存储的 sessionIds 顺序追加已记录会话
1102
+ for (const sId of ws.sessionIds) {
1103
+ if (archived.has(sId) || accounted.has(sId)) continue
1104
+ accounted.add(sId)
1105
+
1106
+ const cache = projCache[sId]
1107
+ const live = liveById.get(sId)
1108
+ // 过滤空白草稿会话(非当前活动会话)
1109
+ if (cache?.rows?.sessionListMetadata?.val?.blank === true && sId !== node.activeSessionId) {
1110
+ continue
1111
+ }
1112
+ // 过滤子代理内部会话
1113
+ if (isSubagentSession(cache, live)) {
1114
+ continue
1115
+ }
1116
+
1117
+ let title = cache?.rows?.title?.val || formatGoalTitle(cache?.rows?.goal?.val)
1118
+ let createdAt = cache?.identity?.createdAt || 0
1119
+ let cwd = ws.path
1120
+
1121
+ // 如果内存有该会话,提取最新数据
1122
+ if (live) {
1123
+ title = live.title || (live.events ? foldTitle(live.events) : '') || title
1124
+ createdAt = live.header?.createdAt || createdAt
1125
+ } else if (!title && node.ctx.sessionPersistence?.load) {
1126
+ try {
1127
+ const insp = await node.ctx.sessionPersistence.load(sId)
1128
+ title = foldTitle(insp.events ?? []) ?? undefined
1129
+ } catch {}
1130
+ }
1131
+
1132
+ result.push({
1133
+ id: sId,
1134
+ createdAt,
1135
+ cwd,
1136
+ workspaceTitle: ws.title,
1137
+ title: title || '新会话',
1138
+ events: live?.events,
1139
+ seq: live?.seq ?? 0,
1140
+ })
1141
+ }
1142
+ }
1143
+
1144
+ // 处理当前内存中处于活动状态但未绑定任何工作区的 live 会话
1145
+ for (const s of liveList) {
1146
+ if (accounted.has(s.id)) continue
1147
+ if (isSubagentSession(projCache[s.id], s)) continue
1148
+ accounted.add(s.id)
1149
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '未分组会话'
1150
+ result.push({
1151
+ id: s.id,
1152
+ createdAt: s.header?.createdAt || Date.now(),
1153
+ cwd: s.header?.cwd || '(未指定)',
1154
+ workspaceTitle: '未指定工作区',
1155
+ title,
1156
+ events: s.events,
1157
+ seq: s.seq ?? 0,
1158
+ })
1159
+ }
1160
+ } else {
1161
+ // 2. 如果系统未注册任何工作区(如单目录/无工作区模式),降级读取内存及持久化会话
1162
+ for (const s of liveList) {
1163
+ accounted.add(s.id)
1164
+ const title = s.title || (s.events ? foldTitle(s.events) : '') || '活跃会话'
1165
+ result.push({
1166
+ id: s.id,
1167
+ createdAt: s.header?.createdAt || Date.now(),
1168
+ cwd: s.header?.cwd || '(未指定)',
1169
+ workspaceTitle: '未指定工作区',
1170
+ title,
1171
+ events: s.events,
1172
+ seq: s.seq ?? 0,
1173
+ })
1174
+ }
1175
+ if (node.ctx.sessionPersistence?.list) {
1176
+ try {
1177
+ const headers = await node.ctx.sessionPersistence.list()
1178
+ if (Array.isArray(headers)) {
1179
+ const coldHeaders = headers.filter((h) => h && h.id && !accounted.has(h.id) && !archived.has(h.id) && !h.archived)
1180
+ for (const h of coldHeaders) {
1181
+ accounted.add(h.id)
1182
+ let title
1183
+ try {
1184
+ const insp = await node.ctx.sessionPersistence.load(h.id)
1185
+ title = foldTitle(insp.events ?? []) ?? undefined
1186
+ } catch {}
1187
+ result.push({
1188
+ id: h.id,
1189
+ createdAt: h.createdAt ?? 0,
1190
+ events: undefined,
1191
+ seq: 0,
1192
+ cwd: h.cwd || '(未指定)',
1193
+ workspaceTitle: '未指定工作区',
1194
+ title: title || '新会话',
1195
+ })
1196
+ }
1197
+ }
1198
+ } catch {}
1199
+ }
1200
+ }
1201
+
1202
+ return result
1203
+ }
1204
+
1205
+ // 列出可用工作区:使用 DSH 官方 workspaceRegistry。返回 [{title, path}]。
1206
+ async function listWorkspaces(node) {
1207
+ try {
1208
+ const list = await node.ctx.workspaceRegistry?.list?.() ?? []
1209
+ const out = []
1210
+ for (const ws of list) {
1211
+ if (ws && ws.path) out.push({ title: ws.title ?? ws.path, path: ws.path })
1212
+ }
1213
+ return out.sort((a, b) => String(a.path).localeCompare(String(b.path)))
1214
+ } catch {
1215
+ return []
1216
+ }
1217
+ }
1218
+
1219
+ function getWorkspaceBasename(cwd) {
1220
+ if (!cwd || cwd === '(未指定)') return '(未指定)'
1221
+ const norm = normalize(cwd).replace(/[\\/]+$/, '')
1222
+ const parts = norm.split(/[\\/]/)
1223
+ return parts[parts.length - 1] || cwd
1224
+ }
1225
+
1226
+ async function routeCommand(node, text) {
1227
+ const trimmed = text.trim()
1228
+
1229
+ if (trimmed === '/yes' || trimmed === '/no' || /^[12]$/.test(trimmed)) {
1230
+ if (node.resolveApproval(trimmed)) return true
1231
+ }
1232
+
1233
+ if (!trimmed.startsWith('/')) return false
1234
+
1235
+ const [command, ...rest] = trimmed.slice(1).split(/\s+/)
1236
+ switch (command) {
1237
+ case 'help':
1238
+ await node.sendText(helpText())
1239
+ return true
1240
+ case 'sessions':
1241
+ case 'list':
1242
+ await node.sendText(await renderSessions(node))
1243
+ return true
1244
+ case 'use':
1245
+ case 'resume': {
1246
+ const index = Number(rest[0])
1247
+ const sessions = sessionsInDisplayOrder(await listSessions(node))
1248
+ if (!Number.isInteger(index) || index < 1 || index > sessions.length) {
1249
+ await node.sendText(`❌ **无效会话编号**:\`${rest[0] ?? ''}\`\n\n> 可用编号范围:\`1 – ${sessions.length}\`(发送 \`/sessions\` 查看会话列表)`)
1250
+ return true
1251
+ }
1252
+ const session = sessions[index - 1]
1253
+ node.setActiveSessionById(session.id)
1254
+ const title = session.title || (session.events ? sessionLabel(session) : '')
1255
+ const titleLine = title ? `\n- **标题**:${title}` : ''
1256
+ await node.sendText(`✓ **已切换到会话 #${index}**${titleLine}\n- **会话 ID**:\`${fmtSessionId(session.id)}\``)
1257
+ return true
1258
+ }
1259
+ case 'rename': {
1260
+ if (!node.activeSessionId) {
1261
+ await node.sendText(`❌ **当前没有活动会话**\n\n> 请先使用 \`/sessions\` 查看会话列表并通过 \`/use 编号\` 切换到目标会话,或通过 \`/new <提示词>\` 创建新会话。`)
1262
+ return true
1263
+ }
1264
+ const newTitle = rest.join(' ').trim()
1265
+ if (!newTitle) {
1266
+ await node.sendText(`❌ **缺少新标题参数**\n\n> 用法:\`/rename <新标题>\`\n> 示例:\`/rename 优化登录交互逻辑\``)
1267
+ return true
1268
+ }
1269
+
1270
+ try {
1271
+ const session = node.activeSession()
1272
+ if (session) {
1273
+ session.title = newTitle
1274
+ }
1275
+ if (node.ctx.sessionPersistence?.update) {
1276
+ await node.ctx.sessionPersistence.update(node.activeSessionId, { title: newTitle }).catch(() => {})
1277
+ }
1278
+ await node.sendText(`✓ **会话重命名成功**\n- **会话 ID**:\`${fmtSessionId(node.activeSessionId)}\`\n- **新标题**:${newTitle}`)
1279
+ } catch (err) {
1280
+ await node.sendText(`❌ **重命名失败**:${err instanceof Error ? err.message : String(err)}`)
1281
+ }
1282
+ return true
1283
+ }
1284
+ case 'workspaces': {
1285
+ const workspaces = await listWorkspaces(node)
1286
+ if (workspaces.length === 0) {
1287
+ await node.sendText(`## 🗂️ 可用工作区\n\n> 当前没有已注册的工作区。可使用 \`/new <提示词> @<路径>\` 指定项目目录。`)
1288
+ return true
1289
+ }
1290
+ const rows = workspaces.map((w, i) => {
1291
+ const titleStr = w.title && w.title !== w.path ? w.title : getWorkspaceBasename(w.path)
1292
+ const safeTitle = titleStr.replace(/\|/g, '|')
1293
+ return `| **@${i + 1}** | ${safeTitle} | \`${w.path}\` |`
1294
+ })
1295
+ await node.sendText([
1296
+ `## 🗂️ 可用工作区 (共 ${workspaces.length} 个)`,
1297
+ `> 新建会话:发送 \`/new <提示词> @序号\` 或 \`/new <提示词> @路径\``,
1298
+ '',
1299
+ '| 序号 | 工作区名称 | 目录路径 |',
1300
+ '| :--- | :--- | :--- |',
1301
+ ...rows,
1302
+ ].join('\n'))
1303
+ return true
1304
+ }
1305
+ case 'addworkspace': {
1306
+ const targetPath = rest.join(' ').trim()
1307
+ if (!targetPath) {
1308
+ await node.sendText(`❌ **缺少工作区路径**\n\n> 用法:\`/addworkspace <电脑绝对路径>\`\n> 示例:\`/addworkspace D:\\IdeaProjects\\my-app\``)
1309
+ return true
1310
+ }
1311
+ try {
1312
+ const safetyCheck = await isSafeWorkspacePath(targetPath)
1313
+ if (!safetyCheck.valid) {
1314
+ await node.sendText(`⚠️ **${safetyCheck.error || '路径安全校验未通过'}**:\`${targetPath}\`\n\n> 出于安全考虑,禁止将系统关键目录或敏感配置文件所在路径登记为工作区。`)
1315
+ return true
1316
+ }
1317
+ const resolved = safetyCheck.path
1318
+ const title = basename(resolved) || resolved
1319
+ if (node.ctx.workspaceRegistry?.add) {
1320
+ await node.ctx.workspaceRegistry.add({ path: resolved, title }).catch(() => {})
1321
+ } else if (node.ctx.workspaceRegistry?.register) {
1322
+ await node.ctx.workspaceRegistry.register({ path: resolved, title }).catch(() => {})
1323
+ }
1324
+ const workspaces = await listWorkspaces(node)
1325
+ const foundIndex = workspaces.findIndex(w => normalize(w.path) === normalize(resolved))
1326
+ const numStr = foundIndex >= 0 ? `@${foundIndex + 1}` : ''
1327
+ await node.sendText([
1328
+ `✓ **工作区添加成功**!`,
1329
+ `- **名称**:${title}`,
1330
+ `- **路径**:\`${resolved}\``,
1331
+ foundIndex >= 0 ? `- **快捷编号**:\`${numStr}\`` : '',
1332
+ '',
1333
+ `> 发送 \`/new <提示词> ${numStr || '@' + resolved}\` 即可直接在此工作区创建会话。`,
1334
+ ].filter(Boolean).join('\n'))
1335
+ } catch (err) {
1336
+ await node.sendText(`❌ **添加工作区失败**:${err instanceof Error ? err.message : String(err)}`)
1337
+ }
1338
+ return true
1339
+ }
1340
+ case 'new': {
1341
+ // 解析尾部 @N 或 @路径 作为工作区 cwd
1342
+ const args = rest.join(' ').trim()
1343
+ let cwd
1344
+ let prompt = args
1345
+ const atMatch = args.match(/\s+@(\S+)$/)
1346
+ if (atMatch) {
1347
+ prompt = args.slice(0, atMatch.index).trim()
1348
+ const sel = atMatch[1]
1349
+ const workspaces = await listWorkspaces(node)
1350
+ if (/^\d+$/.test(sel)) {
1351
+ const idx = Number(sel)
1352
+ const ws = workspaces[idx - 1]
1353
+ if (ws) cwd = ws.path
1354
+ else {
1355
+ await node.sendText(`❌ **无效工作区编号**:\`${sel}\`\n\n> 请发送 \`/workspaces\` 查看可用工作区列表与编号。`)
1356
+ return true
1357
+ }
1358
+ } else {
1359
+ // 直接指定路径时,规范化并校验(必须完全匹配已注册工作区)
1360
+ const validation = await validateWorkspacePath(node, sel)
1361
+ if (!validation.valid) {
1362
+ await node.sendText(validation.error)
1363
+ return true
1364
+ }
1365
+ cwd = validation.path
1366
+ }
1367
+ }
1368
+ await node.createSession(prompt, cwd)
1369
+ return true
1370
+ }
1371
+ case 'stop': {
1372
+ node.stopAllHeartbeats()
1373
+ const agent = node.activeAgent()
1374
+ if (!agent) {
1375
+ await node.sendText(`ℹ️ **当前没有正在运行的 Agent 任务**`)
1376
+ } else {
1377
+ agent.cancel({ kind: 'user' })
1378
+ await node.sendText(`⏹ **已请求停止当前任务**`)
1379
+ }
1380
+ return true
1381
+ }
1382
+ case 'end': {
1383
+ node.stopAllHeartbeats()
1384
+ // 结束当前会话:停止 agent 并清除活动会话(进入"没有活动会话"状态)
1385
+ const agent = node.activeAgent()
1386
+ if (agent) agent.cancel({ kind: 'user' })
1387
+ node.activeSessionId = null
1388
+ await node.onActiveSessionChange?.(null)
1389
+ await node.sendText(`✓ **已结束当前会话**(没有活动会话)。\n\n> **后续操作**:\n> - \`/new <提示词>\` — 新建会话并开始\n> - \`/sessions\` — 查看历史会话列表\n> - \`/help\` — 查看常用指令帮助`)
1390
+ return true
1391
+ }
1392
+ case 'status': {
1393
+ const agent = node.activeAgent()
1394
+ const session = node.activeSession()
1395
+ if (!session) {
1396
+ await node.sendText(`## 📊 Agent 状态看板\n\n> 当前没有活动会话。\n> 发送 \`/new <提示词>\` 开始新任务,或发送 \`/sessions\` 查看已有会话。`)
1397
+ return true
1398
+ }
1399
+ const statusMap = {
1400
+ idle: '空闲 (idle)',
1401
+ running: '运行中 (running)',
1402
+ paused: '已暂停 (paused)',
1403
+ error: '异常 (error)',
1404
+ }
1405
+ const status = statusMap[agent?.status] || (agent?.status ?? '空闲 (idle)')
1406
+ const lastTurn = [...(session.events ?? [])].reverse().find((e) => e.type === 'turn/end')
1407
+ const reason = lastTurn ? describeTurnEnd(lastTurn.data.reason) : '尚未运行'
1408
+ const title = session.title || (session.events ? sessionLabel(session) : '')
1409
+ const shortId = fmtSessionId(session.id)
1410
+ const cwd = session.header?.cwd || node.config?.cwd || ''
1411
+
1412
+ const content = [
1413
+ `## 📊 Agent 状态看板`,
1414
+ '',
1415
+ '| 属性 | 当前状态 / 参数 |',
1416
+ '| :--- | :--- |',
1417
+ `| **会话 ID** | \`${shortId}\` |`,
1418
+ ...(title ? [`| **会话标题** | ${title.replace(/\|/g, '|')} |`] : []),
1419
+ ...(cwd ? [`| **工作区** | \`${cwd}\` |`] : []),
1420
+ `| **Agent 状态** | ${status} |`,
1421
+ `| **累计事件** | ${session.seq ?? 0} 条 |`,
1422
+ `| **最近执行** | ${reason} |`,
1423
+ ].join('\n')
1424
+
1425
+ await node.sendText(content)
1426
+ return true
1427
+ }
1428
+ case 'start': // 别名:首次扫码自动开始一个会话
1429
+ await node.createSession('')
1430
+ return true
1431
+ default:
1432
+ await node.sendText(`❌ **未知指令**:\`/${command}\`\n\n${helpText()}`)
1433
+ return true
1434
+ }
1435
+ }
1436
+
1437
+ function describeTurnEnd(reason) {
1438
+ switch (reason.kind) {
1439
+ case 'completed': return '✓ 已完成'
1440
+ case 'error': return '❌ 出错'
1441
+ case 'aborted': return '⏹ 已停止'
1442
+ case 'blocked': return '⚠️ 已阻塞'
1443
+ case 'max-tokens': return '⚠️ 输出截断'
1444
+ case 'interrupted': return '⚡ 已中断'
1445
+ default: return `[${reason.kind}]`
1446
+ }
1447
+ }
1448
+
1449
+ async function renderSessions(node) {
1450
+ const all = await listSessions(node)
1451
+ if (all.length === 0) {
1452
+ return `## 📋 会话列表\n\n> 暂无历史会话。发送 \`/new <提示词>\` 开始新会话。`
1453
+ }
1454
+ // 按工作区分组(保持 listSessions 中的工作区账本顺序)
1455
+ const groups = new Map()
1456
+ for (const s of all) {
1457
+ const key = s.cwd || '(未指定)'
1458
+ if (!groups.has(key)) {
1459
+ groups.set(key, { title: s.workspaceTitle || getWorkspaceBasename(key), sessions: [] })
1460
+ }
1461
+ groups.get(key).sessions.push(s)
1462
+ }
1463
+ const parts = [
1464
+ `## 📋 会话列表 (共 ${all.length} 个)`,
1465
+ `> 切换会话:发送 \`/use 编号\` 或 \`/resume 编号\``,
1466
+ '',
1467
+ ]
1468
+ let idx = 0
1469
+ for (const [cwd, group] of groups) {
1470
+ const groupName = cwd === '(未指定)' ? '📁 未指定工作区' : `📁 **${group.title || getWorkspaceBasename(cwd)}**`
1471
+ parts.push(groupName)
1472
+ parts.push('')
1473
+ parts.push('| 序号 | 会话标题 / 摘要 | 时间 | 状态 |')
1474
+ parts.push('| :--- | :--- | :--- | :--- |')
1475
+ for (const session of group.sessions.slice(0, 20)) {
1476
+ idx += 1
1477
+ const isActive = session.id === node.activeSessionId
1478
+ const statusTag = isActive ? '`[当前]`' : '-'
1479
+ const rawTitle = session.title || (session.events ? sessionLabel(session) : '')
1480
+ const titleText = formatGoalTitle(rawTitle) || (typeof rawTitle === 'string' ? rawTitle : '') || '新会话'
1481
+ const safeTitle = String(titleText).replace(/\|/g, '|').replace(/\r?\n/g, ' ')
1482
+ const when = session.createdAt ? fmtTime(session.createdAt) : '-'
1483
+ parts.push(`| **#${idx}** | ${safeTitle} | ${when} | ${statusTag} |`)
1484
+ }
1485
+ if (group.sessions.length > 20) {
1486
+ parts.push(`*…该工作区共 ${group.sessions.length} 个会话,仅显示前 20 个*`)
1487
+ }
1488
+ parts.push('')
1489
+ }
1490
+ if (all.length > 50) parts.push(`*…共 ${all.length} 个会话,仅显示前 50 个*`)
1491
+ return parts.join('\n').trim()
1492
+ }
1493
+
1494
+ // 与 renderSessions 完全一致的显示顺序:保持 listSessions 中的分组和顺序。
1495
+ // /use N 用这个数组索引,保证显示的编号 N 与切换的会话一一对应。
1496
+ function sessionsInDisplayOrder(all) {
1497
+ const groups = new Map()
1498
+ for (const s of all) {
1499
+ const key = s.cwd || '(未指定)'
1500
+ if (!groups.has(key)) groups.set(key, [])
1501
+ groups.get(key).push(s)
1502
+ }
1503
+ return [...groups.values()].flatMap((sessions) => sessions)
1504
+ }
1505
+
1506
+ // 时间戳 → 简洁可读时间 (MM-DD HH:mm 或 YYYY-MM-DD HH:mm)
1507
+ function fmtTime(ms) {
1508
+ try {
1509
+ const d = new Date(ms)
1510
+ if (isNaN(d.getTime())) return ''
1511
+ const p = (n) => String(n).padStart(2, '0')
1512
+ const now = new Date()
1513
+ const isSameYear = d.getFullYear() === now.getFullYear()
1514
+ const datePart = isSameYear ? `${p(d.getMonth() + 1)}-${p(d.getDate())}` : `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
1515
+ return `${datePart} ${p(d.getHours())}:${p(d.getMinutes())}`
1516
+ } catch { return '' }
1517
+ }
1518
+
1519
+ // 提取精简会话短 ID(去除冗余 session- 前缀,保留 8 位短标识)
1520
+ function fmtSessionId(id) {
1521
+ if (!id) return ''
1522
+ const clean = String(id).replace(/^session-/, '')
1523
+ return clean.length > 8 ? clean.slice(0, 8) : clean
1524
+ }
1525
+
1526
+ function helpText() {
1527
+ return [
1528
+ '## 🤖 常用指令帮助',
1529
+ '',
1530
+ '### 💬 会话控制',
1531
+ '| 指令 | 说明 | 示例 |',
1532
+ '| :--- | :--- | :--- |',
1533
+ '| `/sessions` | 查看所有会话表格列表 | `/sessions` 或 `/list` |',
1534
+ '| `/use <编号>` | 切换到指定编号会话 | `/use 1` 或 `/resume 1` |',
1535
+ '| `/new <提示词>` | 在当前工作区新建会话 | `/new 帮我写个脚本` |',
1536
+ '| `/new <词> @N` | 在指定工作区新建会话 | `/new 帮我写个脚本 @1` |',
1537
+ '| `/rename <新标题>` | 重命名当前活动会话 | `/rename 优化登录交互` |',
1538
+ '| `/stop` | 中断停止当前正在执行的任务 | `/stop` |',
1539
+ '| `/end` | 结束当前会话(回到空闲) | `/end` |',
1540
+ '',
1541
+ '### 📁 环境与状态',
1542
+ '| 指令 | 说明 |',
1543
+ '| :--- | :--- |',
1544
+ '| `/workspaces` | 查看可用工作区表格列表 |',
1545
+ '| `/addworkspace <路径>` | 注册添加新的电脑工作区目录 |',
1546
+ '| `/status` | 查看 Agent 运行状态看板 |',
1547
+ '| `/help` | 查看此帮助菜单 |',
1548
+ '',
1549
+ '### 🔐 权限确认',
1550
+ '| 指令 | 快捷数字 | 说明 |',
1551
+ '| :--- | :--- | :--- |',
1552
+ '| `/yes` | `1` | 批准当前工具执行请求 |',
1553
+ '| `/no` | `2` | 拒绝当前工具执行请求 |',
1554
+ ].join('\n')
1555
+ }
1556
+
1557
+ // 导出,便于测试与复用
1558
+ export const conversationBridgeHelpers = {
1559
+ splitForIM,
1560
+ digestLine,
1561
+ textOfAssistantMessage,
1562
+ resolveFilePath,
1563
+ extractFilePathsFromText,
1564
+ extractAndStripSendFileDirectives,
1565
+ sessionsInDisplayOrder,
1566
+ listSessions,
1567
+ renderSessions,
1568
+ listWorkspaces,
1569
+ BRIDGE_MARK,
1570
+ }