@wenbin_wb/dsh-bridge 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -77,7 +77,7 @@ var s = {
77
77
  muted: { color: "var(--dsw-alias-label-tertiary,#8b93a1)", fontSize: 12, lineHeight: 1.5 },
78
78
  label: { color: "var(--dsw-alias-label-primary,currentColor)", fontSize: 13, fontWeight: 500 },
79
79
  code: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 12, wordBreak: "break-all", color: "var(--dsw-alias-label-primary,currentColor)" },
80
- btnPri: { font: "inherit", cursor: "pointer", border: "none", background: "var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4f6ef7))", color: "#fff", height: 32, padding: "0 14px", borderRadius: 999, fontSize: 13, fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 4 },
80
+ btnPri: { font: "inherit", cursor: "pointer", border: "none", background: "var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4f6ef7))", color: "var(--dsw-alias-button-primary-label,#fff)", height: 32, padding: "0 14px", borderRadius: 999, fontSize: 13, fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 4 },
81
81
  btnGhost: { font: "inherit", cursor: "pointer", border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", background: "var(--dsw-alias-bg-layer-1,transparent)", color: "var(--dsw-alias-label-primary,currentColor)", height: 32, padding: "0 14px", borderRadius: 999, fontSize: 13, display: "inline-flex", alignItems: "center", gap: 4, textDecoration: "none" },
82
82
  btnLink: { font: "inherit", cursor: "pointer", border: "none", background: "none", color: "var(--dsw-alias-brand-primary,#4f6ef7)", fontSize: 12, padding: 0, display: "inline-flex", alignItems: "center", gap: 3, textDecoration: "none" },
83
83
  qr: { width: 200, height: 200, borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", margin: "8px 0", display: "block" },
@@ -99,11 +99,28 @@ function QrBlock({ url, qr, onReset }) {
99
99
  const [copied, setCopied] = React.useState(false);
100
100
  const [showQr, setShowQr] = React.useState(true);
101
101
  const copy = React.useCallback(() => {
102
- navigator.clipboard?.writeText(url).then(() => {
103
- setCopied(true);
104
- setTimeout(() => setCopied(false), 2e3);
105
- }).catch(() => {
106
- });
102
+ if (navigator.clipboard?.writeText) {
103
+ navigator.clipboard.writeText(url).then(() => {
104
+ setCopied(true);
105
+ setTimeout(() => setCopied(false), 2e3);
106
+ }).catch(() => fallbackCopy());
107
+ } else {
108
+ fallbackCopy();
109
+ }
110
+ function fallbackCopy() {
111
+ const textarea = document.createElement("textarea");
112
+ textarea.value = url;
113
+ textarea.style.position = "fixed";
114
+ textarea.style.opacity = "0";
115
+ document.body.appendChild(textarea);
116
+ textarea.select();
117
+ const success = document.execCommand("copy");
118
+ document.body.removeChild(textarea);
119
+ if (success) {
120
+ setCopied(true);
121
+ setTimeout(() => setCopied(false), 2e3);
122
+ }
123
+ }
107
124
  }, [url]);
108
125
  const toggleQr = React.useCallback(() => setShowQr((v) => !v), []);
109
126
  return React.createElement(
@@ -599,48 +616,50 @@ function VersionBanner({ rpcCall }) {
599
616
  "div",
600
617
  {
601
618
  style: {
602
- display: "flex",
603
- alignItems: "center",
604
- gap: 12,
605
- background: "linear-gradient(135deg, var(--dsw-alias-brand-primary,#4f6ef7) 0%, #6366f1 100%)",
606
- borderRadius: 10,
619
+ ...s.card,
620
+ background: "var(--dsw-alias-state-info-bg,#eff6ff)",
621
+ border: "1px solid var(--dsw-alias-state-info-border,#bfdbfe)",
607
622
  padding: "12px 16px",
608
- marginBottom: 8,
609
- color: "#fff"
623
+ marginBottom: 8
610
624
  }
611
625
  },
612
- React.createElement("span", { style: { fontSize: 20 } }, "\u{1F389}"),
613
626
  React.createElement(
614
627
  "div",
615
- { style: { flex: 1 } },
628
+ { style: { display: "flex", alignItems: "center", gap: 12 } },
629
+ React.createElement("span", { style: { fontSize: 20 } }, "\u{1F389}"),
616
630
  React.createElement(
617
631
  "div",
618
- { style: { fontSize: 13, fontWeight: 600 } },
619
- `\u53D1\u73B0\u65B0\u7248\u672C v${info.latest}\uFF08\u5F53\u524D v${info.current}\uFF09`
632
+ { style: { flex: 1 } },
633
+ React.createElement("div", {
634
+ style: {
635
+ fontSize: 13,
636
+ fontWeight: 600,
637
+ color: "var(--dsw-alias-state-info-primary,#1e40af)",
638
+ marginBottom: 4
639
+ }
640
+ }, `\u53D1\u73B0\u65B0\u7248\u672C v${info.latest}\uFF08\u5F53\u524D v${info.current}\uFF09`),
641
+ React.createElement("code", {
642
+ style: {
643
+ ...s.code,
644
+ fontSize: 11,
645
+ color: "var(--dsw-alias-label-secondary,#6b7280)",
646
+ display: "block"
647
+ }
648
+ }, "dsh plugin --profile web update @wenbin_wb/dsh-bridge --latest")
620
649
  ),
621
- React.createElement("code", {
622
- style: { fontSize: 11, opacity: 0.85, fontFamily: "ui-monospace,Menlo,monospace", wordBreak: "break-all" }
623
- }, "dsh plugin --profile web update @wenbin_wb/dsh-bridge --latest")
624
- ),
625
- React.createElement("button", {
626
- style: {
627
- font: "inherit",
628
- cursor: "pointer",
629
- border: "1px solid rgba(255,255,255,0.4)",
630
- background: "rgba(255,255,255,0.15)",
631
- color: "#fff",
632
- height: 30,
633
- padding: "0 12px",
634
- borderRadius: 999,
635
- fontSize: 12,
636
- display: "inline-flex",
637
- alignItems: "center",
638
- opacity: loading ? 0.5 : 1,
639
- flexShrink: 0
640
- },
641
- onClick: check,
642
- disabled: loading
643
- }, loading ? "\u2026" : "\u5237\u65B0")
650
+ React.createElement("button", {
651
+ style: {
652
+ ...s.btnGhost,
653
+ height: 30,
654
+ padding: "0 12px",
655
+ fontSize: 12,
656
+ opacity: loading ? 0.5 : 1,
657
+ flexShrink: 0
658
+ },
659
+ onClick: check,
660
+ disabled: loading
661
+ }, loading ? "\u2026" : "\u5237\u65B0")
662
+ )
644
663
  ),
645
664
  links
646
665
  );
package/client/index.js CHANGED
@@ -31,7 +31,7 @@ const s = {
31
31
  muted: { color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 12, lineHeight: 1.5 },
32
32
  label: { color: 'var(--dsw-alias-label-primary,currentColor)', fontSize: 13, fontWeight: 500 },
33
33
  code: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, wordBreak: 'break-all', color: 'var(--dsw-alias-label-primary,currentColor)' },
34
- btnPri: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4f6ef7))', color: '#fff', height: 32, padding: '0 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4 },
34
+ btnPri: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4f6ef7))', color: 'var(--dsw-alias-button-primary-label,#fff)', height: 32, padding: '0 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4 },
35
35
  btnGhost: { font: 'inherit', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,transparent)', color: 'var(--dsw-alias-label-primary,currentColor)', height: 32, padding: '0 14px', borderRadius: 999, fontSize: 13, display: 'inline-flex', alignItems: 'center', gap: 4, textDecoration: 'none' },
36
36
  btnLink: { font: 'inherit', cursor: 'pointer', border: 'none', background: 'none', color: 'var(--dsw-alias-brand-primary,#4f6ef7)', fontSize: 12, padding: 0, display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' },
37
37
  qr: { width: 200, height: 200, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', margin: '8px 0', display: 'block' },
@@ -58,10 +58,33 @@ function QrBlock({ url, qr, onReset }) {
58
58
  const [showQr, setShowQr] = React.useState(true);
59
59
 
60
60
  const copy = React.useCallback(() => {
61
- navigator.clipboard?.writeText(url).then(() => {
62
- setCopied(true);
63
- setTimeout(() => setCopied(false), 2000);
64
- }).catch(() => {});
61
+ // 优先使用现代 Clipboard API
62
+ if (navigator.clipboard?.writeText) {
63
+ navigator.clipboard.writeText(url)
64
+ .then(() => {
65
+ setCopied(true);
66
+ setTimeout(() => setCopied(false), 2000);
67
+ })
68
+ .catch(() => fallbackCopy());
69
+ } else {
70
+ fallbackCopy();
71
+ }
72
+
73
+ // 降级方案:使用 document.execCommand (HTTP 环境下 Clipboard API 不可用)
74
+ function fallbackCopy() {
75
+ const textarea = document.createElement('textarea');
76
+ textarea.value = url;
77
+ textarea.style.position = 'fixed';
78
+ textarea.style.opacity = '0';
79
+ document.body.appendChild(textarea);
80
+ textarea.select();
81
+ const success = document.execCommand('copy');
82
+ document.body.removeChild(textarea);
83
+ if (success) {
84
+ setCopied(true);
85
+ setTimeout(() => setCopied(false), 2000);
86
+ }
87
+ }
65
88
  }, [url]);
66
89
 
67
90
  const toggleQr = React.useCallback(() => setShowQr(v => !v), []);
@@ -504,34 +527,51 @@ function VersionBanner({ rpcCall }) { const [info, setInfo] = React.useState(nu
504
527
  }, '🐛 反馈 Bug'),
505
528
  );
506
529
 
507
- // 有新版本:彩色横幅
530
+ // 有新版本:信息卡片
508
531
  if (hasUpdate) {
509
532
  return React.createElement('div', { style: { marginBottom: 16 } },
510
533
  React.createElement('div', {
511
534
  style: {
512
- display: 'flex', alignItems: 'center', gap: 12,
513
- background: 'linear-gradient(135deg, var(--dsw-alias-brand-primary,#4f6ef7) 0%, #6366f1 100%)',
514
- borderRadius: 10, padding: '12px 16px', marginBottom: 8, color: '#fff',
535
+ ...s.card,
536
+ background: 'var(--dsw-alias-state-info-bg,#eff6ff)',
537
+ border: '1px solid var(--dsw-alias-state-info-border,#bfdbfe)',
538
+ padding: '12px 16px',
539
+ marginBottom: 8,
515
540
  },
516
541
  },
517
- React.createElement('span', { style: { fontSize: 20 } }, '🎉'),
518
- React.createElement('div', { style: { flex: 1 } },
519
- React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } },
520
- `发现新版本 v${info.latest}(当前 v${info.current})`
542
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 12 } },
543
+ React.createElement('span', { style: { fontSize: 20 } }, '🎉'),
544
+ React.createElement('div', { style: { flex: 1 } },
545
+ React.createElement('div', {
546
+ style: {
547
+ fontSize: 13,
548
+ fontWeight: 600,
549
+ color: 'var(--dsw-alias-state-info-primary,#1e40af)',
550
+ marginBottom: 4,
551
+ },
552
+ }, `发现新版本 v${info.latest}(当前 v${info.current})`),
553
+ React.createElement('code', {
554
+ style: {
555
+ ...s.code,
556
+ fontSize: 11,
557
+ color: 'var(--dsw-alias-label-secondary,#6b7280)',
558
+ display: 'block',
559
+ },
560
+ }, 'dsh plugin --profile web update @wenbin_wb/dsh-bridge --latest'),
521
561
  ),
522
- React.createElement('code', {
523
- style: { fontSize: 11, opacity: 0.85, fontFamily: 'ui-monospace,Menlo,monospace', wordBreak: 'break-all' },
524
- }, 'dsh plugin --profile web update @wenbin_wb/dsh-bridge --latest'),
562
+ React.createElement('button', {
563
+ style: {
564
+ ...s.btnGhost,
565
+ height: 30,
566
+ padding: '0 12px',
567
+ fontSize: 12,
568
+ opacity: loading ? 0.5 : 1,
569
+ flexShrink: 0,
570
+ },
571
+ onClick: check,
572
+ disabled: loading,
573
+ }, loading ? '…' : '刷新'),
525
574
  ),
526
- React.createElement('button', {
527
- style: {
528
- font: 'inherit', cursor: 'pointer', border: '1px solid rgba(255,255,255,0.4)',
529
- background: 'rgba(255,255,255,0.15)', color: '#fff', height: 30, padding: '0 12px',
530
- borderRadius: 999, fontSize: 12, display: 'inline-flex', alignItems: 'center',
531
- opacity: loading ? 0.5 : 1, flexShrink: 0,
532
- },
533
- onClick: check, disabled: loading,
534
- }, loading ? '…' : '刷新'),
535
575
  ),
536
576
  links,
537
577
  );
@@ -310,7 +310,10 @@ export class WechatGateway extends Service {
310
310
  rateLimitCircuitOpenMs: config.rateLimitCircuitOpenMs ?? 30_000,
311
311
  rateLimitCircuitWindowMs: config.rateLimitCircuitWindowMs ?? 30_000,
312
312
  rateLimitCircuitThreshold: config.rateLimitCircuitThreshold ?? 1,
313
- allowCdnHosts: config.allowCdnHosts ?? [...DEFAULT_CDN_ALLOWLIST],
313
+ allowCdnHosts: [
314
+ ...DEFAULT_CDN_ALLOWLIST,
315
+ ...(config.allowCdnHosts ?? [])
316
+ ].filter((v, i, a) => a.indexOf(v) === i), // 去重
314
317
  }
315
318
  this.syncBuf = ''
316
319
  this.pollTask = null
@@ -322,6 +325,32 @@ export class WechatGateway extends Service {
322
325
  this.rateLimitHits = []
323
326
  this.rateLimitUntil = 0
324
327
  this._disposed = false
328
+ // 内存泄漏防护:每 5 分钟清理过期缓存
329
+ this.cleanupInterval = setInterval(() => this._cleanupMaps(), 300_000)
330
+ }
331
+
332
+ _cleanupMaps() {
333
+ const now = Date.now()
334
+ // 清理 contextTokens:超过 1 小时未使用的删除
335
+ const contextTokenTtl = 3600_000
336
+ // 清理 typingTickets:超过 30 秒的删除
337
+ const typingTicketTtl = 30_000
338
+
339
+ // contextTokens 没有时间戳,保守策略:如果 Map 过大才清理(超过 100 个)
340
+ if (this.contextTokens.size > 100) {
341
+ this.logger?.warn(`contextTokens Map 过大 (${this.contextTokens.size}),清理旧数据`)
342
+ // 保留最近 50 个,删除其余
343
+ const entries = Array.from(this.contextTokens.entries())
344
+ this.contextTokens.clear()
345
+ entries.slice(-50).forEach(([k, v]) => this.contextTokens.set(k, v))
346
+ }
347
+
348
+ // 清理 typingTickets
349
+ for (const [peerId, ticket] of this.typingTickets) {
350
+ if (now - ticket.at > typingTicketTtl) {
351
+ this.typingTickets.delete(peerId)
352
+ }
353
+ }
325
354
  }
326
355
 
327
356
  // ---- 状态访问器 ----------------------------------------------------------
@@ -337,6 +366,10 @@ export class WechatGateway extends Service {
337
366
  dispose() {
338
367
  this._disposed = true
339
368
  this.stopPollingLocal = true
369
+ if (this.cleanupInterval) {
370
+ clearInterval(this.cleanupInterval)
371
+ this.cleanupInterval = null
372
+ }
340
373
  void this.stop()
341
374
  }
342
375
 
@@ -14,6 +14,7 @@
14
14
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
15
15
  import { randomUUID } from 'node:crypto'
16
16
  import { statSync } from 'node:fs'
17
+ import { resolve, normalize } from 'node:path'
17
18
  import { gatewayConstants } from './gateway.js'
18
19
 
19
20
  const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
@@ -117,6 +118,13 @@ function packBlocks(blocks, max) {
117
118
  }
118
119
 
119
120
  function splitForWechat(content, max = MAX_MESSAGE_CHARS) {
121
+ // 安全检查:防止畸形输入导致无限循环或崩溃
122
+ if (typeof content !== 'string' || content.length === 0) return []
123
+ if (content.length > 1_000_000) {
124
+ // 超过 1MB 文本,截断并警告
125
+ content = content.slice(0, 1_000_000) + '\n\n[已截断:内容过长]'
126
+ }
127
+
120
128
  const normalized = normalizeMarkdownBlocks(content)
121
129
  if (!normalized) return []
122
130
  if (normalized.length <= max) return [normalized]
@@ -252,7 +260,9 @@ export class WechatConversationNode {
252
260
  }
253
261
 
254
262
  isAllowed(senderId) {
255
- return Array.isArray(this.config.allowFrom) && this.config.allowFrom.includes(senderId)
263
+ if (!Array.isArray(this.config.allowFrom)) return false
264
+ if (this.config.allowFrom.length === 0) return false
265
+ return this.config.allowFrom.includes(senderId)
256
266
  }
257
267
 
258
268
  setActiveSession(session) {
@@ -279,12 +289,28 @@ export class WechatConversationNode {
279
289
  const sessionId = `session-${randomUUID()}`
280
290
  try {
281
291
  const cwd = cwdOverride || this.config.cwd || process.cwd()
282
- // 校验指定目录存在且是目录
292
+ // 校验指定目录存在且是目录,防止路径遍历
283
293
  if (cwdOverride) {
294
+ // 规范化路径并检查是否在工作区白名单内(必须完全匹配)
295
+ const resolvedCwd = normalize(resolve(cwdOverride))
296
+ const workspaces = await this.ctx.workspaceRegistry.list()
297
+ const allowedPaths = workspaces.map(w => normalize(resolve(w.path)))
298
+
299
+ if (!allowedPaths.includes(resolvedCwd)) {
300
+ const wsDisplay = allowedPaths.slice(0, 5).join('\n ')
301
+ const more = allowedPaths.length > 5 ? `\n ... 等 ${allowedPaths.length} 个工作区` : ''
302
+ await sendTextToPeer(this,
303
+ `${MARK.err} 路径不在已注册工作区内: ${resolvedCwd}\n\n` +
304
+ `可用工作区:\n ${wsDisplay}${more}\n\n` +
305
+ `提示:使用 /workspaces 查看完整列表`
306
+ )
307
+ return
308
+ }
309
+
284
310
  let ok = false
285
- try { ok = statSync(cwd).isDirectory() } catch { ok = false }
311
+ try { ok = statSync(resolvedCwd).isDirectory() } catch { ok = false }
286
312
  if (!ok) {
287
- await sendTextToPeer(this, `${MARK.err} 工作区目录不存在: ${cwd}`)
313
+ await sendTextToPeer(this, `${MARK.err} 工作区目录不存在: ${resolvedCwd}`)
288
314
  return
289
315
  }
290
316
  }
@@ -338,6 +364,16 @@ export class WechatConversationNode {
338
364
  }
339
365
  }
340
366
 
367
+ // 取消并拒绝审批(用于 dispose 清理)
368
+ cancelApproval(number) {
369
+ const entry = this.pending.get(number)
370
+ if (entry) {
371
+ clearTimeout(entry.timer)
372
+ this.pending.delete(number)
373
+ entry.resolve('rejected') // 触发 Promise,防止泄漏
374
+ }
375
+ }
376
+
341
377
  resolveApproval(text) {
342
378
  const entries = [...this.pending.entries()]
343
379
  if (entries.length === 0) return false
@@ -346,12 +382,16 @@ export class WechatConversationNode {
346
382
  else if (text === '/no') outcome = 'rejected'
347
383
  if (outcome) {
348
384
  const [number, entry] = entries[entries.length - 1]
385
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
386
+ if (!this.pending.has(number)) return false
349
387
  this.clearApproval(number)
350
388
  entry.resolve(outcome)
351
389
  return true
352
390
  }
353
391
  if ((text === '1' || text === '2') && entries.length === 1) {
354
392
  const [number, entry] = entries[0]
393
+ // 双重检查:确保此 number 仍在 pending 中(防止超时竞态)
394
+ if (!this.pending.has(number)) return false
355
395
  this.clearApproval(number)
356
396
  entry.resolve(text === '1' ? 'allowed-once' : 'rejected')
357
397
  return true
@@ -364,7 +404,7 @@ export class WechatConversationNode {
364
404
  try { disposer() } catch { /* 忽略 */ }
365
405
  }
366
406
  this.disposers = []
367
- for (const number of [...this.pending.keys()]) this.clearApproval(number)
407
+ for (const number of [...this.pending.keys()]) this.cancelApproval(number)
368
408
  }
369
409
 
370
410
  // ---- 入站 ----
@@ -386,11 +426,18 @@ export class WechatConversationNode {
386
426
  if (text0.trim()) {
387
427
  this.config.allowFrom = [sender]
388
428
  this.logger?.info?.(`[dsh-bridge wechat] auto-approved first sender ${sender} into allowlist (scan onboarding)`)
389
- try { await this.onFirstSender?.(sender) } catch { /* 持久化失败不致命 */ }
429
+ try {
430
+ await this.onFirstSender?.(sender)
431
+ } catch (err) {
432
+ this.logger?.warn?.(`[dsh-bridge wechat] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
433
+ }
390
434
  } else {
391
435
  this.logger?.info?.(`[dsh-bridge wechat] media-only first message from ${sender} not auto-approved (waiting for text)`)
392
436
  }
393
- } else {
437
+ }
438
+
439
+ // 如果仍未通过白名单,拒绝处理(防止绕过白名单)
440
+ if (!this.isAllowed(sender)) {
394
441
  this.logger?.info?.(`[dsh-bridge wechat] ignore message from non-allowlisted sender ${sender} (never fed to model)`)
395
442
  return
396
443
  }
@@ -495,6 +542,8 @@ export class WechatConversationNode {
495
542
  } catch (err) {
496
543
  const reason = err instanceof Error ? err.message : String(err)
497
544
  this.logger?.warn?.(`[dsh-bridge wechat] failed to re-attach agent: ${reason}`)
545
+ // 清除失效的 activeSessionId,避免用户误以为还在旧会话中
546
+ this.activeSessionId = null
498
547
  await sendTextToPeer(this, `${MARK.err} 恢复会话失败: ${reason}。发送 /new <提示词> 新建一个会话。`)
499
548
  }
500
549
  }
@@ -747,8 +796,10 @@ export class WechatConversationNode {
747
796
 
748
797
  void sendTextToPeer(this, prompt)
749
798
 
799
+ let timeoutFired = false
750
800
  const outcome = await new Promise((resolve) => {
751
801
  const timer = setTimeout(() => {
802
+ timeoutFired = true
752
803
  this.clearApproval(number)
753
804
  resolve('rejected')
754
805
  }, timeoutSec * 1000)
@@ -756,8 +807,11 @@ export class WechatConversationNode {
756
807
  this.registerApproval(number, { number, request: req, resolve, timer })
757
808
  })
758
809
 
759
- const label = outcome === 'allowed-once' ? `${MARK.ok} 已同意` : outcome === 'rejected' ? `${MARK.err} 已拒绝` : `[${outcome}]`
760
- void sendTextToPeer(this, `${label}(#${number})`)
810
+ // 仅在非超时路径发送确认消息(超时时 resolve 已经发生在 timer 回调)
811
+ if (!timeoutFired) {
812
+ const label = outcome === 'allowed-once' ? `${MARK.ok} 已同意` : outcome === 'rejected' ? `${MARK.err} 已拒绝` : `[${outcome}]`
813
+ void sendTextToPeer(this, `${label}(#${number})`)
814
+ }
761
815
  return outcome
762
816
  }
763
817
  const disposer = this.ctx.on('approval/request', listener)
@@ -867,9 +921,9 @@ async function listSessions(node) {
867
921
  }
868
922
 
869
923
  // 列出可用工作区:使用 DSH 官方 workspaceRegistry。返回 [{title, path}]。
870
- function listWorkspaces(node) {
924
+ async function listWorkspaces(node) {
871
925
  try {
872
- const list = node.ctx.workspaceRegistry?.list?.() ?? []
926
+ const list = await node.ctx.workspaceRegistry?.list?.() ?? []
873
927
  const out = []
874
928
  for (const ws of list) {
875
929
  if (ws && ws.path) out.push({ title: ws.title ?? ws.path, path: ws.path })
@@ -898,7 +952,7 @@ async function routeCommand(node, text) {
898
952
  return true
899
953
  case 'use': {
900
954
  const index = Number(rest[0])
901
- const sessions = await listSessions(node)
955
+ const sessions = sessionsInDisplayOrder(await listSessions(node))
902
956
  if (!Number.isInteger(index) || index < 1 || index > sessions.length) {
903
957
  await sendTextToPeer(node, `${MARK.err} 无效编号。可用: 1–${sessions.length}(/sessions 查看列表)`)
904
958
  return true
@@ -909,7 +963,7 @@ async function routeCommand(node, text) {
909
963
  return true
910
964
  }
911
965
  case 'workspaces': {
912
- const workspaces = listWorkspaces(node)
966
+ const workspaces = await listWorkspaces(node)
913
967
  if (workspaces.length === 0) {
914
968
  await sendTextToPeer(node, `${MARK.list} 没有可用的工作区。使用 /new <提示词> @<路径> 指定一个目录。`)
915
969
  return true
@@ -930,14 +984,27 @@ async function routeCommand(node, text) {
930
984
  if (atMatch) {
931
985
  prompt = args.slice(0, atMatch.index).trim()
932
986
  const sel = atMatch[1]
933
- const workspaces = listWorkspaces(node)
987
+ const workspaces = await listWorkspaces(node)
934
988
  if (/^\d+$/.test(sel)) {
935
989
  const idx = Number(sel)
936
990
  const ws = workspaces[idx - 1]
937
991
  if (ws) cwd = ws.path
938
992
  else { await sendTextToPeer(node, `${MARK.err} 无效工作区编号 ${sel}。用 /workspaces 查看。`); return true }
939
993
  } else {
940
- cwd = sel
994
+ // 直接指定路径时,规范化并校验(必须完全匹配已注册工作区)
995
+ const normalized = normalize(resolve(sel))
996
+ const allowedPaths = workspaces.map(w => normalize(resolve(w.path)))
997
+ if (!allowedPaths.includes(normalized)) {
998
+ const wsDisplay = allowedPaths.slice(0, 5).join('\n ')
999
+ const more = allowedPaths.length > 5 ? `\n ... 等 ${allowedPaths.length} 个工作区` : ''
1000
+ await sendTextToPeer(node,
1001
+ `${MARK.err} 路径不在已注册工作区内: ${normalized}\n\n` +
1002
+ `可用工作区:\n ${wsDisplay}${more}\n\n` +
1003
+ `提示:使用 /workspaces 查看完整列表`
1004
+ )
1005
+ return true
1006
+ }
1007
+ cwd = normalized
941
1008
  }
942
1009
  }
943
1010
  await node.createSession(prompt, cwd)
@@ -1017,6 +1084,19 @@ async function renderSessions(node) {
1017
1084
  return `${MARK.list}\n${parts.join('\n')}`
1018
1085
  }
1019
1086
 
1087
+ // 与 renderSessions 完全一致的显示顺序:按工作区字母序分组、组内保持 listSessions 顺序。
1088
+ // /use N 用这个数组索引,保证显示的编号 N 与切换的会话一一对应。
1089
+ function sessionsInDisplayOrder(all) {
1090
+ const groups = new Map()
1091
+ for (const s of all) {
1092
+ const key = s.cwd || '(未指定)'
1093
+ if (!groups.has(key)) groups.set(key, [])
1094
+ groups.get(key).push(s)
1095
+ }
1096
+ const sortedGroups = [...groups.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])))
1097
+ return sortedGroups.flatMap(([, sessions]) => sessions)
1098
+ }
1099
+
1020
1100
  // 时间戳 → 可读时间
1021
1101
  function fmtTime(ms) {
1022
1102
  try {
@@ -1056,6 +1136,7 @@ export const wechatNodeHelpers = {
1056
1136
  extractText,
1057
1137
  isGroupMessage,
1058
1138
  listSessions,
1139
+ sessionsInDisplayOrder,
1059
1140
  }
1060
1141
 
1061
1142
  function sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",