@wenbin_wb/dsh-bridge 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -5,9 +5,9 @@
5
5
  // 2. Cloudflare 隧道(一键获取公网地址)
6
6
  // 3. 自建隧道(WebSocket 反向隧道 + Token 认证)
7
7
 
8
- import { createServer, request as httpRequest } from 'node:http';
8
+ import { createServer, request as httpRequest, get as httpGet } from 'node:http';
9
9
  import { get as httpsGet } from 'node:https';
10
- import { networkInterfaces, homedir } from 'node:os';
10
+ import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
11
11
  import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { readFileSync } from 'node:fs';
@@ -452,12 +452,14 @@ class ProxyServer {
452
452
  * Bridge Service
453
453
  */
454
454
  class BridgeService {
455
- constructor({ dshPort, proxyPort, home, customTunnelConfig, authManager, logger }) {
455
+ constructor({ dshPort, proxyPort, home, cloudflaredConfig, customTunnelConfig, authManager, onPersist, logger }) {
456
456
  this.dshPort = dshPort;
457
457
  this.proxyPort = proxyPort;
458
458
  this.home = home;
459
+ this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
459
460
  this.customTunnelConfig = customTunnelConfig ?? null;
460
461
  this.authManager = authManager ?? null;
462
+ this.onPersist = onPersist ?? null;
461
463
  this.logger = logger;
462
464
 
463
465
  this.qrCache = new QrCache();
@@ -539,6 +541,10 @@ class BridgeService {
539
541
  ? await this.qrCache.get(cloudflaredUrl)
540
542
  : null,
541
543
  state: this.cloudflaredState,
544
+ tokenConfigured: !!this.cloudflaredConfig?.token,
545
+ token: adminAuthValid ? (this.cloudflaredConfig?.token || '') : (this.cloudflaredConfig?.token ? '******' : ''),
546
+ hostname: this.cloudflaredConfig?.hostname || '',
547
+ autoStart: Boolean(this.cloudflaredConfig?.autoStart),
542
548
  },
543
549
 
544
550
  customTunnel: {
@@ -551,14 +557,44 @@ class BridgeService {
551
557
  ? await this.qrCache.get(customUrl)
552
558
  : null,
553
559
  state: this.customTunnelState,
560
+ autoStart: Boolean(this.customTunnelConfig?.autoStart),
554
561
  },
555
562
 
556
563
  // 轻量摘要,供 UI Tab 状态点使用(完整状态由 wechatGetStatus 提供)
557
564
  wechat: this.wechat ? { status: this.wechat.gateway?.status ?? 'idle' } : null,
565
+
566
+ // 宿主系统运行监控指标
567
+ system: this.getSystemMetrics(),
568
+ };
569
+ }
570
+
571
+ async saveCloudflaredConfig({ token, hostname }) {
572
+ this.cloudflaredConfig = {
573
+ ...(this.cloudflaredConfig ?? {}),
574
+ token: token ? String(token).trim() : '',
575
+ hostname: hostname ? String(hostname).trim() : '',
558
576
  };
577
+ await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
559
578
  }
560
579
 
561
- async startCustomTunnel() {
580
+ async setTunnelAutoStart({ tunnel, autoStart }) {
581
+ const isAuto = Boolean(autoStart);
582
+ if (tunnel === 'cloudflared') {
583
+ this.cloudflaredConfig = {
584
+ ...(this.cloudflaredConfig ?? {}),
585
+ autoStart: isAuto,
586
+ };
587
+ await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
588
+ } else if (tunnel === 'customTunnel' || tunnel === 'custom') {
589
+ this.customTunnelConfig = {
590
+ ...(this.customTunnelConfig ?? {}),
591
+ autoStart: isAuto,
592
+ };
593
+ await this.onPersist?.({ customTunnel: this.customTunnelConfig });
594
+ }
595
+ }
596
+
597
+ async startCustomTunnel({ autoStart = true } = {}) {
562
598
  if (this.customTunnel) {
563
599
  throw new Error('自建隧道已在运行');
564
600
  }
@@ -567,9 +603,15 @@ class BridgeService {
567
603
  const accessToken = this.customTunnelConfig?.accessToken;
568
604
 
569
605
  if (!serverUrl || !accessToken) {
570
- throw new Error('缺少配置:请在 cordis.yml 中配置 customTunnel.serverUrl 和 customTunnel.accessToken');
606
+ throw new Error('缺少配置:请在控制台配置 customTunnel.serverUrl 和 customTunnel.accessToken');
571
607
  }
572
608
 
609
+ this.customTunnelConfig = {
610
+ ...(this.customTunnelConfig ?? {}),
611
+ autoStart: Boolean(autoStart),
612
+ };
613
+ await this.onPersist?.({ customTunnel: this.customTunnelConfig });
614
+
573
615
  this.customTunnel = new CustomTunnelClient({
574
616
  serverUrl,
575
617
  accessToken,
@@ -584,23 +626,36 @@ class BridgeService {
584
626
  await this.customTunnel.connect();
585
627
  }
586
628
 
587
- stopCustomTunnel() {
629
+ async stopCustomTunnel() {
588
630
  if (this.customTunnel) {
589
631
  this.customTunnel.disconnect();
590
632
  this.customTunnel = null;
591
633
  this.customTunnelState = { phase: 'idle', detail: '' };
592
634
  }
635
+ this.customTunnelConfig = {
636
+ ...(this.customTunnelConfig ?? {}),
637
+ autoStart: false,
638
+ };
639
+ await this.onPersist?.({ customTunnel: this.customTunnelConfig });
593
640
  }
594
641
 
595
- async startCloudflared() {
642
+ async startCloudflared({ autoStart = true } = {}) {
596
643
  if (this.cloudflared) {
597
644
  throw new Error('Cloudflare 隧道已在运行');
598
645
  }
599
646
 
647
+ this.cloudflaredConfig = {
648
+ ...(this.cloudflaredConfig ?? {}),
649
+ autoStart: Boolean(autoStart),
650
+ };
651
+ await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
652
+
600
653
  this.cloudflaredState = { phase: 'connecting', detail: '正在初始化...' };
601
654
  this.cloudflared = new CloudflaredManager({
602
655
  port: this.proxyPort,
603
656
  home: this.home,
657
+ token: this.cloudflaredConfig?.token,
658
+ hostname: this.cloudflaredConfig?.hostname,
604
659
  onStateChange: (state) => {
605
660
  this.cloudflaredState = state;
606
661
  // 出错时自动清理,让用户可以重新开启
@@ -615,17 +670,22 @@ class BridgeService {
615
670
  this.cloudflared.start();
616
671
  }
617
672
 
618
- stopCloudflared() {
673
+ async stopCloudflared() {
619
674
  if (this.cloudflared) {
620
675
  this.cloudflared.stop();
621
676
  this.cloudflared = null;
622
677
  this.cloudflaredState = { phase: 'idle', detail: '' };
623
678
  }
679
+ this.cloudflaredConfig = {
680
+ ...(this.cloudflaredConfig ?? {}),
681
+ autoStart: false,
682
+ };
683
+ await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
624
684
  }
625
685
 
626
686
  // 重置 Cloudflare 隧道:关闭隧道 + 删除已下载的 cloudflared 二进制
627
687
  async resetCloudflared() {
628
- this.stopCloudflared();
688
+ await this.stopCloudflared();
629
689
  const binDir = join(this.home ?? join(homedir(), '.dsh-bridge'), 'bin');
630
690
  const candidates = ['cloudflared.exe', 'cloudflared'];
631
691
  for (const name of candidates) {
@@ -730,6 +790,214 @@ class BridgeService {
730
790
  return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
731
791
  }
732
792
 
793
+ // 优雅重启 DSH 服务(支持守护进程自动拉起或独立派生子进程重启)
794
+ async restartDsh() {
795
+ this.logger?.info('收到 DSH 重启请求,正在调度重启...');
796
+ setTimeout(() => {
797
+ try {
798
+ if (process.env.DSH_DAEMON || process.env.PM2_HOME) {
799
+ process.exit(0);
800
+ } else {
801
+ // 常规 Node/CLI 模式:派生与当前参数一致的独立后台子进程并退出当前进程
802
+ const child = spawn(process.execPath, process.argv.slice(1), {
803
+ cwd: process.cwd(),
804
+ env: process.env,
805
+ detached: true,
806
+ stdio: 'ignore',
807
+ windowsHide: false,
808
+ });
809
+ child.unref();
810
+ process.exit(0);
811
+ }
812
+ } catch (err) {
813
+ this.logger?.error('派生重启进程失败: %s,执行直接退出', err.message);
814
+ process.exit(0);
815
+ }
816
+ }, 600);
817
+
818
+ return { ok: true, message: 'DSH 服务正在重启中,前端将在几秒后自动重新连接…' };
819
+ }
820
+
821
+ getSystemMetrics() {
822
+ try {
823
+ const totalMem = totalmem();
824
+ const freeMem = freemem();
825
+ const usedMem = totalMem - freeMem;
826
+ const memUsage = process.memoryUsage();
827
+ const cpusList = cpus() || [];
828
+ const cpuCount = cpusList.length;
829
+ const cpuModel = cpusList[0]?.model || 'Generic CPU';
830
+
831
+ return {
832
+ os: {
833
+ platform: platform(),
834
+ arch: arch(),
835
+ release: release(),
836
+ hostname: hostname(),
837
+ nodeVersion: process.version,
838
+ },
839
+ uptime: {
840
+ processSec: Math.floor(process.uptime()),
841
+ systemSec: Math.floor(uptime()),
842
+ },
843
+ cpu: {
844
+ model: cpuModel,
845
+ cores: cpuCount,
846
+ loadAvg: typeof loadavg === 'function' ? loadavg() : [0, 0, 0],
847
+ },
848
+ memory: {
849
+ totalBytes: totalMem,
850
+ freeBytes: freeMem,
851
+ usedBytes: usedMem,
852
+ usedPercent: Math.round((usedMem / totalMem) * 100),
853
+ processHeapUsed: memUsage.heapUsed,
854
+ processRss: memUsage.rss,
855
+ },
856
+ };
857
+ } catch {
858
+ return null;
859
+ }
860
+ }
861
+
862
+ async diagnoseNetwork() {
863
+ const results = [];
864
+
865
+ // 1. 本地代理端口检测
866
+ results.push({
867
+ item: 'local_proxy',
868
+ name: `本地反向代理端口 (${this.proxyPort})`,
869
+ status: this.proxy ? 'pass' : 'fail',
870
+ detail: this.proxy ? `正常运行中 (代理目标端口: ${this.dshPort})` : '代理未启动',
871
+ });
872
+
873
+ // 2. 局域网网卡检测
874
+ const lanIp = selectLanIPv4();
875
+ results.push({
876
+ item: 'lan_interface',
877
+ name: '局域网 IP 分配与可用性',
878
+ status: lanIp ? 'pass' : 'warn',
879
+ detail: lanIp ? `检测到有效局域网 IPv4: ${lanIp}` : '未检测到活跃局域网 IPv4 地址 (可能未连接 Wi-Fi/以太网)',
880
+ });
881
+
882
+ // 3. Cloudflare 边缘连通性测试
883
+ const cfStart = Date.now();
884
+ try {
885
+ await new Promise((resolve, reject) => {
886
+ const req = httpsGet('https://1.1.1.1', { timeout: 3500 }, (res) => {
887
+ res.resume();
888
+ resolve();
889
+ });
890
+ req.on('error', reject);
891
+ req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (3.5s)')); });
892
+ });
893
+ const cfLatency = Date.now() - cfStart;
894
+ results.push({
895
+ item: 'cloudflare_edge',
896
+ name: 'Cloudflare Anycast 边缘网络',
897
+ status: 'pass',
898
+ latencyMs: cfLatency,
899
+ detail: `连接畅通 (延迟 ${cfLatency}ms)`,
900
+ });
901
+ } catch (err) {
902
+ results.push({
903
+ item: 'cloudflare_edge',
904
+ name: 'Cloudflare Anycast 边缘网络',
905
+ status: 'warn',
906
+ detail: `连接异常: ${err.message} (临时公网隧道可能受阻)`,
907
+ });
908
+ }
909
+
910
+ // 4. 国内 npm 高速镜像源 (npmmirror)
911
+ const npmStart = Date.now();
912
+ try {
913
+ await new Promise((resolve, reject) => {
914
+ const req = httpsGet('https://registry.npmmirror.com', { timeout: 3500 }, (res) => {
915
+ res.resume();
916
+ resolve();
917
+ });
918
+ req.on('error', reject);
919
+ req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (3.5s)')); });
920
+ });
921
+ const npmLatency = Date.now() - npmStart;
922
+ results.push({
923
+ item: 'npmmirror',
924
+ name: '国内 npm 高速镜像源 (npmmirror)',
925
+ status: 'pass',
926
+ latencyMs: npmLatency,
927
+ detail: `连接畅通 (延迟 ${npmLatency}ms)`,
928
+ });
929
+ } catch (err) {
930
+ results.push({
931
+ item: 'npmmirror',
932
+ name: '国内 npm 高速镜像源 (npmmirror)',
933
+ status: 'warn',
934
+ detail: `连接超时或异常: ${err.message}`,
935
+ });
936
+ }
937
+
938
+ // 5. 自建隧道部署服务器连通性检测
939
+ const customServerUrl = this.customTunnelConfig?.serverUrl?.trim();
940
+ if (customServerUrl) {
941
+ const isRunning = Boolean(this.customTunnelClient?.running);
942
+ const ctStart = Date.now();
943
+ try {
944
+ const parsedUrl = new URL(customServerUrl);
945
+ const isSecure = parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'wss:';
946
+ const getter = isSecure ? httpsGet : httpGet;
947
+ const probeUrl = new URL(customServerUrl);
948
+ probeUrl.protocol = isSecure ? 'https:' : 'http:';
949
+
950
+ await new Promise((resolve, reject) => {
951
+ const req = getter(probeUrl.toString(), { timeout: 4000 }, (res) => {
952
+ res.resume();
953
+ resolve();
954
+ });
955
+ req.on('error', reject);
956
+ req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (4.0s)')); });
957
+ });
958
+ const ctLatency = Date.now() - ctStart;
959
+ results.push({
960
+ item: 'custom_tunnel_server',
961
+ name: `自建隧道部署服务器 (${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ''})`,
962
+ status: 'pass',
963
+ latencyMs: ctLatency,
964
+ detail: `服务器连通良好 (延迟 ${ctLatency}ms · 状态: ${isRunning ? '客户端在线运行中' : '待连接/就绪'})`,
965
+ });
966
+ } catch (err) {
967
+ if (isRunning) {
968
+ results.push({
969
+ item: 'custom_tunnel_server',
970
+ name: `自建隧道部署服务器 (${customServerUrl})`,
971
+ status: 'pass',
972
+ detail: '客户端在线运行中 (WebSocket 通道已建立)',
973
+ });
974
+ } else {
975
+ results.push({
976
+ item: 'custom_tunnel_server',
977
+ name: `自建隧道部署服务器 (${customServerUrl})`,
978
+ status: 'warn',
979
+ detail: `无法连通自建服务器: ${err.message}`,
980
+ });
981
+ }
982
+ }
983
+ } else {
984
+ results.push({
985
+ item: 'custom_tunnel_server',
986
+ name: '自建隧道部署服务器',
987
+ status: 'pass',
988
+ detail: '未配置自建服务器(若已部署自建隧道可在「公网隧道」中配置)',
989
+ });
990
+ }
991
+
992
+ const allPassed = results.every(r => r.status === 'pass');
993
+ return {
994
+ ok: true,
995
+ timestamp: new Date().toISOString(),
996
+ overall: allPassed ? 'healthy' : 'warning',
997
+ results,
998
+ };
999
+ }
1000
+
733
1001
  async dispose() {
734
1002
  this.stopCustomTunnel();
735
1003
  this.stopCloudflared();
@@ -835,15 +1103,38 @@ function apply(ctx, config = {}) {
835
1103
  proxyPort,
836
1104
  home: config.home,
837
1105
  customTunnelConfig: config.customTunnel ?? null,
1106
+ cloudflaredConfig: config.cloudflared ?? null,
838
1107
  authManager,
1108
+ onPersist: async (patch) => {
1109
+ const stored = await loadConfig();
1110
+ Object.assign(stored, patch);
1111
+ await saveConfig(stored);
1112
+ },
839
1113
  logger,
840
1114
  });
841
1115
 
842
- // 启动时读取已保存的自建隧道配置
843
- loadConfig().then((stored) => {
844
- if (stored?.customTunnel?.serverUrl) {
1116
+ // 启动时读取已保存的公网隧道配置并按需自动拉起
1117
+ loadConfig().then(async (stored) => {
1118
+ if (stored?.cloudflared) {
1119
+ service.cloudflaredConfig = stored.cloudflared;
1120
+ logger.info('dsh-bridge: loaded saved cloudflared config (autoStart=%s, tokenConfigured=%s)', Boolean(service.cloudflaredConfig.autoStart), Boolean(service.cloudflaredConfig.token));
1121
+ if (service.cloudflaredConfig.autoStart) {
1122
+ logger.info('dsh-bridge: auto-starting cloudflared tunnel...');
1123
+ service.startCloudflared({ autoStart: true }).catch((err) => {
1124
+ logger.error('dsh-bridge: cloudflared auto-start failed: %s', err?.message ?? err);
1125
+ });
1126
+ }
1127
+ }
1128
+
1129
+ if (stored?.customTunnel) {
845
1130
  service.customTunnelConfig = stored.customTunnel;
846
- logger.info('dsh-bridge: loaded saved custom tunnel config');
1131
+ logger.info('dsh-bridge: loaded saved custom tunnel config (autoStart=%s)', Boolean(service.customTunnelConfig.autoStart));
1132
+ if (service.customTunnelConfig.autoStart && service.customTunnelConfig.serverUrl) {
1133
+ logger.info('dsh-bridge: auto-starting custom tunnel...');
1134
+ service.startCustomTunnel({ autoStart: true }).catch((err) => {
1135
+ logger.error('dsh-bridge: custom tunnel auto-start failed: %s', err?.message ?? err);
1136
+ });
1137
+ }
847
1138
  }
848
1139
  }).catch(() => {});
849
1140
 
@@ -1067,6 +1358,63 @@ function apply(ctx, config = {}) {
1067
1358
  await saveConfig(stored);
1068
1359
  service.customTunnelConfig = { serverUrl, accessToken };
1069
1360
  },
1361
+ exportBackup: async () => {
1362
+ const stored = await loadConfig();
1363
+ return {
1364
+ version: VERSION,
1365
+ exportedAt: new Date().toISOString(),
1366
+ config: stored,
1367
+ };
1368
+ },
1369
+ importBackup: async (backup) => {
1370
+ if (!backup || typeof backup !== 'object' || !backup.config || typeof backup.config !== 'object') {
1371
+ throw new Error('无效的备份数据结构:缺少 config 节点');
1372
+ }
1373
+ const incoming = backup.config;
1374
+ await saveConfig(incoming);
1375
+
1376
+ // 重新载入 Auth
1377
+ if (incoming.auth) {
1378
+ if (incoming.auth.enabled != null) authManager.enabled = Boolean(incoming.auth.enabled);
1379
+ if (incoming.auth.mode) authManager.mode = incoming.auth.mode;
1380
+ if (incoming.auth.scope) authManager.scope = incoming.auth.scope;
1381
+ if (incoming.auth.adminPolicy) authManager.adminPolicy = incoming.auth.adminPolicy;
1382
+ if (incoming.auth.passwordHash) authManager.passwordHash = incoming.auth.passwordHash;
1383
+ if (incoming.auth.passwordSalt) authManager.passwordSalt = incoming.auth.passwordSalt;
1384
+ if (incoming.auth.adminPasswordHash) authManager.adminPasswordHash = incoming.auth.adminPasswordHash;
1385
+ if (incoming.auth.adminPasswordSalt) authManager.adminPasswordSalt = incoming.auth.adminPasswordSalt;
1386
+ if (incoming.auth.secretToken) authManager.secretToken = incoming.auth.secretToken;
1387
+ }
1388
+ // 重新载入 Tunnels
1389
+ if (incoming.cloudflared) {
1390
+ service.cloudflaredConfig = incoming.cloudflared;
1391
+ }
1392
+ if (incoming.customTunnel) {
1393
+ service.customTunnelConfig = incoming.customTunnel;
1394
+ }
1395
+ // 重新载入各 IM 平台白名单与配置
1396
+ if (incoming.wechat) wechat.node.config.allowFrom = incoming.wechat.allowFrom ?? [];
1397
+ if (incoming.qq) {
1398
+ qq.node.config.allowFrom = incoming.qq.allowFrom ?? [];
1399
+ if (incoming.qq.appId && incoming.qq.clientSecret) {
1400
+ qq.gateway.setCredentials({ appId: incoming.qq.appId, clientSecret: incoming.qq.clientSecret });
1401
+ }
1402
+ }
1403
+ if (incoming.feishu) {
1404
+ feishu.node.config.allowFrom = incoming.feishu.allowFrom ?? [];
1405
+ if (incoming.feishu.appId && incoming.feishu.appSecret) {
1406
+ feishu.gateway.setCredentials({ appId: incoming.feishu.appId, appSecret: incoming.feishu.appSecret });
1407
+ }
1408
+ }
1409
+ if (incoming.telegram) {
1410
+ telegram.node.config.allowFrom = incoming.telegram.allowFrom ?? [];
1411
+ if (incoming.telegram.botToken) {
1412
+ telegram.gateway.setCredentials({ botToken: incoming.telegram.botToken, proxy: incoming.telegram.proxy || '' });
1413
+ }
1414
+ }
1415
+
1416
+ return { ok: true, message: '配置已成功导入并刷新生效!' };
1417
+ },
1070
1418
  });
1071
1419
 
1072
1420
  // 代理随插件自动启动
@@ -241,6 +241,7 @@ export class ConversationBridge {
241
241
  // 配置恢复状态追踪(用于防止 handleInbound 在配置加载前处理消息)
242
242
  this._configRestored = false
243
243
  this._restoringConfig = null
244
+ this._restoringSessionMap = new Map() // sessionId -> Promise<Agent|null>
244
245
 
245
246
  this.mark = BRIDGE_MARK
246
247
  this._attachOutbound()
@@ -478,56 +479,75 @@ export class ConversationBridge {
478
479
 
479
480
  let agent = this.activeAgent()
480
481
  if (!agent && this.activeSessionId) {
481
- // agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
482
- try {
483
- const agentOptions = {}
484
- if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
485
- if (this.config.agentModel) agentOptions.model = this.config.agentModel
486
- if (!agentOptions.provider || !agentOptions.model) {
482
+ const sessionId = this.activeSessionId
483
+ if (this._restoringSessionMap.has(sessionId)) {
484
+ try {
485
+ await this._restoringSessionMap.get(sessionId)
486
+ } catch { /* 错误已在原始 Promise 中捕获 */ }
487
+ agent = this.activeAgent()
488
+ } else {
489
+ const restorePromise = (async () => {
490
+ // agent 不在内存(DSH 重启后或切换到持久化会话)→ re-attach。
487
491
  try {
488
- const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
489
- if (def) {
490
- if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
491
- if (!agentOptions.model && def.model) agentOptions.model = def.model
492
+ const agentOptions = {}
493
+ if (this.config.agentProvider) agentOptions.provider = this.config.agentProvider
494
+ if (this.config.agentModel) agentOptions.model = this.config.agentModel
495
+ if (!agentOptions.provider || !agentOptions.model) {
496
+ try {
497
+ const def = this.ctx.get?.('agentDefaultModel')?.currentSelection?.()
498
+ if (def) {
499
+ if (!agentOptions.provider && def.provider) agentOptions.provider = def.provider
500
+ if (!agentOptions.model && def.model) agentOptions.model = def.model
501
+ }
502
+ } catch { /* ignore */ }
492
503
  }
493
- } catch { /* ignore */ }
494
- }
495
504
 
496
- // 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
497
- // 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
498
- let persisted = false
499
- try {
500
- const headers = await this.ctx.sessionPersistence?.list?.()
501
- persisted = Array.isArray(headers) && headers.some((h) => h?.id === this.activeSessionId)
502
- } catch { /* 读取失败则按未持久化处理 */ }
503
-
504
- let handle
505
- if (persisted) {
506
- handle = await this.ctx.agents.resume({
507
- resumeSessionId: this.activeSessionId,
508
- agentOptions,
509
- })
510
- } else {
511
- // 读取持久化会话的 cwd 做 fallback(新建会话时用)
512
- let sessionCwd = this.config.cwd || process.cwd()
513
- const meta = {
514
- cwd: sessionCwd,
515
- agentPreset: this.config.agentPreset || 'routing-suite',
505
+ // 判断该 session 是否已持久化:已持久化 → agents.resume(从持久化加载历史恢复,
506
+ // 避免 agents.create 用空 seed 与已持久化事件冲突);未持久化 → agents.create。
507
+ let persisted = false
508
+ try {
509
+ const headers = await this.ctx.sessionPersistence?.list?.()
510
+ persisted = Array.isArray(headers) && headers.some((h) => h?.id === sessionId)
511
+ } catch { /* 读取失败则按未持久化处理 */ }
512
+
513
+ let handle
514
+ if (persisted) {
515
+ handle = await this.ctx.agents.resume({
516
+ resumeSessionId: sessionId,
517
+ agentOptions,
518
+ })
519
+ } else {
520
+ // 读取持久化会话的 cwd 做 fallback(新建会话时用)
521
+ let sessionCwd = this.config.cwd || process.cwd()
522
+ const meta = {
523
+ cwd: sessionCwd,
524
+ agentPreset: this.config.agentPreset || 'routing-suite',
525
+ }
526
+ handle = await this.ctx.agents.create({
527
+ sessionId: sessionId,
528
+ meta,
529
+ agentOptions,
530
+ })
531
+ }
532
+ const resumedAgent = handle?.agent
533
+ this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${sessionId} (${persisted ? 'resume' : 'create'})`)
534
+ return resumedAgent
535
+ } catch (err) {
536
+ const reason = err instanceof Error ? err.message : String(err)
537
+ this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
538
+ // 清除失效的 activeSessionId,避免用户误以为还在旧会话中
539
+ if (this.activeSessionId === sessionId) {
540
+ this.activeSessionId = null
541
+ }
542
+ await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
543
+ return null
516
544
  }
517
- handle = await this.ctx.agents.create({
518
- sessionId: this.activeSessionId,
519
- meta,
520
- agentOptions,
521
- })
522
- }
523
- agent = handle.agent
524
- this.logger?.info?.(`[dsh-bridge ${this.platform.id}] re-attached agent to session ${this.activeSessionId} (${persisted ? 'resume' : 'create'})`)
525
- } catch (err) {
526
- const reason = err instanceof Error ? err.message : String(err)
527
- this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to re-attach agent: ${reason}`)
528
- // 清除失效的 activeSessionId,避免用户误以为还在旧会话中
529
- this.activeSessionId = null
530
- await this.sendText(`❌ **恢复会话失败**:${reason}\n\n> 发送 \`/new <提示词>\` 可新建一个会话。`)
545
+ })().finally(() => {
546
+ this._restoringSessionMap.delete(sessionId)
547
+ })
548
+
549
+ this._restoringSessionMap.set(sessionId, restorePromise)
550
+ agent = await restorePromise
531
551
  }
532
552
  }
533
553
  if (!agent) {
@@ -908,6 +928,31 @@ async function routeCommand(node, text) {
908
928
  await node.sendText(`✓ **已切换到会话 #${index}**${titleLine}\n- **会话 ID**:\`${fmtSessionId(session.id)}\``)
909
929
  return true
910
930
  }
931
+ case 'rename': {
932
+ if (!node.activeSessionId) {
933
+ await node.sendText(`❌ **当前没有活动会话**\n\n> 请先使用 \`/sessions\` 查看会话列表并通过 \`/use 编号\` 切换到目标会话,或通过 \`/new <提示词>\` 创建新会话。`)
934
+ return true
935
+ }
936
+ const newTitle = rest.join(' ').trim()
937
+ if (!newTitle) {
938
+ await node.sendText(`❌ **缺少新标题参数**\n\n> 用法:\`/rename <新标题>\`\n> 示例:\`/rename 优化登录交互逻辑\``)
939
+ return true
940
+ }
941
+
942
+ try {
943
+ const session = node.activeSession()
944
+ if (session) {
945
+ session.title = newTitle
946
+ }
947
+ if (node.ctx.sessionPersistence?.update) {
948
+ await node.ctx.sessionPersistence.update(node.activeSessionId, { title: newTitle }).catch(() => {})
949
+ }
950
+ await node.sendText(`✓ **会话重命名成功**\n- **会话 ID**:\`${fmtSessionId(node.activeSessionId)}\`\n- **新标题**:${newTitle}`)
951
+ } catch (err) {
952
+ await node.sendText(`❌ **重命名失败**:${err instanceof Error ? err.message : String(err)}`)
953
+ }
954
+ return true
955
+ }
911
956
  case 'workspaces': {
912
957
  const workspaces = await listWorkspaces(node)
913
958
  if (workspaces.length === 0) {
@@ -1125,6 +1170,7 @@ function helpText() {
1125
1170
  '| `/use <编号>` | 切换到指定编号会话 | `/use 1` 或 `/resume 1` |',
1126
1171
  '| `/new <提示词>` | 在当前工作区新建会话 | `/new 帮我写个脚本` |',
1127
1172
  '| `/new <词> @N` | 在指定工作区新建会话 | `/new 帮我写个脚本 @1` |',
1173
+ '| `/rename <新标题>` | 重命名当前活动会话 | `/rename 优化登录交互` |',
1128
1174
  '| `/stop` | 中断停止当前正在执行的任务 | `/stop` |',
1129
1175
  '| `/end` | 结束当前会话(回到空闲) | `/end` |',
1130
1176
  '',
package/lib/qq/gateway.js CHANGED
@@ -174,11 +174,14 @@ export class QqGateway extends Service {
174
174
  }
175
175
 
176
176
  async start() {
177
+ if (this._startingPromise) return this._startingPromise
177
178
  if (!this.configured) { this.setStatus('idle'); return }
178
- if (!this.loopTask) {
179
+ if (this.loopTask) return
180
+ this._startingPromise = (async () => {
179
181
  this.stopRequested = false
180
182
  this.loopTask = this.runLoop().finally(() => { this.loopTask = null })
181
- }
183
+ })().finally(() => { this._startingPromise = null })
184
+ return this._startingPromise
182
185
  }
183
186
 
184
187
  async persist(patch) {