@lanbaolu/dsh-wechat-bridge 0.1.0 → 0.2.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
@@ -12,7 +12,7 @@
12
12
  import { createServer } from 'node:http';
13
13
  import { randomBytes } from 'node:crypto';
14
14
  import { spawn } from 'node:child_process';
15
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, unlinkSync, chmodSync } from 'node:fs';
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync, statSync, unlinkSync, chmodSync } from 'node:fs';
16
16
  import { join, dirname, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { homedir } from 'node:os';
@@ -21,6 +21,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
21
21
  import { SessionId } from '@deepseek-ai/dsh-session';
22
22
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
23
23
  import { startQrLogin, checkQrStatus } from './bridge/wechat/login.js';
24
+ import { loadJson, saveJson, validateAccountId } from './bridge/store.js';
24
25
  export const name = '@lanbaolu/dsh-wechat-bridge';
25
26
  /** Host services the plugin needs. `webServer` is optional (headless profiles). */
26
27
  export const inject = ['tools', 'agents', 'agentDefaultModel'];
@@ -28,7 +29,7 @@ export const Config = z.object({
28
29
  dataDir: z.string().default(''),
29
30
  host: z.string().default('127.0.0.1'),
30
31
  port: z.number().min(0).max(65535).default(0),
31
- autoStart: z.boolean().default(false),
32
+ autoStart: z.boolean().default(true),
32
33
  provider: z.string().default(''),
33
34
  model: z.string().default(''),
34
35
  workingDirectory: z.string().default(''),
@@ -62,6 +63,8 @@ export function apply(ctx, config) {
62
63
  const activeSessionIds = new Set();
63
64
  const creating = new Map();
64
65
  const streamClients = new Map();
66
+ /** Accounts waiting to switch to a selected project after the current turn ends. */
67
+ const pendingProjectSwitches = new Set();
65
68
  let bridgeChild;
66
69
  let bridgeStartedAt;
67
70
  let internalServer;
@@ -96,6 +99,37 @@ export function apply(ctx, config) {
96
99
  saveSessionIdMap(map);
97
100
  }
98
101
  }
102
+ // -------------------------------------------------------------------------
103
+ // Explicit project-conversation binding (Web panel selection)
104
+ // -------------------------------------------------------------------------
105
+ const selectedSessionIdPath = join(dataDir, 'selected-sessions.json');
106
+ function loadSelectedSessionIds() {
107
+ try {
108
+ const raw = JSON.parse(readFileSync(selectedSessionIdPath, 'utf8'));
109
+ return raw && typeof raw === 'object' ? raw : {};
110
+ }
111
+ catch {
112
+ return {};
113
+ }
114
+ }
115
+ function saveSelectedSessionIds(map) {
116
+ mkdirSync(dataDir, { recursive: true });
117
+ writeFileSync(selectedSessionIdPath, JSON.stringify(map, null, 2) + '\n', 'utf8');
118
+ }
119
+ function persistSelectedSessionId(accountId, dshSessionId) {
120
+ validateAccountId(accountId);
121
+ const map = loadSelectedSessionIds();
122
+ map[accountId] = dshSessionId;
123
+ saveSelectedSessionIds(map);
124
+ }
125
+ function removeSelectedSessionId(accountId) {
126
+ const map = loadSelectedSessionIds();
127
+ if (accountId in map) {
128
+ delete map[accountId];
129
+ saveSelectedSessionIds(map);
130
+ }
131
+ }
132
+ const selectedSessionIds = new Map(Object.entries(loadSelectedSessionIds()));
99
133
  function newDshSessionId(accountId) {
100
134
  return `wb-${accountId}-${Date.now()}-${randomBytes(4).toString('hex')}`;
101
135
  }
@@ -103,6 +137,14 @@ export function apply(ctx, config) {
103
137
  // Agent management
104
138
  // -------------------------------------------------------------------------
105
139
  async function ensureAgent(accountId, input) {
140
+ // If the model just selected a project from inside a WeChat turn, finish
141
+ // tearing down the old bridge agent before accepting the next message.
142
+ if (pendingProjectSwitches.has(accountId)) {
143
+ pendingProjectSwitches.delete(accountId);
144
+ if (agents.has(accountId)) {
145
+ await disposeAgent(accountId, { preserveSelection: true });
146
+ }
147
+ }
106
148
  const existing = agents.get(accountId);
107
149
  if (existing)
108
150
  return existing;
@@ -121,14 +163,21 @@ export function apply(ctx, config) {
121
163
  };
122
164
  // Resume the persisted DSH session when possible; create a fresh one only
123
165
  // when there is no mapping, the old log is gone/corrupt, or the requested
124
- // workspace differs from the persisted session's cwd.
166
+ // workspace differs from the persisted session's cwd (unless the user
167
+ // explicitly bound the bridge to a project conversation).
168
+ const selectedSessionId = selectedSessionIds.get(accountId);
125
169
  let dshSessionId = sessionIds.get(accountId);
126
170
  let handle;
127
171
  let resumed = false;
172
+ let isSelected = false;
173
+ let selectedCwd;
174
+ if (!dshSessionId)
175
+ dshSessionId = selectedSessionId;
128
176
  if (!dshSessionId) {
129
177
  const sessionMap = loadSessionIdMap();
130
178
  dshSessionId = sessionMap[accountId] || undefined;
131
179
  }
180
+ isSelected = !!selectedSessionId && dshSessionId === selectedSessionId;
132
181
  if (dshSessionId) {
133
182
  try {
134
183
  const candidate = await ctx.agents.resume({
@@ -136,7 +185,9 @@ export function apply(ctx, config) {
136
185
  agentOptions,
137
186
  });
138
187
  const persistedCwd = candidate.agent.session.header.cwd;
139
- if (input?.cwd && persistedCwd && resolve(input.cwd) !== resolve(persistedCwd)) {
188
+ selectedCwd = persistedCwd || undefined;
189
+ const cwdMismatch = input?.cwd && persistedCwd && resolve(input.cwd) !== resolve(persistedCwd);
190
+ if (cwdMismatch && !isSelected) {
140
191
  debugLog('resume cwd mismatch, create new', {
141
192
  accountId,
142
193
  dshSessionId,
@@ -161,8 +212,17 @@ export function apply(ctx, config) {
161
212
  }
162
213
  }
163
214
  if (!handle) {
215
+ if (isSelected) {
216
+ debugLog('selected project session resume failed, clearing binding', {
217
+ accountId,
218
+ dshSessionId,
219
+ });
220
+ selectedSessionIds.delete(accountId);
221
+ removeSelectedSessionId(accountId);
222
+ isSelected = false;
223
+ }
164
224
  dshSessionId = newDshSessionId(accountId);
165
- debugLog('ensureAgent create', { accountId, dshSessionId, provider, model, selection, resumed: false });
225
+ debugLog('ensureAgent create', { accountId, dshSessionId, provider, model, selection, resumed: false, selected: false });
166
226
  handle = await ctx.agents.create({
167
227
  sessionId: SessionId(dshSessionId),
168
228
  meta: input?.cwd ? { cwd: resolve(input.cwd) } : undefined,
@@ -170,16 +230,27 @@ export function apply(ctx, config) {
170
230
  });
171
231
  }
172
232
  else {
173
- debugLog('ensureAgent resume', { accountId, dshSessionId, provider, model, selection, resumed: true });
233
+ debugLog('ensureAgent resume', { accountId, dshSessionId, provider, model, selection, resumed: true, selected: isSelected });
174
234
  }
175
235
  const finalSessionId = dshSessionId;
176
236
  sessionIds.set(accountId, finalSessionId);
177
237
  persistSessionId(accountId, finalSessionId);
238
+ if (isSelected) {
239
+ persistSelectedSessionId(accountId, finalSessionId);
240
+ }
241
+ else if (selectedSessionIds.has(accountId)) {
242
+ selectedSessionIds.delete(accountId);
243
+ removeSelectedSessionId(accountId);
244
+ }
178
245
  agents.set(accountId, handle);
179
246
  activeSessionIds.add(finalSessionId);
180
- debugLog('agent ready', { accountId, dshSessionId: finalSessionId, provider, model, selection, resumed });
181
- if (input?.cwd)
247
+ debugLog('agent ready', { accountId, dshSessionId: finalSessionId, provider, model, selection, resumed, selected: isSelected });
248
+ if (isSelected && selectedCwd) {
249
+ await attachSessionToWorkspace(finalSessionId, selectedCwd);
250
+ }
251
+ else if (input?.cwd) {
182
252
  await attachSessionToWorkspace(finalSessionId, resolve(input.cwd));
253
+ }
183
254
  return handle;
184
255
  })();
185
256
  creating.set(accountId, task);
@@ -190,7 +261,7 @@ export function apply(ctx, config) {
190
261
  creating.delete(accountId);
191
262
  }
192
263
  }
193
- async function disposeAgent(accountId) {
264
+ async function disposeAgent(accountId, options) {
194
265
  const handle = agents.get(accountId);
195
266
  const dshSessionId = sessionIds.get(accountId);
196
267
  if (dshSessionId)
@@ -198,6 +269,10 @@ export function apply(ctx, config) {
198
269
  sessionIds.delete(accountId);
199
270
  agents.delete(accountId);
200
271
  removePersistedSessionId(accountId);
272
+ if (!options?.preserveSelection && selectedSessionIds.has(accountId)) {
273
+ selectedSessionIds.delete(accountId);
274
+ removeSelectedSessionId(accountId);
275
+ }
201
276
  if (handle)
202
277
  await handle.dispose();
203
278
  closeStreams(accountId);
@@ -223,6 +298,217 @@ export function apply(ctx, config) {
223
298
  }
224
299
  }
225
300
  // -------------------------------------------------------------------------
301
+ // Project conversation selection (Web panel)
302
+ // -------------------------------------------------------------------------
303
+ function latestAccountId() {
304
+ try {
305
+ const accountsDir = join(dataDir, 'accounts');
306
+ const files = readdirSync(accountsDir).filter((file) => file.endsWith('.json'));
307
+ if (files.length === 0)
308
+ return undefined;
309
+ let latestFile = files[0];
310
+ let latestMtime = 0;
311
+ for (const file of files) {
312
+ const stat = statSync(join(accountsDir, file));
313
+ if (stat.mtimeMs > latestMtime) {
314
+ latestMtime = stat.mtimeMs;
315
+ latestFile = file;
316
+ }
317
+ }
318
+ return latestFile.replace(/\.json$/, '');
319
+ }
320
+ catch {
321
+ return undefined;
322
+ }
323
+ }
324
+ async function listProjectSessions() {
325
+ const registry = ctx.get('workspaceRegistry');
326
+ if (!registry?.list)
327
+ return [];
328
+ const sessionsService = ctx.get('sessions');
329
+ const persistence = ctx.get('sessionPersistence');
330
+ const headerById = new Map();
331
+ const liveIds = new Set();
332
+ for (const session of sessionsService?.list() ?? []) {
333
+ const id = String(session.id ?? session.header.id);
334
+ if (!id)
335
+ continue;
336
+ liveIds.add(id);
337
+ headerById.set(id, {
338
+ cwd: session.header.cwd,
339
+ createdAt: session.header.createdAt,
340
+ });
341
+ }
342
+ if (persistence?.listSnapshots) {
343
+ try {
344
+ for (const snap of await persistence.listSnapshots()) {
345
+ const id = String(snap.header.id);
346
+ if (!id)
347
+ continue;
348
+ if (!headerById.has(id)) {
349
+ headerById.set(id, {
350
+ cwd: snap.header.cwd,
351
+ createdAt: snap.header.createdAt,
352
+ });
353
+ }
354
+ }
355
+ }
356
+ catch (err) {
357
+ debugLog('listProjectSessions snapshots failed', { error: err instanceof Error ? err.message : String(err) });
358
+ }
359
+ }
360
+ const items = [];
361
+ for (const ws of registry.list()) {
362
+ for (const sid of ws.sessionIds) {
363
+ const sessionId = String(sid);
364
+ const header = headerById.get(sessionId);
365
+ items.push({
366
+ sessionId,
367
+ workspaceId: ws.id,
368
+ workspaceTitle: ws.title,
369
+ path: ws.path,
370
+ cwd: header?.cwd || ws.path,
371
+ createdAt: header?.createdAt || ws.createdAt,
372
+ live: liveIds.has(sessionId),
373
+ });
374
+ }
375
+ }
376
+ return items;
377
+ }
378
+ async function selectedProjectPayload(accountId) {
379
+ const target = accountId || latestAccountId();
380
+ if (!target)
381
+ return null;
382
+ const selectedId = selectedSessionIds.get(target);
383
+ if (!selectedId)
384
+ return null;
385
+ const items = await listProjectSessions();
386
+ const item = items.find((candidate) => candidate.sessionId === selectedId);
387
+ if (!item)
388
+ return null;
389
+ return {
390
+ sessionId: selectedId,
391
+ workspaceId: item.workspaceId,
392
+ workspaceTitle: item.workspaceTitle,
393
+ path: item.path,
394
+ };
395
+ }
396
+ function accountIdForAgent(agent) {
397
+ if (!agent?.session?.id)
398
+ return undefined;
399
+ const sid = String(agent.session.id);
400
+ for (const [accountId, dshSessionId] of sessionIds) {
401
+ if (dshSessionId === sid)
402
+ return accountId;
403
+ }
404
+ return undefined;
405
+ }
406
+ async function selectProjectFromAgent(agent, sessionId) {
407
+ const accountId = accountIdForAgent(agent);
408
+ if (!accountId) {
409
+ return { ok: false, error: '当前不是微信桥接会话,无法切换项目。' };
410
+ }
411
+ const items = await listProjectSessions();
412
+ const item = items.find((candidate) => candidate.sessionId === sessionId);
413
+ if (!item) {
414
+ return { ok: false, error: '未找到该项目会话,请先使用 wechat_bridge_list_projects 查看可绑定项目。' };
415
+ }
416
+ const currentDshSessionId = sessionIds.get(accountId);
417
+ if (sessionId === currentDshSessionId) {
418
+ return {
419
+ ok: true,
420
+ accountId,
421
+ selectedSessionId: sessionId,
422
+ project: item,
423
+ message: `已经在项目 ${item.workspaceTitle} 中。`,
424
+ };
425
+ }
426
+ const sessionsService = ctx.get('sessions');
427
+ if (sessionsService?.get(SessionId(sessionId))) {
428
+ return { ok: false, error: '该项目会话当前正在 DSH 中打开,请先在 DSH 中关闭该会话后再进入。' };
429
+ }
430
+ selectedSessionIds.set(accountId, sessionId);
431
+ persistSelectedSessionId(accountId, sessionId);
432
+ resetBridgeAccountSession(accountId, item.path);
433
+ pendingProjectSwitches.add(accountId);
434
+ return {
435
+ ok: true,
436
+ accountId,
437
+ selectedSessionId: sessionId,
438
+ project: item,
439
+ message: `已进入项目 ${item.workspaceTitle}(${item.path}),后续对话会记录到这个项目。`,
440
+ };
441
+ }
442
+ function readBridgeAccountSession(accountId) {
443
+ validateAccountId(accountId);
444
+ return loadJson(join(dataDir, 'sessions', `${accountId}.json`), {});
445
+ }
446
+ function writeBridgeAccountSession(accountId, session) {
447
+ validateAccountId(accountId);
448
+ saveJson(join(dataDir, 'sessions', `${accountId}.json`), session);
449
+ }
450
+ function resetBridgeAccountSession(accountId, cwd) {
451
+ const session = readBridgeAccountSession(accountId);
452
+ session.workingDirectory = cwd;
453
+ session.state = 'idle';
454
+ session.chatHistory = [];
455
+ writeBridgeAccountSession(accountId, session);
456
+ }
457
+ async function selectProjectSession(dshSessionId, accountId) {
458
+ const target = accountId || latestAccountId();
459
+ if (!target)
460
+ return { ok: false, error: '没有已绑定的微信账号,请先扫码绑定。' };
461
+ const items = await listProjectSessions();
462
+ const item = items.find((candidate) => candidate.sessionId === dshSessionId);
463
+ if (!item)
464
+ return { ok: false, error: '指定的会话不存在或不属于任何项目。' };
465
+ // If the user is re-selecting the conversation the bridge already owns,
466
+ // treat it as a no-op instead of disposing the live agent.
467
+ const currentDshSessionId = sessionIds.get(target);
468
+ if (dshSessionId === currentDshSessionId) {
469
+ return {
470
+ ok: true,
471
+ accountId: target,
472
+ selectedSessionId: dshSessionId,
473
+ project: item,
474
+ daemon: '已经绑定到该项目会话。',
475
+ };
476
+ }
477
+ // A live DSH session can only be owned by one agent loop. If the selected
478
+ // conversation is currently open in the DSH UI, refuse before touching the
479
+ // current bridge agent instead of silently falling back on the next message.
480
+ const sessionsService = ctx.get('sessions');
481
+ if (sessionsService?.get(SessionId(dshSessionId))) {
482
+ return { ok: false, error: '该会话当前正在 DSH 中打开,请先在 DSH 中关闭该会话后再绑定。' };
483
+ }
484
+ // Drop the current bridge-owned agent so the next message resumes the
485
+ // selected project conversation instead of the previous bridge session.
486
+ if (agents.has(target) || sessionIds.has(target)) {
487
+ await disposeAgent(target);
488
+ }
489
+ selectedSessionIds.set(target, dshSessionId);
490
+ persistSelectedSessionId(target, dshSessionId);
491
+ resetBridgeAccountSession(target, item.path);
492
+ const daemonResult = daemonRunning() ? await restartDaemon() : { ok: true, message: '守护进程未运行,绑定将在下次启动时生效。' };
493
+ return {
494
+ ok: true,
495
+ accountId: target,
496
+ selectedSessionId: dshSessionId,
497
+ project: item,
498
+ daemon: daemonResult.message,
499
+ };
500
+ }
501
+ async function detachProjectSession(accountId) {
502
+ const target = accountId || latestAccountId();
503
+ if (!target)
504
+ return { ok: false, error: '没有已绑定的微信账号。' };
505
+ await disposeAgent(target);
506
+ const config = readBridgeConfig();
507
+ resetBridgeAccountSession(target, config.workingDirectory);
508
+ const daemonResult = daemonRunning() ? await restartDaemon() : { ok: true, message: '守护进程未运行,解除绑定将在下次启动时生效。' };
509
+ return { ok: true, accountId: target, daemon: daemonResult.message };
510
+ }
511
+ // -------------------------------------------------------------------------
226
512
  // SSE broadcast
227
513
  // -------------------------------------------------------------------------
228
514
  function broadcast(sessionId, event) {
@@ -274,6 +560,15 @@ export function apply(ctx, config) {
274
560
  else if (event.type === 'turn/end') {
275
561
  debugLog('session turn/end', { accountId, sessionId: sid, reason: event.data.reason });
276
562
  broadcast(accountId, { type: 'done', turn: event.data.turn, message: 'turn ended' });
563
+ if (pendingProjectSwitches.has(accountId)) {
564
+ pendingProjectSwitches.delete(accountId);
565
+ void disposeAgent(accountId, { preserveSelection: true }).catch((err) => {
566
+ debugLog('pending project switch dispose failed', {
567
+ accountId,
568
+ error: err instanceof Error ? err.message : String(err),
569
+ });
570
+ });
571
+ }
277
572
  }
278
573
  });
279
574
  // -------------------------------------------------------------------------
@@ -307,6 +602,22 @@ export function apply(ctx, config) {
307
602
  sendJson(res, 200, await statusPayload());
308
603
  return;
309
604
  }
605
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
606
+ sendJson(res, 200, { ok: true, items: await listProjectSessions() });
607
+ return;
608
+ }
609
+ if (req.method === 'POST' && url.pathname === '/api/projects/select') {
610
+ const body = await readBody(req);
611
+ const sessionId = String(body.sessionId || '');
612
+ const result = await selectProjectSession(sessionId);
613
+ sendJson(res, result.ok ? 200 : 400, result);
614
+ return;
615
+ }
616
+ if (req.method === 'POST' && url.pathname === '/api/projects/detach') {
617
+ const result = await detachProjectSession();
618
+ sendJson(res, result.ok ? 200 : 400, result);
619
+ return;
620
+ }
310
621
  if (req.method === 'POST' && url.pathname === '/api/prompt') {
311
622
  const body = await readBody(req);
312
623
  const sessionId = String(body.sessionId || 'default');
@@ -551,11 +862,16 @@ export function apply(ctx, config) {
551
862
  config.workingDirectory = pendingSetup.workingDirectory;
552
863
  saveBridgeConfig(config);
553
864
  pendingSetup = undefined;
865
+ // A newly bound account only takes effect after the daemon reloads the
866
+ // latest account file. Restart when running, otherwise start it so the
867
+ // user does not have to manually restart after every re-scan.
868
+ const daemonResult = daemonRunning() ? await restartDaemon() : await startDaemon();
554
869
  return {
555
870
  ok: true,
556
871
  status: 'confirmed',
557
872
  accountId: result.account.accountId,
558
873
  workingDirectory: config.workingDirectory,
874
+ daemon: daemonResult.message,
559
875
  };
560
876
  }
561
877
  if (result.status === 'expired') {
@@ -604,6 +920,7 @@ export function apply(ctx, config) {
604
920
  workingDirectory: readBridgeConfig().workingDirectory,
605
921
  accounts: accountFiles,
606
922
  sessions: [...sessionIds.keys()],
923
+ selectedProject: await selectedProjectPayload(),
607
924
  };
608
925
  }
609
926
  // -------------------------------------------------------------------------
@@ -643,6 +960,52 @@ export function apply(ctx, config) {
643
960
  res.end(readDaemonLogs(200));
644
961
  },
645
962
  }));
963
+ disposers.push(webServer.register({
964
+ kind: 'exact',
965
+ path: '/@lanbaolu/dsh-wechat-bridge/projects',
966
+ handler: async (_req, res) => {
967
+ const result = await listProjectSessions();
968
+ res.writeHead(200, { 'Content-Type': 'application/json' });
969
+ res.end(JSON.stringify({ ok: true, items: result }));
970
+ },
971
+ }));
972
+ disposers.push(webServer.register({
973
+ kind: 'exact',
974
+ path: '/@lanbaolu/dsh-wechat-bridge/projects/select',
975
+ handler: async (req, res) => {
976
+ try {
977
+ const body = await readBody(req);
978
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
979
+ const accountId = typeof body.accountId === 'string' && body.accountId ? body.accountId : undefined;
980
+ const result = await selectProjectSession(sessionId, accountId);
981
+ res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
982
+ res.end(JSON.stringify(result));
983
+ }
984
+ catch (err) {
985
+ const message = err instanceof Error ? err.message : String(err);
986
+ res.writeHead(400, { 'Content-Type': 'application/json' });
987
+ res.end(JSON.stringify({ ok: false, error: message }));
988
+ }
989
+ },
990
+ }));
991
+ disposers.push(webServer.register({
992
+ kind: 'exact',
993
+ path: '/@lanbaolu/dsh-wechat-bridge/projects/detach',
994
+ handler: async (req, res) => {
995
+ try {
996
+ const body = await readBody(req).catch(() => ({}));
997
+ const accountId = body && typeof body.accountId === 'string' && body.accountId ? body.accountId : undefined;
998
+ const result = await detachProjectSession(accountId);
999
+ res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
1000
+ res.end(JSON.stringify(result));
1001
+ }
1002
+ catch (err) {
1003
+ const message = err instanceof Error ? err.message : String(err);
1004
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1005
+ res.end(JSON.stringify({ ok: false, error: message }));
1006
+ }
1007
+ },
1008
+ }));
646
1009
  disposers.push(webServer.register({
647
1010
  kind: 'exact',
648
1011
  path: '/@lanbaolu/dsh-wechat-bridge/setup/start',
@@ -704,6 +1067,72 @@ export function apply(ctx, config) {
704
1067
  };
705
1068
  },
706
1069
  }));
1070
+ ctx.tools.register(defineTool({
1071
+ name: 'wechat_bridge_list_projects',
1072
+ description: '列出 DSH 中可进入/可绑定的项目会话(含项目名、路径、会话 ID)。当用户询问“有哪些项目”“看看我有什么项目”“我要看下项目”“我在哪个项目”“想继续某个项目”“有个任务想做”等意图,或用户描述内容可能对应某个项目时,都应调用此工具查看项目,不要要求用户使用固定句式。',
1073
+ parameters: {},
1074
+ output: {
1075
+ schema: {
1076
+ type: 'object',
1077
+ additionalProperties: false,
1078
+ properties: {
1079
+ ok: { type: 'boolean', required: true },
1080
+ message: { type: 'string', required: true },
1081
+ projects: {
1082
+ type: 'array',
1083
+ required: true,
1084
+ items: {
1085
+ type: 'object',
1086
+ additionalProperties: false,
1087
+ properties: {
1088
+ sessionId: { type: 'string', required: true },
1089
+ workspaceTitle: { type: 'string', required: true },
1090
+ path: { type: 'string', required: true },
1091
+ live: { type: 'boolean', required: true },
1092
+ },
1093
+ },
1094
+ },
1095
+ },
1096
+ },
1097
+ render: (_args, value) => [{
1098
+ type: 'text',
1099
+ text: value.projects.length === 0
1100
+ ? '当前没有可绑定的项目会话。'
1101
+ : `📁 可进入的项目会话(${value.projects.length} 个):\n` + value.projects.map((p, i) => `${i + 1}. ${p.workspaceTitle} · ${p.path} · ${p.sessionId.slice(-8)}`).join('\n'),
1102
+ }],
1103
+ },
1104
+ execute: async () => {
1105
+ const items = await listProjectSessions();
1106
+ return {
1107
+ ok: true,
1108
+ message: `共 ${items.length} 个项目会话`,
1109
+ projects: items.map((item) => ({
1110
+ sessionId: item.sessionId,
1111
+ workspaceTitle: item.workspaceTitle,
1112
+ path: item.path,
1113
+ live: item.live,
1114
+ })),
1115
+ };
1116
+ },
1117
+ }));
1118
+ ctx.tools.register(defineTool({
1119
+ name: 'wechat_bridge_select_project',
1120
+ description: '进入一个 DSH 项目会话。微信桥接会话中,模型应先调用 wechat_bridge_list_projects 获取 sessionId,再调用本工具切换到对应项目;切换后后续微信对话会记录到该项目。支持用户自然语言模糊指代,例如“进入某某项目”“去这个项目”“继续在 XXX 里做”“我现在要做 XXX”等,模型应根据上下文/项目列表选择对应 sessionId。',
1121
+ parameters: {
1122
+ sessionId: { type: 'string', description: '要进入的项目会话 ID,来自 wechat_bridge_list_projects 返回的 sessionId。' },
1123
+ },
1124
+ output: simpleOutput,
1125
+ execute: async (args, exec) => {
1126
+ const result = await selectProjectFromAgent(exec?.agent, args.sessionId);
1127
+ if (!result.ok) {
1128
+ throw new Error(String(result.error || '进入项目失败'));
1129
+ }
1130
+ return {
1131
+ ok: true,
1132
+ message: String(result.message || '已进入项目。'),
1133
+ };
1134
+ },
1135
+ }));
707
1136
  ctx.tools.register(defineTool({
708
1137
  name: 'wechat_bridge_start',
709
1138
  description: '启动 DSH 微信桥接守护进程。需要先完成微信扫码绑定(wechat_bridge_setup 或 node lib/bridge/main.js setup)。',
@@ -804,7 +1233,24 @@ export function apply(ctx, config) {
804
1233
  internalServer = server;
805
1234
  registerTools();
806
1235
  const webDisposers = registerWebRoutes();
1236
+ // Watchdog: after sleep/wake or an unexpected daemon exit, automatically
1237
+ // bring the bridge back instead of requiring the user to click Start.
1238
+ let healthTimer;
1239
+ if (config.autoStart) {
1240
+ healthTimer = setInterval(() => {
1241
+ if (daemonRunning())
1242
+ return;
1243
+ startDaemon().then(result => {
1244
+ ctx.logger?.info?.('[dsh-wechat-bridge] watchdog auto-start', result);
1245
+ }).catch(err => {
1246
+ ctx.logger?.warn?.('[dsh-wechat-bridge] watchdog auto-start failed', { error: String(err) });
1247
+ });
1248
+ }, 15_000);
1249
+ healthTimer.unref?.();
1250
+ }
807
1251
  return () => {
1252
+ if (healthTimer)
1253
+ clearInterval(healthTimer);
808
1254
  for (const dispose of webDisposers)
809
1255
  dispose();
810
1256
  for (const handle of agents.values()) {