evolclaw 2.3.0 → 2.4.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.
@@ -16,6 +16,10 @@ export class AUNChannel {
16
16
  const line = JSON.stringify({ ts: localTimestamp(), dir, event, data });
17
17
  this.traceStream.write(line + '\n');
18
18
  }
19
+ /** 判断 channelId 是否为群组 ID(g-xxx.agentid.pub 或 grp_ 前缀) */
20
+ isGroupId(id) {
21
+ return id.startsWith('grp_') || (id.startsWith('g-') && id.includes('.'));
22
+ }
19
23
  getShortAid(aid) {
20
24
  if (!aid)
21
25
  return undefined;
@@ -48,16 +52,11 @@ export class AUNChannel {
48
52
  result = result.replace(/(^|\s)@all(?=$|\s|[.,!?;:,。!?;:]|[\u4e00-\u9fff])/gi, '$1');
49
53
  return result.replace(/[ \t]+/g, ' ').trim();
50
54
  }
51
- buildGroupReplyContext(taskId, senderAid, text) {
55
+ buildGroupReplyContext(taskId, senderAid) {
52
56
  const replyContext = {};
53
57
  if (taskId)
54
58
  replyContext.threadId = taskId;
55
- if (this.hasExplicitMention(text, 'all')) {
56
- replyContext.mentionUserIds = ['all'];
57
- }
58
- else {
59
- replyContext.mentionUserIds = [senderAid];
60
- }
59
+ replyContext.peerId = senderAid;
61
60
  return replyContext;
62
61
  }
63
62
  acknowledgeImmediately(messageId, seq) {
@@ -150,6 +149,18 @@ export class AUNChannel {
150
149
  this.handleConnectionState(data);
151
150
  });
152
151
  // Authenticate
152
+ // Workaround: SDK 0.3.x _loadIdentityOrRaise doesn't set identity.aid from requested aid,
153
+ // causing gateway "missing aid" error. Patch to backfill aid on loaded identity.
154
+ const authFlow = this.client._auth;
155
+ if (authFlow && typeof authFlow._loadIdentityOrRaise === 'function') {
156
+ const origLoad = authFlow._loadIdentityOrRaise.bind(authFlow);
157
+ authFlow._loadIdentityOrRaise = (aid) => {
158
+ const identity = origLoad(aid);
159
+ if (identity && !identity.aid)
160
+ identity.aid = aid ?? authFlow._aid;
161
+ return identity;
162
+ };
163
+ }
153
164
  let accessToken;
154
165
  try {
155
166
  logger.info(`[AUN] Authenticating as ${aidName}...`);
@@ -169,7 +180,8 @@ export class AUNChannel {
169
180
  // Fallback: try direct token from env/config (legacy)
170
181
  accessToken = this.config.accessToken || process.env.AUN_ACCESS_TOKEN || '';
171
182
  if (!accessToken) {
172
- logger.error(`[AUN] No accessToken fallback available, AUN channel disabled`);
183
+ logger.error(`[AUN] No accessToken fallback available, scheduling retry`);
184
+ this.scheduleReconnect();
173
185
  return;
174
186
  }
175
187
  logger.warn(`[AUN] Using accessToken fallback`);
@@ -180,10 +192,22 @@ export class AUNChannel {
180
192
  this._aid = this.client.aid ?? undefined;
181
193
  this.connected = true;
182
194
  this.reconnectAttempt = 0;
195
+ // Workaround: SDK e2ee uses _identity.cert for sender_cert_fingerprint;
196
+ // if cert is missing, it falls back to public key SPKI fingerprint which
197
+ // causes peer cert lookup failures. Backfill from keystore if needed.
198
+ const clientAny = this.client;
199
+ if (clientAny._identity && !clientAny._identity.cert) {
200
+ const cert = clientAny._keystore?.loadCert?.(aidName);
201
+ if (cert) {
202
+ clientAny._identity.cert = cert;
203
+ logger.info('[AUN] Backfilled identity.cert from keystore for e2ee fingerprint');
204
+ }
205
+ }
183
206
  logger.info(`[AUN] Connected as ${this._aid}`);
184
207
  }
185
208
  catch (e) {
186
209
  logger.error(`[AUN] Connection failed: ${e}`);
210
+ this.scheduleReconnect();
187
211
  return;
188
212
  }
189
213
  }
@@ -225,6 +249,10 @@ export class AUNChannel {
225
249
  const taskId = msg.task_id;
226
250
  const messageId = msg.message_id ?? '';
227
251
  const seq = msg.seq;
252
+ // Extract structured mentions from payload (e.g. payload.mentions: ["evolai.agentid.pub"])
253
+ const payloadMentions = Array.isArray(payload?.mentions)
254
+ ? payload.mentions.filter((m) => typeof m === 'string')
255
+ : [];
228
256
  logger.info(`[AUN][DIAG-GRP] full_msg=${JSON.stringify(msg).substring(0, 500)}`);
229
257
  if (!groupId || !senderAid) {
230
258
  this.acknowledgeImmediately(messageId, seq);
@@ -234,8 +262,10 @@ export class AUNChannel {
234
262
  this.acknowledgeImmediately(messageId, seq);
235
263
  return;
236
264
  }
237
- const mentionedSelf = this._aid ? this.hasExplicitMention(text, this._aid) : false;
238
- const mentionedAll = this.hasExplicitMention(text, 'all');
265
+ const mentionedSelf = this._aid
266
+ ? (this.hasExplicitMention(text, this._aid) || payloadMentions.includes(this._aid))
267
+ : false;
268
+ const mentionedAll = this.hasExplicitMention(text, 'all') || payloadMentions.includes('all');
239
269
  if (!mentionedSelf && !mentionedAll) {
240
270
  this.acknowledgeImmediately(messageId, seq);
241
271
  return;
@@ -256,7 +286,7 @@ export class AUNChannel {
256
286
  seq,
257
287
  taskId,
258
288
  mentions,
259
- replyContext: this.buildGroupReplyContext(taskId, senderAid, text),
289
+ replyContext: this.buildGroupReplyContext(taskId, senderAid),
260
290
  });
261
291
  }
262
292
  dispatchMessage(event) {
@@ -338,18 +368,17 @@ export class AUNChannel {
338
368
  finalText = '最终回复\n' + text;
339
369
  }
340
370
  this.sentCount.set(channelId, (this.sentCount.get(channelId) || 0) + 1);
341
- // Render outbound mentions for group sends
342
- if (channelId.startsWith('grp_') && context?.mentionUserIds?.length) {
343
- const mentionPrefix = context.mentionUserIds.includes('all')
344
- ? '@all '
345
- : context.mentionUserIds.map(id => `@${id}`).join(' ') + ' ';
346
- finalText = mentionPrefix + finalText;
371
+ // 群聊 @ 兜底:提示词已告知 agent @,但如果 agent 没写,系统自动补上
372
+ if (this.isGroupId(channelId) && context?.peerId) {
373
+ if (!finalText.includes(`@${context.peerId}`)) {
374
+ finalText = `@${context.peerId} ` + finalText;
375
+ }
347
376
  }
348
377
  const params = { payload: { text: finalText }, encrypt: true };
349
378
  if (context?.threadId)
350
379
  params.task_id = context.threadId;
351
380
  try {
352
- if (channelId.startsWith('grp_')) {
381
+ if (this.isGroupId(channelId)) {
353
382
  params.group_id = channelId;
354
383
  this.trace('OUT', 'group.send', params);
355
384
  await this.client.call('group.send', params);
@@ -366,13 +395,9 @@ export class AUNChannel {
366
395
  }
367
396
  }
368
397
  acknowledge(messageId) {
369
- const seq = this.messageSeqMap.get(messageId);
370
- if (seq != null && this.client) {
371
- this.client.call('message.ack', { seq }).catch(e => {
372
- logger.debug(`[AUN] Ack failed: ${e}`);
373
- });
374
- this.messageSeqMap.delete(messageId);
375
- }
398
+ // Gateway auto-delivery-ack is sufficient; skip explicit message.ack RPC
399
+ // to avoid duplicate "已送达" at the sender CLI
400
+ this.messageSeqMap.delete(messageId);
376
401
  }
377
402
  sendProcessingStatus(channelId, status, sessionId, context) {
378
403
  if (status === 'start')
@@ -387,11 +412,11 @@ export class AUNChannel {
387
412
  };
388
413
  const params = {
389
414
  payload,
390
- encrypt: true, persist: false,
415
+ encrypt: true,
391
416
  };
392
417
  if (context?.threadId)
393
418
  params.task_id = context.threadId;
394
- if (channelId.startsWith('grp_')) {
419
+ if (this.isGroupId(channelId)) {
395
420
  params.group_id = channelId;
396
421
  this.trace('OUT', 'group.send.status', params);
397
422
  this.client.call('group.send', params).catch(e => {
@@ -421,7 +446,7 @@ export class AUNChannel {
421
446
  }
422
447
  const sendParams = {
423
448
  to: channelId, payload: payloadObj,
424
- encrypt: true, persist: false,
449
+ encrypt: true,
425
450
  };
426
451
  this.trace('OUT', 'message.send.custom', sendParams);
427
452
  this.client.call('message.send', sendParams).catch(e => {
@@ -549,7 +574,7 @@ export class AUNChannelPlugin {
549
574
  canCreateSession: (chatType, identity) => true,
550
575
  canDeleteSession: (chatType, identity) => true,
551
576
  canImportCliSession: (chatType, identity) => identity === 'owner',
552
- messagePrefix: () => '',
577
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
553
578
  showMiddleResult: (chatType, identity) => {
554
579
  const mode = inst.showActivities ?? config.showActivities ?? 'all';
555
580
  if (mode === 'none')
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { promisify } from 'util';
6
6
  import { resolveRoot, resolvePaths, ensureDataDirs, getPackageRoot } from './paths.js';
7
7
  import { loadConfig, validateConfigIntegrity, resolveAnthropicConfig } from './config.js';
8
8
  import { migrateProject } from './utils/migrate-project.js';
9
+ import readline from 'readline';
9
10
  import { cmdInit } from './utils/init.js';
10
11
  import { ipcQuery } from './ipc.js';
11
12
  import { cmdInitWechat, cmdInitFeishu, cmdInitAun } from './utils/init-channel.js';
@@ -568,22 +569,111 @@ async function cmdStatus() {
568
569
  console.log(' (no log file yet)');
569
570
  }
570
571
  }
571
- function cmdLogs() {
572
+ // Log line pattern: [timestamp] [LEVEL] [Module?] message
573
+ const LOG_RE = /^(\[[^\]]+\]) (\[(?:INFO|WARN|ERROR|DEBUG)\]) ((?:\[[^\]]+\] )*)(.*)$/;
574
+ const MAX_MSG = 200; // truncate long messages
575
+ function makeColors(enabled) {
576
+ const e = (code) => enabled ? code : '';
577
+ return {
578
+ reset: e('\x1b[0m'), dim: e('\x1b[2m'), bold: e('\x1b[1m'),
579
+ red: e('\x1b[31m'), yellow: e('\x1b[33m'), cyan: e('\x1b[36m'),
580
+ magenta: e('\x1b[35m'), gray: e('\x1b[90m'),
581
+ };
582
+ }
583
+ function renderLogLine(line, opts) {
584
+ const m = line.match(LOG_RE);
585
+ if (!m)
586
+ return line; // passthrough non-standard lines (stack traces etc.)
587
+ const [, ts, levelTag, modulePart, msg] = m;
588
+ const level = levelTag.slice(1, -1); // strip brackets
589
+ // Level filter
590
+ if (opts.level) {
591
+ const want = opts.level.toUpperCase();
592
+ if (want === 'ERROR' && level !== 'ERROR')
593
+ return null;
594
+ if (want === 'WARN' && level !== 'WARN' && level !== 'ERROR')
595
+ return null;
596
+ }
597
+ // Module filter (case-insensitive substring match)
598
+ if (opts.module) {
599
+ const mod = modulePart.toLowerCase();
600
+ if (!mod.includes(opts.module.toLowerCase()))
601
+ return null;
602
+ }
603
+ // Truncate long messages (always, regardless of color)
604
+ const truncated = msg.length > MAX_MSG ? msg.slice(0, MAX_MSG) + '…' : msg;
605
+ const C = makeColors(opts.color);
606
+ // Color by level
607
+ const levelColor = level === 'ERROR' ? C.red : level === 'WARN' ? C.yellow : level === 'DEBUG' ? C.gray : '';
608
+ // Highlight user messages: [channel] channelId: text
609
+ const isUserMsg = modulePart && /^\S+: .+$/.test(truncated);
610
+ const renderedMsg = isUserMsg
611
+ ? C.cyan + truncated + C.reset
612
+ : levelColor + truncated + C.reset;
613
+ return (C.dim + ts + C.reset + ' ' +
614
+ levelColor + C.bold + levelTag + C.reset + ' ' +
615
+ C.magenta + modulePart.trimEnd() + C.reset +
616
+ (modulePart ? ' ' : '') +
617
+ renderedMsg);
618
+ }
619
+ function cmdLogs(args) {
620
+ const raw = args.includes('--raw');
621
+ const noColor = args.includes('--no-color');
622
+ const levelIdx = args.indexOf('--level');
623
+ const moduleIdx = args.indexOf('--module');
624
+ const level = levelIdx !== -1 ? args[levelIdx + 1] : undefined;
625
+ const module = moduleIdx !== -1 ? args[moduleIdx + 1] : undefined;
572
626
  const p = resolvePaths();
573
627
  const mainLog = path.join(p.logs, 'evolclaw.log');
574
628
  if (!fs.existsSync(mainLog)) {
575
629
  console.log(`❌ Log file not found: ${mainLog}`);
576
630
  process.exit(1);
577
631
  }
632
+ if (raw) {
633
+ // Raw mode: plain tail -f, no rendering at all
634
+ if (platform.isWindows) {
635
+ const tail = platform.tailFile(mainLog);
636
+ platform.onShutdown(() => tail.abort());
637
+ }
638
+ else {
639
+ const child = spawn('tail', ['-f', '-n', '50', mainLog], { stdio: 'inherit' });
640
+ child.on('exit', (code) => process.exit(code || 0));
641
+ }
642
+ return;
643
+ }
644
+ // Rendered mode: always filter+truncate, color depends on TTY
645
+ const useColor = !noColor && !!process.stdout.isTTY;
646
+ const opts = { level, module, color: useColor };
647
+ function processLine(line) {
648
+ const rendered = renderLogLine(line, opts);
649
+ if (rendered !== null)
650
+ process.stdout.write(rendered + '\n');
651
+ }
578
652
  if (platform.isWindows) {
579
- // Windows: use fs.watch for live tail
580
- const tail = platform.tailFile(mainLog);
581
- platform.onShutdown(() => tail.abort());
653
+ // Windows: read existing content + watch
654
+ const existing = fs.readFileSync(mainLog, 'utf-8').split('\n').slice(-50);
655
+ existing.forEach(processLine);
656
+ let size = fs.statSync(mainLog).size;
657
+ const watcher = fs.watch(mainLog, () => {
658
+ const newSize = fs.statSync(mainLog).size;
659
+ if (newSize <= size)
660
+ return;
661
+ const buf = Buffer.alloc(newSize - size);
662
+ const fd = fs.openSync(mainLog, 'r');
663
+ fs.readSync(fd, buf, 0, buf.length, size);
664
+ fs.closeSync(fd);
665
+ size = newSize;
666
+ buf.toString().split('\n').forEach(l => l && processLine(l));
667
+ });
668
+ platform.onShutdown(() => watcher.close());
582
669
  }
583
670
  else {
584
- // Unix: use tail -f
585
- const child = spawn('tail', ['-f', mainLog], { stdio: 'inherit' });
671
+ // Unix: spawn tail -f, pipe through renderer
672
+ const child = spawn('tail', ['-f', '-n', '50', mainLog]);
673
+ const rl = readline.createInterface({ input: child.stdout });
674
+ rl.on('line', processLine);
586
675
  child.on('exit', (code) => process.exit(code || 0));
676
+ platform.onShutdown(() => { child.kill(); });
587
677
  }
588
678
  }
589
679
  /**
@@ -1201,7 +1291,7 @@ export async function main(args) {
1201
1291
  await cmdStatus();
1202
1292
  break;
1203
1293
  case 'logs':
1204
- cmdLogs();
1294
+ cmdLogs(args.slice(1));
1205
1295
  break;
1206
1296
  case 'restart-monitor':
1207
1297
  await cmdRestartMonitor();
@@ -1227,7 +1317,10 @@ Commands:
1227
1317
  stop 停止服务
1228
1318
  restart 重启服务
1229
1319
  status 查看状态
1230
- logs 查看日志 (tail -f)
1320
+ logs 查看日志 (tail -f, 着色渲染)
1321
+ --level error|warn 只显示指定级别及以上
1322
+ --module <name> 只显示指定模块(如 feishu、AgentRunner)
1323
+ --raw 原始输出,不着色
1231
1324
  tui 启动 AUN TUI 客户端
1232
1325
  diagnose 诊断启动环境(配置、数据库、进程)
1233
1326
  mv <old> <new> 迁移项目目录(保留 Claude/Codex/EvolClaw 会话)
@@ -324,15 +324,25 @@ export class CommandHandler {
324
324
  * 返回结构化命令菜单(供 menu.query 使用)
325
325
  * admin 看到全部命令分组,guest 仅看到用户级命令
326
326
  */
327
- getMenuItems(isAdmin) {
327
+ getMenuItems(isAdmin, chatType = 'private') {
328
328
  const items = [];
329
+ if (!isAdmin && chatType === 'group') {
330
+ return [
331
+ {
332
+ group: '其他',
333
+ commands: [
334
+ { cmd: '/status', label: '显示会话状态' },
335
+ { cmd: '/help', label: '显示帮助信息' },
336
+ ]
337
+ }
338
+ ];
339
+ }
329
340
  if (isAdmin) {
330
341
  items.push({
331
342
  group: '项目管理',
332
343
  commands: [
333
344
  { cmd: '/pwd', label: '显示当前项目路径' },
334
- { cmd: '/plist', label: '列出所有配置的项目' },
335
- { cmd: '/p', args: '<name|path>', label: '切换项目' },
345
+ { cmd: '/p', args: '[name|path]', label: '列出或切换项目' },
336
346
  { cmd: '/bind', args: '<path>', label: '绑定新项目目录' },
337
347
  ]
338
348
  });
@@ -340,15 +350,12 @@ export class CommandHandler {
340
350
  items.push({
341
351
  group: '会话管理',
342
352
  commands: [
343
- { cmd: '/new', args: '[name]', label: '创建新会话' },
344
- { cmd: '/slist', label: '列出当前项目的所有会话' },
345
- { cmd: '/slist', args: 'cli', label: '列出 CLI 会话(未导入的)' },
346
- { cmd: '/s', args: '<name|index|uuid>', label: '切换到指定会话' },
353
+ { cmd: '/new', args: '[name]', label: '创建新会话(清空历史请用此命令)' },
354
+ { cmd: '/s', args: '[cli|name|index|uuid]', label: '列出或切换会话(cli 查看未导入的 CLI 会话)' },
347
355
  { cmd: '/name', args: '<name>', label: '重命名当前会话' },
348
356
  { cmd: '/del', args: '<name>', label: '删除指定会话' },
349
357
  ...(isAdmin ? [
350
358
  { cmd: '/fork', args: '[name]', label: '分支当前会话' },
351
- { cmd: '/clear', label: '清空会话对话历史' },
352
359
  { cmd: '/compact', label: '压缩会话上下文' },
353
360
  ] : []),
354
361
  ]
@@ -374,9 +381,7 @@ export class CommandHandler {
374
381
  { cmd: '/status', label: '显示会话状态' },
375
382
  { cmd: '/stop', label: '中断当前任务' },
376
383
  { cmd: '/restart', label: '重启服务' },
377
- { cmd: '/repair', label: '检查并修复会话' },
378
- { cmd: '/safe', label: '进入安全模式' },
379
- { cmd: '/send', args: '[渠道] <path>', label: '发送项目内文件' },
384
+ { cmd: '/send', args: '[channel] <path>', label: '发送项目内文件' },
380
385
  { cmd: '/check', args: '[rty <channel>]', label: '检查渠道状态或重连指定渠道' },
381
386
  ]
382
387
  });
@@ -434,11 +439,15 @@ export class CommandHandler {
434
439
  }
435
440
  // 权限检查:区分用户级命令和管理级命令
436
441
  const isAdmin = identity.role === 'owner';
442
+ const activeChatType = activeSession?.chatType || 'private';
437
443
  if (normalizedContent.startsWith('/')) {
438
- const userCommands = ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s '];
444
+ const guestGroupCommands = ['/status', '/help'];
445
+ const userCommands = activeChatType === 'group' && !isAdmin
446
+ ? guestGroupCommands
447
+ : ['/slist', '/new', '/session', '/rename', '/name', '/status', '/help', '/del', '/s '];
439
448
  const isUserCommand = userCommands.some(cmd => normalizedContent === cmd.trimEnd() || normalizedContent.startsWith(cmd));
440
449
  if (!isUserCommand && !isAdmin) {
441
- return '❌ 无权限:此命令仅限管理员使用';
450
+ return '❌ 无权限:当前群聊仅支持 /status 和 /help';
442
451
  }
443
452
  }
444
453
  // 空闲检查:某些命令需要等待当前会话空闲
@@ -480,16 +489,24 @@ export class CommandHandler {
480
489
  return undefined;
481
490
  // /help 命令不需要会话
482
491
  if (normalizedContent === '/help') {
492
+ if (!isAdmin && activeChatType === 'group') {
493
+ const lines = [
494
+ '可用命令:',
495
+ '',
496
+ '其他:',
497
+ ' /status - 显示会话状态',
498
+ ' /help - 显示此帮助信息',
499
+ ];
500
+ return lines.join('\n');
501
+ }
483
502
  if (!isAdmin) {
484
503
  const lines = [
485
504
  '可用命令:',
486
505
  '',
487
506
  '🔄 会话管理:',
488
- ' /new [名称] - 创建新会话(可选命名)',
489
- ' /slist - 列出当前项目的所有会话',
490
- ' /slist cli - 列出 CLI 会话(未导入的)',
491
- ' /s, /session <名称|序号|uuid> - 切换到指定会话',
492
- ' /name, /rename <新名称> - 重命名当前会话',
507
+ ' /new [名称] - 创建新会话(清空历史请用此命令,可选命名)',
508
+ ' /s [cli|名称|序号|uuid] - 列出或切换会话(cli 查看未导入的 CLI 会话)',
509
+ ' /name <新名称> - 重命名当前会话',
493
510
  ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
494
511
  ' /status - 显示会话状态',
495
512
  '',
@@ -503,18 +520,15 @@ export class CommandHandler {
503
520
  '',
504
521
  '📁 项目管理:',
505
522
  ' /pwd - 显示当前项目路径',
506
- ' /plist - 列出所有配置的项目',
507
- ' /p, /project <name|path> - 切换项目',
523
+ ' /p [name|path] - 列出或切换项目',
508
524
  ' /bind <path> - 绑定新项目目录',
509
525
  '',
510
526
  '🔄 会话管理:',
511
- ' /new [名称] - 创建新会话(可选命名)',
512
- ' /slist - 列出当前项目的所有会话',
513
- ' /s, /session <名称> - 切换到指定会话',
514
- ' /name, /rename <新名称> - 重命名当前会话',
527
+ ' /new [名称] - 创建新会话(清空历史请用此命令,可选命名)',
528
+ ' /s [cli|名称|序号|uuid] - 列出或切换会话(cli 查看未导入的 CLI 会话)',
529
+ ' /name <新名称> - 重命名当前会话',
515
530
  ' /del <名称> - 删除指定会话(仅解绑,不删除文件)',
516
531
  ' /fork [名称] - 分支当前会话(从当前对话点创建分支)',
517
- ' /clear - 清空当前会话的对话历史',
518
532
  ' /compact - 压缩会话上下文(减少 token 用量)',
519
533
  '',
520
534
  '🤖 Agent 与模型:',
@@ -531,9 +545,7 @@ export class CommandHandler {
531
545
  ' /status - 显示会话状态',
532
546
  ' /stop - 中断当前任务',
533
547
  ' /restart - 重启服务',
534
- ' /repair - 检查并修复会话',
535
- ' /safe - 进入安全模式',
536
- ' /send [渠道] <路径> - 发送项目内文件',
548
+ ' /send [channel] <path> - 发送项目内文件',
537
549
  '',
538
550
  '❓ 帮助:',
539
551
  ' /help - 显示此帮助信息',
@@ -554,7 +566,8 @@ export class CommandHandler {
554
566
  if (!hasPermissionController(permAgent)) {
555
567
  return '❌ 权限控制不可用';
556
568
  }
557
- const currentMode = permSession.metadata?.permissionMode ?? 'bypass';
569
+ const defaultPermMode = identity.role === 'owner' ? 'bypass' : 'readonly';
570
+ const currentMode = permSession.metadata?.permissionMode ?? defaultPermMode;
558
571
  const modes = permAgent.listModes();
559
572
  // 尝试发送交互卡片
560
573
  if (this.interactionRouter) {
@@ -1193,8 +1206,8 @@ export class CommandHandler {
1193
1206
  lines.push('⚠️ 当前处于安全模式(历史上下文已禁用)');
1194
1207
  lines.push('');
1195
1208
  lines.push('退出方式:');
1196
- lines.push('1. /repair - 检查并修复会话(推荐,保留历史)');
1197
- lines.push('2. /new [名称] - 创建新会话(清空历史)');
1209
+ lines.push('1. /new [名称] - 创建新会话(清空历史)');
1210
+ lines.push('2. 联系管理员使用 /repair 检查并修复会话');
1198
1211
  }
1199
1212
  if (health.lastError) {
1200
1213
  lines.push('');
@@ -1229,7 +1242,7 @@ export class CommandHandler {
1229
1242
  await agent.clearSession(session.id, session.agentSessionId || '', session.projectPath);
1230
1243
  await agent.closeSession(session.id);
1231
1244
  }
1232
- return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /slist 查看`;
1245
+ return `✓ 已创建新会话${sessionName ? `: ${sessionName}` : ''}\n 之前的对话历史已保留,可通过 /s 查看`;
1233
1246
  }
1234
1247
  // /check 命令:检查渠道状态 / 手动重连指定渠道
1235
1248
  if (normalizedContent === '/check' || normalizedContent.startsWith('/check ')) {
@@ -1291,7 +1304,6 @@ export class CommandHandler {
1291
1304
  ? this.statsCollector.getSnapshot().uptimeMs
1292
1305
  : process.uptime() * 1000;
1293
1306
  lines.push(` 运行时间: ${this.formatUptime(uptimeMs)}`);
1294
- lines.push(` 当前安全模式会话: ${this.sessionManager.getSafeModeSessionCount()}`);
1295
1307
  // 近 1 小时统计
1296
1308
  if (this.statsCollector) {
1297
1309
  const snap = this.statsCollector.getSnapshot();
@@ -1311,7 +1323,6 @@ export class CommandHandler {
1311
1323
  lines.push(` 工具失败: ${h.toolErrors} (${toolBreakdown})`);
1312
1324
  }
1313
1325
  lines.push(` 被中断: ${h.interrupts}`);
1314
- lines.push(` 进入安全模式: ${h.safeModeEntries}`);
1315
1326
  if (h.completed > 0) {
1316
1327
  lines.push(` 平均响应耗时: ${(h.avgResponseMs / 1000).toFixed(1)}s`);
1317
1328
  }
@@ -1641,7 +1652,7 @@ export class CommandHandler {
1641
1652
  else {
1642
1653
  projectPath = this.projects[arg];
1643
1654
  if (!projectPath) {
1644
- return `❌ 项目 "${arg}" 不存在\n提示: 使用 /plist 查看可用项目`;
1655
+ return `❌ 项目 "${arg}" 不存在\n提示: 使用 /p 查看可用项目`;
1645
1656
  }
1646
1657
  projectName = arg;
1647
1658
  }
@@ -1746,7 +1757,7 @@ export class CommandHandler {
1746
1757
  请先执行以下操作之一:
1747
1758
  1. 发送任意消息 - 自动创建新会话
1748
1759
  2. /new [名称] - 创建命名会话
1749
- 3. /project <项目> - 切换到指定项目`;
1760
+ 3. /p <项目> - 切换到指定项目`;
1750
1761
  }
1751
1762
  const showCliOnly = normalizedContent === '/slist cli';
1752
1763
  // /slist cli — 仅显示 CLI 会话
@@ -1940,13 +1951,17 @@ export class CommandHandler {
1940
1951
  lines.push('');
1941
1952
  }
1942
1953
  lines.push('使用 /s <序号、name或8位uuid> 切换会话');
1943
- lines.push('使用 /slist cli 查看 CLI 会话');
1954
+ lines.push('使用 /s cli 查看 CLI 会话');
1944
1955
  return lines.join('\n');
1945
1956
  }
1946
1957
  // /session(无参数):直接复用 /slist 逻辑(含卡片交互)
1947
1958
  if (normalizedContent === '/session') {
1948
1959
  return this.handle('/slist', channel, channelId, undefined, userId, threadId);
1949
1960
  }
1961
+ // /session cli(= /s cli):列出未导入的 CLI 会话
1962
+ if (normalizedContent === '/session cli') {
1963
+ return this.handle('/slist cli', channel, channelId, undefined, userId, threadId);
1964
+ }
1950
1965
  // /session 或 /s 命令:切换会话
1951
1966
  if (normalizedContent.startsWith('/session ')) {
1952
1967
  const sessionName = normalizedContent.slice(9).trim();
@@ -1967,7 +1982,7 @@ export class CommandHandler {
1967
1982
  targetSession = visibleSessions[idx - 1];
1968
1983
  }
1969
1984
  else {
1970
- return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
1985
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /s 查看可用会话`;
1971
1986
  }
1972
1987
  }
1973
1988
  if (!targetSession && sessionName.length === 8) {
@@ -1992,7 +2007,7 @@ export class CommandHandler {
1992
2007
  }
1993
2008
  }
1994
2009
  if (!targetSession) {
1995
- return `❌ 会话不存在: ${sessionName}\n使用 /slist 查看可用会话`;
2010
+ return `❌ 会话不存在: ${sessionName}\n使用 /s 查看可用会话`;
1996
2011
  }
1997
2012
  const lastInput = targetSession.agentSessionId
1998
2013
  ? this.sessionManager.readSessionLastUserMessage(targetSession.projectPath, targetSession.agentSessionId, targetSession.agentId)
@@ -2071,14 +2086,14 @@ export class CommandHandler {
2071
2086
  targetSession = visibleSessions[idx - 1];
2072
2087
  }
2073
2088
  else {
2074
- return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /slist 查看可用会话`;
2089
+ return `❌ 序号超出范围 (1-${visibleSessions.length})\n使用 /s 查看可用会话`;
2075
2090
  }
2076
2091
  }
2077
2092
  if (!targetSession && sessionName.length === 8) {
2078
2093
  targetSession = await this.sessionManager.getSessionByUuidPrefix(channel, channelId, sessionName);
2079
2094
  }
2080
2095
  if (!targetSession) {
2081
- return `❌ 会话不存在: ${sessionName}\n使用 /slist 查看可用会话`;
2096
+ return `❌ 会话不存在: ${sessionName}\n使用 /s 查看可用会话`;
2082
2097
  }
2083
2098
  if (targetSession.id === session.id) {
2084
2099
  return `❌ 无法删除当前活跃会话\n请先切换到其他会话`;
@@ -2109,7 +2124,7 @@ export class CommandHandler {
2109
2124
  const forkedSessionId = await forkAgent.forkSession(session.agentSessionId, session.projectPath, forkName);
2110
2125
  const newSession = await this.sessionManager.createForkedSession(session, forkedSessionId, forkName);
2111
2126
  this.eventBus.publish({ type: 'session:forked', sessionId: newSession.id, sourceSessionId: session.id, name: forkName });
2112
- return `✅ 会话已分支: ${newSession.name}\n新会话已激活,可以继续对话\n\n使用 /slist 查看所有会话,/s <名称> 切换回原会话`;
2127
+ return `✅ 会话已分支: ${newSession.name}\n新会话已激活,可以继续对话\n\n使用 /s 查看所有会话,/s <名称> 切换回原会话`;
2113
2128
  }
2114
2129
  catch (error) {
2115
2130
  logger.error('[CommandHandler] Fork session failed:', error);
@@ -78,7 +78,8 @@ export class MessageBridge {
78
78
  // 3. session 解析(使用 Channel 层填充的 chatType)
79
79
  const chatType = msg.chatType || 'private';
80
80
  const metadata = {};
81
- if (msg.replyContext)
81
+ // 话题会话创建时写入 replyContext(用于 threadId 路由);主会话不写(避免群聊覆盖)
82
+ if (msg.threadId && msg.replyContext)
82
83
  metadata.replyContext = msg.replyContext;
83
84
  // 写入实例名(审计 + 精确出站路由)
84
85
  metadata.channelName = channelName;
@@ -151,7 +152,7 @@ export class MessageBridge {
151
152
  if (parsed.type === 'menu.query') {
152
153
  const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
153
154
  const isAdmin = identity.role === 'owner';
154
- const items = this.cmdHandler.getMenuItems(isAdmin);
155
+ const items = this.cmdHandler.getMenuItems(isAdmin, msg.chatType || 'private');
155
156
  const response = JSON.stringify({ type: 'menu.response', items });
156
157
  if (adapter?.sendCustomPayload) {
157
158
  adapter.sendCustomPayload(msg.channelId, response);
@@ -181,7 +181,7 @@ export class MessageProcessor {
181
181
  const msg = showIdleMonitor
182
182
  ? result.message
183
183
  : `\u26a0\ufe0f 任务超时(${result.idleSec}秒无响应),已自动中断`;
184
- channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(session)).catch(e => {
184
+ channelInfo.adapter.sendText(message.channelId, msg, this.getReplyContext(message)).catch(e => {
185
185
  logger.debug(`[MessageProcessor] Failed to send kill diagnostic message:`, e);
186
186
  });
187
187
  }
@@ -196,7 +196,7 @@ export class MessageProcessor {
196
196
  logger.info(`[MessageProcessor] Idle monitor: ${result.action} after ${result.idleSec}s idle, stream: ${streamKey}`);
197
197
  if (channelInfo && showIdleMonitor && !shouldSuppress()) {
198
198
  if (!isBackground) {
199
- channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(session)).catch(e => {
199
+ channelInfo.adapter.sendText(message.channelId, result.message, this.getReplyContext(message)).catch(e => {
200
200
  logger.debug(`[MessageProcessor] Failed to send idle monitor message:`, e);
201
201
  });
202
202
  }
@@ -237,7 +237,7 @@ export class MessageProcessor {
237
237
  else {
238
238
  const prefixed = prefixErrorType(ERROR_PREFIX.INFRA, errorType);
239
239
  const newCount = await this.sessionManager.recordError(session.id, prefixed, error.message);
240
- await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount);
240
+ await this.checkSafeMode(session, message.channelId, channelInfo.adapter, safeModeThreshold, newCount, message);
241
241
  }
242
242
  }
243
243
  catch (statusError) {
@@ -251,18 +251,18 @@ export class MessageProcessor {
251
251
  clearInterval(monitorInterval);
252
252
  }
253
253
  }
254
- /** session 提取渠道预构建的回复上下文 */
255
- getReplyContext(session) {
256
- return session.metadata?.replyContext;
254
+ /** 获取回复上下文(跟着任务走) */
255
+ getReplyContext(message) {
256
+ return message.replyContext;
257
257
  }
258
258
  /**
259
259
  * 检查是否需要进入安全模式(safeModeThreshold 为 0 时跳过)
260
260
  */
261
- async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors) {
261
+ async checkSafeMode(session, channelId, adapter, safeModeThreshold, consecutiveErrors, message) {
262
262
  if (safeModeThreshold <= 0)
263
263
  return;
264
264
  const health = await this.sessionManager.getHealthStatus(session.id);
265
- const sendOpts = this.getReplyContext(session);
265
+ const sendOpts = this.getReplyContext(message);
266
266
  const isThread = !!session.threadId;
267
267
  if (consecutiveErrors >= safeModeThreshold && !health.safeMode) {
268
268
  await this.sessionManager.setSafeMode(session.id, true);
@@ -324,7 +324,7 @@ ${suggestions}`, sendOpts);
324
324
  logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
325
325
  // 记录开始处理
326
326
  this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
327
- adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(session));
327
+ adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(message));
328
328
  logger.message({
329
329
  msgId: messageId,
330
330
  sessionId: session.id,
@@ -341,8 +341,8 @@ ${suggestions}`, sendOpts);
341
341
  const opts = {};
342
342
  if (isFinal)
343
343
  opts.title = '\u2713 \u6700\u7ec8\u56de\u590d:';
344
- // 话题会话:使用 Channel 预构建的 replyContext(确保消息进入话题)
345
- const replyCtx = session.metadata?.replyContext;
344
+ // replyContext 跟着任务走:优先用当前 message 的,兜底用 session 的(话题会话创建时写入)
345
+ const replyCtx = this.getReplyContext(message);
346
346
  if (replyCtx) {
347
347
  Object.assign(opts, replyCtx);
348
348
  }
@@ -362,17 +362,18 @@ ${suggestions}`, sendOpts);
362
362
  // 调用 AgentRunner(含上下文过长自动 compact 重试)
363
363
  // 设置权限审批的消息发送回调(指向当前渠道)
364
364
  agent.setSendPrompt(async (text) => {
365
- await adapter.sendText(message.channelId, text, this.getReplyContext(session));
365
+ await adapter.sendText(message.channelId, text, this.getReplyContext(message));
366
366
  });
367
367
  // 设置权限审批的交互上下文(支持交互卡片)
368
368
  agent.setPermissionContext?.({
369
369
  adapter,
370
370
  channelId: message.channelId,
371
- replyContext: this.getReplyContext(session),
371
+ replyContext: this.getReplyContext(message),
372
372
  interactionRouter: this.interactionRouter,
373
373
  });
374
- // 设置 per-session 权限模式
375
- agent.setMode(session.metadata?.permissionMode ?? 'bypass');
374
+ // 设置 per-session 权限模式(动态默认值:owner → bypass,guest → readonly)
375
+ const defaultPermMode = session.identity?.role === 'owner' ? 'bypass' : 'readonly';
376
+ agent.setMode(session.metadata?.permissionMode ?? defaultPermMode);
376
377
  // 标记会话为处理中(实时持久化,重启后可恢复)
377
378
  this.sessionManager.markProcessing(session.id);
378
379
  // 检查是否因新消息自动中断 — 包装 prompt 让 Agent 知道上下文
@@ -435,6 +436,10 @@ ${suggestions}`, sendOpts);
435
436
  if (capParts.length > 0) {
436
437
  contextParts.push(`[通道能力] ${capParts.join('、')}`);
437
438
  }
439
+ // 4. 群聊 @ 规则:告知 agent 应该 @ 谁,由 agent 自行在回复中添加
440
+ if (message.chatType === 'group' && message.peerId) {
441
+ contextParts.push(`[群聊回复规则] 回复时必须在开头添加 @${message.peerId} 来通知对方`);
442
+ }
438
443
  const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
439
444
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
440
445
  const MAX_RETRIES = 3;
@@ -515,22 +520,22 @@ ${suggestions}`, sendOpts);
515
520
  && targetSpec !== currentChannelType;
516
521
  // 跨通道仅限 owner
517
522
  if (isCrossChannel && session.identity?.role !== 'owner') {
518
- await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(session));
523
+ await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(message));
519
524
  continue;
520
525
  }
521
526
  const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
522
527
  if (!fs.existsSync(resolvedPath)) {
523
528
  logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
524
- await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(session));
529
+ await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(message));
525
530
  continue;
526
531
  }
527
532
  // 找目标 adapter
528
533
  if (!targetInfo) {
529
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(session));
534
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(message));
530
535
  continue;
531
536
  }
532
537
  if (!targetInfo.adapter.sendFile) {
533
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(session));
538
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(message));
534
539
  continue;
535
540
  }
536
541
  // 找目标 channelId
@@ -541,21 +546,21 @@ ${suggestions}`, sendOpts);
541
546
  const ownerPeerId = getOwner(this.config, targetAdapterName);
542
547
  targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
543
548
  if (!targetChannelId) {
544
- await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(session));
549
+ await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(message));
545
550
  continue;
546
551
  }
547
552
  }
548
553
  logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
549
554
  try {
550
- await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(session));
555
+ await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(message));
551
556
  this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
552
557
  if (isCrossChannel) {
553
- await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(session));
558
+ await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(message));
554
559
  }
555
560
  }
556
561
  catch (error) {
557
562
  logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
558
- await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(session));
563
+ await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(message));
559
564
  }
560
565
  }
561
566
  // 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
@@ -580,7 +585,7 @@ ${suggestions}`, sendOpts);
580
585
  const hint = session.threadId
581
586
  ? '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /clear 清空会话'
582
587
  : '\n\n\u26a0\ufe0f 当前处于安全模式(无上下文记忆)。使用 /repair 修复 或 /new 新建会话';
583
- await adapter.sendText(message.channelId, hint, this.getReplyContext(session));
588
+ await adapter.sendText(message.channelId, hint, this.getReplyContext(message));
584
589
  }
585
590
  // 清理 activeStreams(正常完成)
586
591
  agent.cleanupStream(streamKey);
@@ -593,7 +598,7 @@ ${suggestions}`, sendOpts);
593
598
  const errorSummary = streamResult.errors?.join('; ') || '任务执行失败';
594
599
  const rawSubtype = streamResult.subtype || 'agent_error';
595
600
  const errorType = prefixErrorType(ERROR_PREFIX.AGENT, rawSubtype);
596
- adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(session));
601
+ adapter.sendProcessingStatus?.(message.channelId, 'error', session.id, this.getReplyContext(message));
597
602
  this.eventBus.publish({
598
603
  type: 'message:error',
599
604
  sessionId: session.id,
@@ -609,7 +614,7 @@ ${suggestions}`, sendOpts);
609
614
  const { policy } = channelInfo;
610
615
  if (policy.accumulateErrors(chatType, identityRole)) {
611
616
  const newCount = await this.sessionManager.recordError(session.id, errorType, errorSummary);
612
- await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount);
617
+ await this.checkSafeMode(session, message.channelId, adapter, safeModeThreshold, newCount, message);
613
618
  }
614
619
  }
615
620
  logger.message({
@@ -623,7 +628,7 @@ ${suggestions}`, sendOpts);
623
628
  }
624
629
  else {
625
630
  // 真正的成功
626
- adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(session));
631
+ adapter.sendProcessingStatus?.(message.channelId, interruptReason ? 'interrupted' : 'done', session.id, this.getReplyContext(message));
627
632
  await this.sessionManager.recordSuccess(session.id);
628
633
  this.eventBus.publish({
629
634
  type: 'message:completed',
@@ -676,7 +681,7 @@ ${suggestions}`, sendOpts);
676
681
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
677
682
  if (!isUserInterrupt) {
678
683
  try {
679
- adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(session));
684
+ adapter.sendProcessingStatus?.(message.channelId, procStatus, session.id, this.getReplyContext(message));
680
685
  }
681
686
  catch { }
682
687
  }
@@ -724,7 +729,7 @@ ${suggestions}`, sendOpts);
724
729
  let sendOpts;
725
730
  try {
726
731
  const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId);
727
- sendOpts = this.getReplyContext(session);
732
+ sendOpts = this.getReplyContext(message);
728
733
  }
729
734
  catch { }
730
735
  await adapter.sendText(message.channelId, userMessage, sendOpts);
@@ -735,11 +740,12 @@ ${suggestions}`, sendOpts);
735
740
  * 解析会话和项目路径
736
741
  */
737
742
  async resolveSession(message) {
738
- // 话题会话:使用 Channel 预构建的 replyContext
739
- const metadata = message.replyContext
743
+ // 话题会话创建时写入 replyContext(threadId 路由);主会话不写(避免群聊覆盖)
744
+ const metadata = (message.threadId && message.replyContext)
740
745
  ? { replyContext: message.replyContext }
741
746
  : undefined;
742
747
  const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, this.config.projects?.defaultPath || process.cwd(), message.threadId, metadata, undefined, message.peerId);
748
+ // replyContext 不再写入 session.metadata(跟着 message 走,避免群聊多人覆盖)
743
749
  const absoluteProjectPath = path.isAbsolute(session.projectPath)
744
750
  ? session.projectPath
745
751
  : path.resolve(process.cwd(), session.projectPath);
@@ -798,6 +798,10 @@ export class SessionManager {
798
798
  .run(JSON.stringify(metadata), Date.now(), targetSessionId);
799
799
  return { ...this.rowToSession(target), metadata, updatedAt: Date.now() };
800
800
  }
801
+ updateMetadata(sessionId, metadata) {
802
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
803
+ .run(JSON.stringify(metadata), Date.now(), sessionId);
804
+ }
801
805
  async renameSession(sessionId, newName) {
802
806
  const result = this.db.prepare(`
803
807
  UPDATE sessions SET name = ?, updated_at = ? WHERE id = ?
package/dist/index.js CHANGED
@@ -214,11 +214,11 @@ async function main() {
214
214
  await handler({
215
215
  channel: channelType, channelId: chatId, content, images, chatType,
216
216
  peerId: peerId || '', peerName, messageId, mentions, threadId,
217
- replyContext: rootId ? { replyToMessageId: rootId, replyInThread: true } : undefined,
217
+ replyContext: rootId ? { replyToMessageId: rootId, replyInThread: !!threadId } : undefined,
218
218
  });
219
219
  }), (channelId, text, replyContext) => inst.channel.sendMessage(channelId, text, {
220
220
  replyToMessageId: replyContext?.replyToMessageId,
221
- replyInThread: true,
221
+ replyInThread: replyContext?.replyInThread,
222
222
  }), inst.adapter, channelType);
223
223
  inst.channel.onRecall?.((messageId) => {
224
224
  msgBridge.cancel(messageId);
@@ -303,18 +303,17 @@ async function main() {
303
303
  const sourceChannelName = event.channelName || sourceChannelType;
304
304
  const msg = event.message;
305
305
  logger.error(`[ChannelHealth] ${sourceChannelName} auth_error: ${msg}`);
306
- const notified = new Set(); // channelType:ownerId 去重
306
+ const notified = new Set(); // channelType 去重(同类型只通知一次)
307
307
  for (const other of channelInstances) {
308
308
  const otherType = other.channelType || other.adapter.channelName;
309
309
  if (otherType === sourceChannelType)
310
310
  continue; // 跳过同类型通道
311
+ if (notified.has(otherType))
312
+ continue; // 同类型已通知过
311
313
  const ownerId = getOwner(config, other.adapter.channelName);
312
314
  if (!ownerId)
313
315
  continue;
314
- const key = `${otherType}:${ownerId}`;
315
- if (notified.has(key))
316
- continue; // 同类型已通知过此 owner
317
- notified.add(key);
316
+ notified.add(otherType);
318
317
  other.adapter.sendText(ownerId, msg).catch(err => {
319
318
  logger.error(`[ChannelHealth] Failed to notify ${other.adapter.channelName} owner:`, err);
320
319
  });
@@ -380,7 +379,7 @@ async function main() {
380
379
  const adapter = cmdHandler.getAdapter(pending.channel);
381
380
  if (adapter) {
382
381
  const replyContext = pending.rootId
383
- ? { replyToMessageId: pending.rootId, replyInThread: true }
382
+ ? { replyToMessageId: pending.rootId, replyInThread: !!pending.threadId }
384
383
  : undefined;
385
384
  await adapter.sendText(pending.channelId, '✅ 服务重启成功!', replyContext);
386
385
  logger.info(`[Restart] Notification sent via ${pending.channel}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",