evolclaw 2.5.4 → 2.5.6

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.
@@ -127,7 +127,7 @@ export class MessageProcessor {
127
127
  static COMMAND_PREFIXES = [
128
128
  '/new', '/pwd', '/plist', '/project', '/bind', '/help', '/status', '/restart',
129
129
  '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork',
130
- '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/send', '/check',
130
+ '/stop', '/clear', '/compact', '/safe', '/del', '/perm', '/file', '/check',
131
131
  '/p ', '/s ', '/name ',
132
132
  ];
133
133
  /** 判断消息内容是否为已知命令 */
@@ -299,6 +299,7 @@ export class MessageProcessor {
299
299
  const imageInfo = message.images && message.images.length > 0 ? ` [${message.images.length} image(s)]` : '';
300
300
  const modeInfo = isBackground ? ' [\u540e\u53f0]' : '';
301
301
  logger.info(`[${message.channel}] ${message.channelId}: ${message.content}${imageInfo}${modeInfo}`);
302
+ logger.info(`[MessageProcessor] session=${session.id} chatType=${session.chatType} sessionMode=${session.sessionMode} agentId=${session.agentId} msgChatType=${message.chatType ?? 'n/a'}`);
302
303
  // 记录开始处理
303
304
  this.eventBus.publish({ type: 'message:processing', sessionId: session.id });
304
305
  adapter.sendProcessingStatus?.(message.channelId, 'start', session.id, this.getReplyContext(message));
@@ -312,6 +313,7 @@ export class MessageProcessor {
312
313
  // 创建 StreamFlusher,传入文件标记模式用于自动过滤
313
314
  // 使用动态判断,确保切换项目后不会继续输出
314
315
  let firstReply = true;
316
+ const isProactive = session.sessionMode === 'proactive';
315
317
  const flusher = new StreamFlusher(async (text, isFinal, hasText) => {
316
318
  const isCurrentlyBackground = await this.isBackgroundSession(session, message.channel, message.channelId);
317
319
  if (!isCurrentlyBackground) {
@@ -333,7 +335,7 @@ export class MessageProcessor {
333
335
  await adapter.sendText(message.channelId, text, Object.keys(opts).length ? opts : undefined);
334
336
  }
335
337
  // 后台任务:静默,不发送输出
336
- }, (options?.flushDelay ?? this.config.flushDelay ?? 3) * 1000, options?.fileMarkerPattern, this.config.debug?.flusherDiag);
338
+ }, (options?.flushDelay ?? this.config.flushDelay ?? 3) * 1000, options?.fileMarkerPattern, this.config.debug?.flusherDiag, isProactive);
337
339
  // 保存当前 flusher,用于 compact 事件
338
340
  this.currentFlusher = flusher;
339
341
  // 调用 AgentRunner(含上下文过长自动 compact 重试)
@@ -396,24 +398,30 @@ export class MessageProcessor {
396
398
  contextParts.push(`[当前环境] ${envParts.join(' | ')}`);
397
399
  // 只读模式提示
398
400
  if (session.metadata?.permissionMode === 'readonly') {
399
- contextParts.push('[只读模式] 禁止修改项目文件。如需生成文件供用户下载,请写入 .evolclaw/tmp/ 目录后使用 [SEND_FILE:] 发送');
401
+ const sendHint = isProactive
402
+ ? '使用 evolclaw ctl file 发送'
403
+ : '使用 [SEND_FILE:] 发送';
404
+ contextParts.push(`[只读模式] 禁止修改项目文件。如需生成文件供用户下载,请写入 .evolclaw/tmp/ 目录后${sendHint}`);
400
405
  }
401
406
  // 2. 文件发送能力(按 channelType 去重,提示词只展示第一级通道名)
402
- const fileChannelTypes = new Set();
403
- const currentCanSend = !!channelInfo.adapter.sendFile;
404
- for (const [, info] of this.channels) {
405
- if (info.adapter.sendFile) {
406
- fileChannelTypes.add(info.options?.channelType || info.adapter.channelName);
407
+ // proactive 模式:不推送 [SEND_FILE:] 提示,统一通过 evolclaw ctl file 显式发送(与 ctl send 契约一致)
408
+ if (!isProactive) {
409
+ const fileChannelTypes = new Set();
410
+ const currentCanSend = !!channelInfo.adapter.sendFile;
411
+ for (const [, info] of this.channels) {
412
+ if (info.adapter.sendFile) {
413
+ fileChannelTypes.add(info.options?.channelType || info.adapter.channelName);
414
+ }
415
+ }
416
+ const crossChannelTypes = [...fileChannelTypes].filter(t => t !== currentChannelType);
417
+ if (currentCanSend || crossChannelTypes.length > 0) {
418
+ const hints = [];
419
+ if (currentCanSend)
420
+ hints.push(`[SEND_FILE:路径] 发送文件到当前通道`);
421
+ if (crossChannelTypes.length > 0)
422
+ hints.push(`[SEND_FILE:${crossChannelTypes[0]}:路径] 发送文件到指定通道(可用: ${crossChannelTypes.join('/')})`);
423
+ contextParts.push(hints.join(','));
407
424
  }
408
- }
409
- const crossChannelTypes = [...fileChannelTypes].filter(t => t !== currentChannelType);
410
- if (currentCanSend || crossChannelTypes.length > 0) {
411
- const hints = [];
412
- if (currentCanSend)
413
- hints.push(`[SEND_FILE:路径] 发送文件到当前通道`);
414
- if (crossChannelTypes.length > 0)
415
- hints.push(`[SEND_FILE:${crossChannelTypes[0]}:路径] 发送文件到指定通道(可用: ${crossChannelTypes.join('/')})`);
416
- contextParts.push(hints.join(','));
417
425
  }
418
426
  // 3. 当前通道能力
419
427
  const capParts = [];
@@ -439,6 +447,13 @@ export class MessageProcessor {
439
447
  if (skillsHint) {
440
448
  contextParts.push(`[EvolClaw 自管理] ${skillsHint}`);
441
449
  }
450
+ // 6. Proactive 模式提示词:agent 的输出不会自动发送,必须主动调用 ctl send/file
451
+ if (isProactive) {
452
+ contextParts.push('[Proactive 模式] 本次对话中你的流式输出不会自动发送给用户,必须通过以下命令主动发送:\n' +
453
+ '- 发送文本:evolclaw ctl send "<消息内容>"\n' +
454
+ '- 发送文件:evolclaw ctl file <路径>\n' +
455
+ '可多次调用。如不调用,用户将看不到任何回复。');
456
+ }
442
457
  const effectiveSystemPrompt = [options?.systemPromptAppend, ...contextParts].filter(Boolean).join('\n') || undefined;
443
458
  // 可重试错误(403/429/5xx)指数退避重试,最多 3 次
444
459
  const MAX_RETRIES = 3;
@@ -491,77 +506,80 @@ export class MessageProcessor {
491
506
  // 处理文件标记 - 支持 [SEND_FILE:path] 和 [SEND_FILE:channel:path]
492
507
  // 注意:始终扫描全部文本(含中间轮),因为文件标记可能出现在任意轮次
493
508
  // suppressed 模式下 flusher 只有最后一轮文本,需要用 streamResult.fullText(SDK 全文)兜底
494
- const FILE_MARKER_RE = /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
495
- const markerPattern = options?.fileMarkerPattern ?? FILE_MARKER_RE;
496
- const flusherText = flusher.getFinalText();
497
- const fullText = flusherText.length >= (streamResult.fullText?.length || 0) ? flusherText : streamResult.fullText;
498
- const fileMatches = [...fullText.matchAll(markerPattern)];
499
- for (const match of fileMatches) {
500
- // 兼容旧格式 (1组) 和新格式 (2组)
501
- const hasChannelGroup = match.length >= 3;
502
- const targetSpec = hasChannelGroup ? (match[1] ?? undefined) : undefined;
503
- const filePath = (hasChannelGroup ? match[2] : match[1]).trim();
504
- if (this.isPlaceholderPath(filePath)) {
505
- logger.info(`[${adapter.channelName}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
506
- continue;
507
- }
508
- // 解析目标:按实例名匹配,再按 channelType 映射
509
- let targetInfo = targetSpec ? this.channels.get(targetSpec) : channelInfo;
510
- let targetLabel = targetSpec || message.channel;
511
- if (targetSpec && !targetInfo) {
512
- // 按 channelType 查找首个匹配的实例
513
- const instanceName = this.channelTypeMap.get(targetSpec);
514
- if (instanceName)
515
- targetInfo = this.channels.get(instanceName);
516
- }
517
- const currentChannelType = channelInfo.options?.channelType || adapter.channelName;
518
- const isCrossChannel = targetSpec && targetSpec !== message.channel
519
- && targetSpec !== currentChannelType;
520
- // 跨通道仅限 owner
521
- if (isCrossChannel && session.identity?.role !== 'owner') {
522
- await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(message));
523
- continue;
524
- }
525
- const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
526
- if (!fs.existsSync(resolvedPath)) {
527
- logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
528
- await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(message));
529
- continue;
530
- }
531
- // 找目标 adapter
532
- if (!targetInfo) {
533
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(message));
534
- continue;
535
- }
536
- if (!targetInfo.adapter.sendFile) {
537
- await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(message));
538
- continue;
539
- }
540
- // 找目标 channelId
541
- let targetChannelId = message.channelId;
542
- if (isCrossChannel) {
543
- const targetAdapterName = targetInfo.adapter.channelName;
544
- const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
545
- const ownerPeerId = getOwner(this.config, targetAdapterName);
546
- targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
547
- if (!targetChannelId) {
548
- await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(message));
509
+ // proactive 模式:agent 主动调用 ctl file 发送文件,跳过标记处理
510
+ if (!isProactive) {
511
+ const FILE_MARKER_RE = /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g;
512
+ const markerPattern = options?.fileMarkerPattern ?? FILE_MARKER_RE;
513
+ const flusherText = flusher.getFinalText();
514
+ const fullText = flusherText.length >= (streamResult.fullText?.length || 0) ? flusherText : streamResult.fullText;
515
+ const fileMatches = [...fullText.matchAll(markerPattern)];
516
+ for (const match of fileMatches) {
517
+ // 兼容旧格式 (1组) 和新格式 (2组)
518
+ const hasChannelGroup = match.length >= 3;
519
+ const targetSpec = hasChannelGroup ? (match[1] ?? undefined) : undefined;
520
+ const filePath = (hasChannelGroup ? match[2] : match[1]).trim();
521
+ if (this.isPlaceholderPath(filePath)) {
522
+ logger.info(`[${adapter.channelName}] Skipped placeholder file marker: [SEND_FILE:${filePath}]`);
549
523
  continue;
550
524
  }
551
- }
552
- logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
553
- try {
554
- await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(message));
555
- this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
525
+ // 解析目标:按实例名匹配,再按 channelType 映射
526
+ let targetInfo = targetSpec ? this.channels.get(targetSpec) : channelInfo;
527
+ let targetLabel = targetSpec || message.channel;
528
+ if (targetSpec && !targetInfo) {
529
+ // channelType 查找首个匹配的实例
530
+ const instanceName = this.channelTypeMap.get(targetSpec);
531
+ if (instanceName)
532
+ targetInfo = this.channels.get(instanceName);
533
+ }
534
+ const currentChannelType = channelInfo.options?.channelType || adapter.channelName;
535
+ const isCrossChannel = targetSpec && targetSpec !== message.channel
536
+ && targetSpec !== currentChannelType;
537
+ // 跨通道仅限 owner
538
+ if (isCrossChannel && session.identity?.role !== 'owner') {
539
+ await adapter.sendText(message.channelId, `\u274c 跨通道发送仅限管理员`, this.getReplyContext(message));
540
+ continue;
541
+ }
542
+ const resolvedPath = this.resolveFilePath(filePath, absoluteProjectPath);
543
+ if (!fs.existsSync(resolvedPath)) {
544
+ logger.warn(`[${adapter.channelName}] File not found: ${resolvedPath}`);
545
+ await adapter.sendText(message.channelId, `\u26a0\ufe0f 文件未找到: ${filePath}`, this.getReplyContext(message));
546
+ continue;
547
+ }
548
+ // 找目标 adapter
549
+ if (!targetInfo) {
550
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 未启用或不存在`, this.getReplyContext(message));
551
+ continue;
552
+ }
553
+ if (!targetInfo.adapter.sendFile) {
554
+ await adapter.sendText(message.channelId, `\u274c 通道 ${targetLabel} 不支持文件发送`, this.getReplyContext(message));
555
+ continue;
556
+ }
557
+ // 找目标 channelId
558
+ let targetChannelId = message.channelId;
556
559
  if (isCrossChannel) {
557
- await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(message));
560
+ const targetAdapterName = targetInfo.adapter.channelName;
561
+ const targetChannelType = targetInfo.options?.channelType || targetAdapterName;
562
+ const ownerPeerId = getOwner(this.config, targetAdapterName);
563
+ targetChannelId = ownerPeerId ? (this.sessionManager.getOwnerChatId(targetChannelType, ownerPeerId) ?? '') : '';
564
+ if (!targetChannelId) {
565
+ await adapter.sendText(message.channelId, `\u274c 未找到 ${targetLabel} 的私聊会话,请先在该通道发送一条消息`, this.getReplyContext(message));
566
+ continue;
567
+ }
568
+ }
569
+ logger.info(`[${adapter.channelName}] Sending file via ${targetInfo.adapter.channelName}: ${resolvedPath}`);
570
+ try {
571
+ await targetInfo.adapter.sendFile(targetChannelId, resolvedPath, this.getReplyContext(message));
572
+ this.eventBus.publish({ type: 'agent:file-sent', sessionId: session.id, filePath: resolvedPath, channel: targetInfo.adapter.channelName });
573
+ if (isCrossChannel) {
574
+ await adapter.sendText(message.channelId, `\ud83d\udcce 文件已通过 ${targetLabel} 发送`, this.getReplyContext(message));
575
+ }
576
+ }
577
+ catch (error) {
578
+ logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
579
+ await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(message));
558
580
  }
559
581
  }
560
- catch (error) {
561
- logger.error(`[${adapter.channelName}] Failed to send file: ${resolvedPath}`, error);
562
- await adapter.sendText(message.channelId, `\u274c 文件发送失败: ${filePath}`, this.getReplyContext(message));
563
- }
564
- }
582
+ } // end of !isProactive
565
583
  // 最终回复文本添加到 flusher(统一在流结束后处理,避免多 complete 事件重复发送)
566
584
  // suppressed 模式:中间流式文本未推送,使用最后一轮回复(回退到全文)
567
585
  // 非 suppressed 且无流式文本:同上
@@ -663,7 +681,7 @@ export class MessageProcessor {
663
681
  // 区分超时 / 中断 / 错误
664
682
  const errType = classifyError(error);
665
683
  const interruptReason = this.interruptedSessions.get(session.id);
666
- const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
684
+ const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
667
685
  const procStatus = errType === ErrorType.SDK_TIMEOUT ? 'timeout'
668
686
  : errType === ErrorType.STREAM_ERROR ? 'interrupted'
669
687
  : 'error';
@@ -871,7 +889,7 @@ export class MessageProcessor {
871
889
  // 失败且无前置错误输出:显示 errors 摘要
872
890
  // 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
873
891
  const interruptReason = this.interruptedSessions.get(session.id);
874
- const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop';
892
+ const isUserInterrupt = interruptReason === 'new_message' || interruptReason === 'stop' || interruptReason === 'recalled';
875
893
  if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt) {
876
894
  const errorSummary = event.errors?.join('; ') || '\u4efb\u52a1\u6267\u884c\u5931\u8d25';
877
895
  // 使用 terminalReason 提供更友好的错误提示
@@ -32,6 +32,7 @@ let instanceCounter = 0;
32
32
  export class StreamFlusher {
33
33
  send;
34
34
  interval;
35
+ silent;
35
36
  buffer = '';
36
37
  queue = []; // 按入队顺序记录 activity 和 text 段
37
38
  timer;
@@ -45,14 +46,15 @@ export class StreamFlusher {
45
46
  createTime = Date.now();
46
47
  diagEnabled;
47
48
  sendChain = Promise.resolve(); // 串行发送队列,保证消息按序到达
48
- constructor(send, interval = 4000, fileMarkerPattern, diagEnabled = false) {
49
+ constructor(send, interval = 4000, fileMarkerPattern, diagEnabled = false, silent = false) {
49
50
  this.send = send;
50
51
  this.interval = interval;
52
+ this.silent = silent;
51
53
  this.fileMarkerPattern = fileMarkerPattern;
52
54
  this.diagEnabled = diagEnabled;
53
55
  this.instanceId = `F${++instanceCounter}`;
54
56
  if (this.diagEnabled)
55
- diag(this.instanceId, 'created', { interval });
57
+ diag(this.instanceId, 'created', { interval, silent });
56
58
  }
57
59
  addText(text) {
58
60
  if (this.buffer.length === 0 && text.length > 0) {
@@ -101,6 +103,8 @@ export class StreamFlusher {
101
103
  this.buffer = this.buffer.replace(pattern, '').trim();
102
104
  }
103
105
  scheduleFlush() {
106
+ if (this.silent)
107
+ return; // proactive 模式:不调度发送
104
108
  if (this.timer) {
105
109
  if (this.diagEnabled)
106
110
  diag(this.instanceId, 'scheduleFlush:skip', { reason: 'timer_exists' });
@@ -144,6 +148,8 @@ export class StreamFlusher {
144
148
  * 用于 complete 事件前清空 pending activities,让最终文本留给 flush(true) 发送
145
149
  */
146
150
  async flushActivitiesOnly() {
151
+ if (this.silent)
152
+ return;
147
153
  const hasActivities = this.queue.some(e => e.kind === 'activity');
148
154
  if (!hasActivities)
149
155
  return;
@@ -173,6 +179,16 @@ export class StreamFlusher {
173
179
  }
174
180
  }
175
181
  async flush(isFinal) {
182
+ if (this.silent) {
183
+ // 清理内部状态,避免后续误用
184
+ if (this.timer) {
185
+ clearTimeout(this.timer);
186
+ this.timer = undefined;
187
+ }
188
+ this.queue = [];
189
+ this.buffer = '';
190
+ return;
191
+ }
176
192
  if (this.timer) {
177
193
  clearTimeout(this.timer);
178
194
  this.timer = undefined;
@@ -10,6 +10,7 @@ export class SessionManager {
10
10
  eventBus;
11
11
  ownerResolver;
12
12
  adminResolver;
13
+ sessionModeResolver;
13
14
  fileAdapters = new Map();
14
15
  constructor(dbPath = resolvePaths().db, eventBus, ownerResolver, adminResolver) {
15
16
  ensureDir(path.dirname(dbPath));
@@ -25,6 +26,15 @@ export class SessionManager {
25
26
  setAdminResolver(resolver) {
26
27
  this.adminResolver = resolver;
27
28
  }
29
+ setSessionModeResolver(resolver) {
30
+ this.sessionModeResolver = resolver;
31
+ }
32
+ /** 解析默认 sessionMode:通道配置锁定 > chatType 默认 > 'interactive' */
33
+ resolveDefaultSessionMode(channel, chatType) {
34
+ const ct = chatType || 'private';
35
+ const resolved = this.sessionModeResolver?.(channel, ct);
36
+ return resolved || 'interactive';
37
+ }
28
38
  registerFileAdapter(adapter) {
29
39
  this.fileAdapters.set(adapter.agentId, adapter);
30
40
  logger.debug(`[SessionManager] Registered file adapter: ${adapter.agentId}`);
@@ -93,6 +103,15 @@ export class SessionManager {
93
103
  `).run(JSON.stringify(metadata), Date.now(), row.id);
94
104
  }
95
105
  }
106
+ /** 获取当前活跃 session 的 chatType(用于新建 session 时继承) */
107
+ getActiveChatType(channel, channelId) {
108
+ const row = this.db.prepare(`
109
+ SELECT chat_type FROM sessions
110
+ WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true AND thread_id = '' AND deleted_at IS NULL
111
+ ORDER BY updated_at DESC LIMIT 1
112
+ `).get(channel, channelId);
113
+ return row?.chat_type || 'private';
114
+ }
96
115
  validateSessionFile(row) {
97
116
  const agentSessionId = row.agent_session_id;
98
117
  if (!agentSessionId)
@@ -447,6 +466,13 @@ export class SessionManager {
447
466
  const validSessionId = this.validateSessionFile(active);
448
467
  const session = { ...this.rowToSession(active), agentSessionId: validSessionId };
449
468
  session.identity = this.resolveIdentity(channel, userId);
469
+ // chatType 自动修正:入站 chatType 与存储值不一致时更新(修复历史 session 因错误识别留下的脏数据)
470
+ if (chatType && session.chatType !== chatType) {
471
+ logger.info(`[SessionManager] Updating chatType for session ${session.id}: ${session.chatType} -> ${chatType}`);
472
+ this.db.prepare(`UPDATE sessions SET chat_type = ?, updated_at = ? WHERE id = ?`)
473
+ .run(chatType, Date.now(), active.id);
474
+ session.chatType = chatType;
475
+ }
450
476
  // 补写 peerId/peerName/channelName(旧 session 可能在这些字段引入前创建)
451
477
  if (chatType === 'private' && userId) {
452
478
  const activeMeta = active.metadata ? JSON.parse(active.metadata) : {};
@@ -492,6 +518,12 @@ export class SessionManager {
492
518
  // 激活此会话
493
519
  const existingMeta = existing.metadata ? JSON.parse(existing.metadata) : {};
494
520
  existingMeta.isActive = true;
521
+ // chatType 自动修正(同 active 分支)
522
+ const shouldUpdateChatType = chatType !== undefined && existing.chat_type !== chatType;
523
+ if (shouldUpdateChatType) {
524
+ logger.info(`[SessionManager] Updating chatType for session ${existing.id}: ${existing.chat_type} -> ${chatType}`);
525
+ existing.chat_type = chatType;
526
+ }
495
527
  // 补写 peerId/peerName
496
528
  if (chatType === 'private' && userId && !existingMeta.peerId) {
497
529
  existingMeta.peerId = userId;
@@ -499,8 +531,14 @@ export class SessionManager {
499
531
  if (chatType === 'private' && metadata?.peerName && !existingMeta.peerName) {
500
532
  existingMeta.peerName = metadata.peerName;
501
533
  }
502
- this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
503
- .run(JSON.stringify(existingMeta), Date.now(), existing.id);
534
+ if (shouldUpdateChatType) {
535
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ?, chat_type = ? WHERE id = ?`)
536
+ .run(JSON.stringify(existingMeta), Date.now(), chatType, existing.id);
537
+ }
538
+ else {
539
+ this.db.prepare(`UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?`)
540
+ .run(JSON.stringify(existingMeta), Date.now(), existing.id);
541
+ }
504
542
  const session = { ...this.rowToSession(existing), agentSessionId: validSessionId, metadata: existingMeta };
505
543
  session.identity = this.resolveIdentity(channel, userId);
506
544
  return session;
@@ -515,7 +553,7 @@ export class SessionManager {
515
553
  threadId: '',
516
554
  agentId: agentId || 'claude',
517
555
  chatType: chatType || 'private',
518
- sessionMode: 'interactive',
556
+ sessionMode: this.resolveDefaultSessionMode(channel, chatType || 'private'),
519
557
  metadata: sessionMetadata,
520
558
  name: name || '默认会话',
521
559
  createdAt: Date.now(),
@@ -550,6 +588,10 @@ export class SessionManager {
550
588
  sets.push('name = ?');
551
589
  values.push(updates.name);
552
590
  }
591
+ if (updates.sessionMode !== undefined) {
592
+ sets.push('session_mode = ?');
593
+ values.push(updates.sessionMode);
594
+ }
553
595
  if (updates.metadata !== undefined) {
554
596
  sets.push('metadata = ?');
555
597
  values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
@@ -583,13 +625,14 @@ export class SessionManager {
583
625
  }
584
626
  return { ...this.rowToSession(existing), agentSessionId: validSessionId };
585
627
  }
586
- // 继承当前活跃主会话的项目路径
628
+ // 继承当前活跃主会话的项目路径和 chatType
587
629
  const activeMain = this.db.prepare(`
588
- SELECT project_path FROM sessions
630
+ SELECT project_path, chat_type FROM sessions
589
631
  WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true AND thread_id = ''
590
632
  `).get(channel, channelId);
591
633
  const projectPath = activeMain?.project_path || defaultProjectPath;
592
634
  // 创建新话题会话
635
+ const inheritedChatType = activeMain?.chat_type || 'private';
593
636
  const session = {
594
637
  id: `${channel}-${channelId}-${Date.now()}`,
595
638
  channel,
@@ -597,8 +640,8 @@ export class SessionManager {
597
640
  projectPath,
598
641
  threadId,
599
642
  agentId: agentId || 'claude',
600
- chatType: 'private',
601
- sessionMode: 'interactive',
643
+ chatType: inheritedChatType,
644
+ sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
602
645
  metadata,
603
646
  name: name || '话题会话',
604
647
  createdAt: Date.now(),
@@ -618,9 +661,11 @@ export class SessionManager {
618
661
  }
619
662
  async switchProject(channel, channelId, newProjectPath, currentAgentId) {
620
663
  const agentId = currentAgentId || 'claude';
621
- // 1. 取消当前活跃会话
664
+ // 1. 继承当前 chatType(在 deactivate 之前读取)
665
+ const inheritedChatType = this.getActiveChatType(channel, channelId);
666
+ // 2. 取消当前活跃会话
622
667
  this.deactivateAllMetadata(channel, channelId);
623
- // 2. 查找目标项目 + 当前 agent 的会话
668
+ // 3. 查找目标项目 + 当前 agent 的会话
624
669
  const target = this.db.prepare(`
625
670
  SELECT * FROM sessions
626
671
  WHERE channel = ? AND channel_id = ? AND project_path = ? AND agent_id = ? AND thread_id = '' AND deleted_at IS NULL
@@ -635,7 +680,7 @@ export class SessionManager {
635
680
  .run(JSON.stringify(metadata), Date.now(), target.id);
636
681
  return { ...this.rowToSession(target), agentSessionId: validSessionId, metadata };
637
682
  }
638
- // 3. 创建新会话
683
+ // 4. 创建新会话
639
684
  const session = {
640
685
  id: `${channel}-${channelId}-${Date.now()}`,
641
686
  channel,
@@ -643,8 +688,8 @@ export class SessionManager {
643
688
  projectPath: newProjectPath,
644
689
  threadId: '',
645
690
  agentId,
646
- chatType: 'private',
647
- sessionMode: 'interactive',
691
+ chatType: inheritedChatType,
692
+ sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
648
693
  metadata: { isActive: true },
649
694
  name: '默认会话',
650
695
  createdAt: Date.now(),
@@ -680,9 +725,11 @@ export class SessionManager {
680
725
  `).run(agentSessionId, Date.now(), sessionId);
681
726
  }
682
727
  async switchAgent(channel, channelId, projectPath, newAgentId) {
683
- // 1. 取消当前活跃会话
728
+ // 1. 继承当前 chatType(在 deactivate 之前读取)
729
+ const inheritedChatType = this.getActiveChatType(channel, channelId);
730
+ // 2. 取消当前活跃会话
684
731
  this.deactivateAllMetadata(channel, channelId);
685
- // 2. 查找目标 agent 在当前项目下的会话
732
+ // 3. 查找目标 agent 在当前项目下的会话
686
733
  const target = this.db.prepare(`
687
734
  SELECT * FROM sessions
688
735
  WHERE channel = ? AND channel_id = ? AND project_path = ? AND agent_id = ? AND thread_id = '' AND deleted_at IS NULL
@@ -697,7 +744,7 @@ export class SessionManager {
697
744
  .run(JSON.stringify(metadata), Date.now(), target.id);
698
745
  return { ...this.rowToSession(target), agentSessionId: validSessionId, metadata };
699
746
  }
700
- // 3. 创建新会话(与 switchProject 保持一致)
747
+ // 4. 创建新会话(与 switchProject 保持一致)
701
748
  const session = {
702
749
  id: `${channel}-${channelId}-${Date.now()}`,
703
750
  channel,
@@ -705,8 +752,8 @@ export class SessionManager {
705
752
  projectPath,
706
753
  threadId: '',
707
754
  agentId: newAgentId,
708
- chatType: 'private',
709
- sessionMode: 'interactive',
755
+ chatType: inheritedChatType,
756
+ sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
710
757
  metadata: { isActive: true },
711
758
  name: '默认会话',
712
759
  createdAt: Date.now(),
@@ -838,6 +885,8 @@ export class SessionManager {
838
885
  `).run(Date.now(), Date.now(), channelId);
839
886
  }
840
887
  async createNewSession(channel, channelId, projectPath, name, agentId) {
888
+ // 继承当前 chatType(在 deactivate 之前读取)
889
+ const inheritedChatType = this.getActiveChatType(channel, channelId);
841
890
  // 取消当前活跃会话
842
891
  this.deactivateAllMetadata(channel, channelId);
843
892
  // 创建新会话
@@ -848,8 +897,8 @@ export class SessionManager {
848
897
  projectPath,
849
898
  threadId: '',
850
899
  agentId: agentId || 'claude',
851
- chatType: 'private',
852
- sessionMode: 'interactive',
900
+ chatType: inheritedChatType,
901
+ sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
853
902
  metadata: { isActive: true },
854
903
  name: name || '默认会话',
855
904
  createdAt: Date.now(),
@@ -955,6 +1004,8 @@ export class SessionManager {
955
1004
  return this.rowToSession(rows[0]);
956
1005
  }
957
1006
  async importCliSession(channel, channelId, projectPath, agentSessionId, agentId = 'claude') {
1007
+ // 继承当前 chatType(在 deactivate 之前读取)
1008
+ const inheritedChatType = this.getActiveChatType(channel, channelId);
958
1009
  // 取消当前活跃会话
959
1010
  this.deactivateAllMetadata(channel, channelId);
960
1011
  // 从 CLI 会话文件读取标题
@@ -968,8 +1019,8 @@ export class SessionManager {
968
1019
  projectPath,
969
1020
  threadId: '',
970
1021
  agentId,
971
- chatType: 'private',
972
- sessionMode: 'interactive',
1022
+ chatType: inheritedChatType,
1023
+ sessionMode: this.resolveDefaultSessionMode(channel, inheritedChatType),
973
1024
  agentSessionId,
974
1025
  metadata: { isActive: true },
975
1026
  name,
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ClaudeSessionFileAdapter } from './core/session/adapters/claude-session-file-adapter.js';
2
2
  import { CodexSessionFileAdapter } from './core/session/adapters/codex-session-file-adapter.js';
3
3
  import { GeminiSessionFileAdapter } from './core/session/adapters/gemini-session-file-adapter.js';
4
- import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, isAdmin, validateConfigIntegrity, validateChannelInstanceNames, getOwner } from './config.js';
4
+ import { loadConfig, ensureDataDirs, resolvePaths, resolveAnthropicConfig, isOwner, isAdmin, validateConfigIntegrity, validateChannelInstanceNames, getOwner, getChannelSessionMode } from './config.js';
5
5
  import { SessionManager } from './core/session/session-manager.js';
6
6
  import { ClaudeAgentPlugin } from './agents/claude-runner.js';
7
7
  import { CodexAgentPlugin } from './agents/codex-runner.js';
@@ -73,6 +73,29 @@ async function main() {
73
73
  const statsCollector = new StatsCollector(eventBus);
74
74
  // 初始化数据库(带 ownerResolver)
75
75
  const sessionManager = new SessionManager(undefined, eventBus, (channel, userId) => isOwner(config, channel, userId), (channel, userId) => isAdmin(config, channel, userId));
76
+ // sessionMode 解析:通道配置锁定 > chatType 默认(AUN 群聊 → proactive,其余 → interactive)
77
+ sessionManager.setSessionModeResolver((channel, chatType) => {
78
+ const locked = getChannelSessionMode(config, channel);
79
+ if (locked)
80
+ return locked;
81
+ // chatType 默认值:仅 AUN 群聊默认为 proactive,其余通道默认 interactive
82
+ // channel 在多实例时为 instanceName,需要识别 AUN 系
83
+ // 简化:通过 ChannelOptions.channelType 在 MessageProcessor 注册时已知,但 SessionManager 不持有这个映射
84
+ // 这里回退到按 instanceName 反查 config.channels.aun
85
+ if (chatType === 'group') {
86
+ const aun = config.channels?.aun;
87
+ if (Array.isArray(aun)) {
88
+ if (aun.some((i) => i.name === channel))
89
+ return 'proactive';
90
+ }
91
+ else if (aun) {
92
+ const effectiveName = aun.name ?? 'aun';
93
+ if (effectiveName === channel)
94
+ return 'proactive';
95
+ }
96
+ }
97
+ return undefined;
98
+ });
76
99
  logger.info('✓ Database initialized');
77
100
  // 注册会话文件适配器(Claude / Codex 各自的会话文件操作)
78
101
  sessionManager.registerFileAdapter(new ClaudeSessionFileAdapter());
@@ -369,7 +392,7 @@ async function main() {
369
392
  .map(([type, names]) => names.length === 1 ? names[0] : `${type}[${names.join(', ')}]`)
370
393
  .join(', ');
371
394
  const totalCount = connected.length;
372
- logger.info(`\n🚀 EvolClaw is running with ${totalCount} channel(s): ${channelSummary}\n`);
395
+ logger.info(`🚀 EvolClaw is running with ${totalCount} channel(s): ${channelSummary}`);
373
396
  eventBus.publish({
374
397
  type: 'system:started',
375
398
  channels: connected.map(c => c.toLowerCase()),