evolclaw 3.1.0 → 3.1.1

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/dist/cli/index.js CHANGED
@@ -231,11 +231,53 @@ function reportOrphans(orphans) {
231
231
  console.log(' 这些进程不属于当前 HOME 的实例登记簿,自动清理不会处理它们。');
232
232
  console.log(' 使用 evolclaw restart --clear 一并清掉,或手动 kill。');
233
233
  }
234
- async function cmdStart() {
235
- const cmdStartedAt = Date.now();
234
+ function formatLocalTime(ms) {
235
+ const d = new Date(ms);
236
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
237
+ }
238
+ function printStartupInfo() {
236
239
  const pkgRoot = getPackageRoot();
237
240
  const isNpmInstall = pkgRoot.includes('node_modules');
238
- console.log(`⏱ ${new Date().toLocaleString()} [${isNpmInstall ? 'pkg' : 'dev'}] ${pkgRoot}`);
241
+ const cliRunsSource = !import.meta.url.includes('/dist/');
242
+ const daemonEntry = path.join(pkgRoot, 'dist', 'index.js');
243
+ const daemonRunsDist = fs.existsSync(daemonEntry);
244
+ const scanDir = path.join(pkgRoot, daemonRunsDist ? 'dist' : 'src');
245
+ let latestMtime = 0;
246
+ const scanRecursive = (dir) => {
247
+ try {
248
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
249
+ if (entry.name === 'node_modules')
250
+ continue;
251
+ const full = path.join(dir, entry.name);
252
+ if (entry.isDirectory()) {
253
+ scanRecursive(full);
254
+ continue;
255
+ }
256
+ if (entry.name.endsWith('.js') || entry.name.endsWith('.ts')) {
257
+ const mt = fs.statSync(full).mtimeMs;
258
+ if (mt > latestMtime)
259
+ latestMtime = mt;
260
+ }
261
+ }
262
+ }
263
+ catch { }
264
+ };
265
+ scanRecursive(scanDir);
266
+ let version = '?';
267
+ try {
268
+ version = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8')).version;
269
+ }
270
+ catch { }
271
+ console.log(` EvolClaw v${version}`);
272
+ console.log(` 包路径: ${pkgRoot}`);
273
+ console.log(` 安装类型: ${isNpmInstall ? 'npm全局安装' : '开发仓(link)'}`);
274
+ console.log(` CLI执行: ${cliRunsSource ? '源码(tsx)' : '编译产物(dist)'}`);
275
+ console.log(` Daemon执行: ${daemonRunsDist ? '编译产物(dist)' : '未知'}`);
276
+ console.log(` 代码时间: ${latestMtime ? formatLocalTime(latestMtime) : '?'}`);
277
+ }
278
+ async function cmdStart() {
279
+ const cmdStartedAt = Date.now();
280
+ printStartupInfo();
239
281
  const p = resolvePaths();
240
282
  ensureDataDirs();
241
283
  // 旧配置自动迁移(evolclaw.json → 新结构)
@@ -263,7 +305,7 @@ async function cmdStart() {
263
305
  if (aliveMains.length > 0) {
264
306
  const first = aliveMains[0];
265
307
  console.log(`❌ EvolClaw is already running (PID: ${aliveMains.map(m => m.record.pid).join(', ')})`);
266
- console.log(` 启动于: ${first.record.startedAtIso}`);
308
+ console.log(` 启动于: ${new Date(first.record.startedAtIso).toLocaleString()}`);
267
309
  console.log(` 启动方式: ${first.record.launchedBy}`);
268
310
  // 报告 AID 状态
269
311
  if (status.aidLastActivity.size > 0) {
@@ -444,9 +486,7 @@ async function cmdStop() {
444
486
  }
445
487
  async function cmdRestart(opts = {}) {
446
488
  const cmdStartedAt = Date.now();
447
- const pkgRoot = getPackageRoot();
448
- const isNpmInstall = pkgRoot.includes('node_modules');
449
- console.log(`⏱ ${new Date().toLocaleString()} [${isNpmInstall ? 'pkg' : 'dev'}] ${pkgRoot}`);
489
+ printStartupInfo();
450
490
  console.log('🔄 Restarting EvolClaw...');
451
491
  // 版本检查与自动升级
452
492
  console.log('📦 Checking for updates...');
@@ -1523,7 +1563,7 @@ function cmdWatch() {
1523
1563
  }
1524
1564
  const m = aliveMainEntries[0].record;
1525
1565
  const uptime = formatTimeAgo(Date.now() - m.startedAt);
1526
- console.log(`📦 Instance: PID ${m.pid} | 启动于 ${m.startedAtIso} (${uptime}) | via ${m.launchedBy}`);
1566
+ console.log(`📦 Instance: PID ${m.pid} | 启动于 ${new Date(m.startedAtIso).toLocaleString()} (${uptime}) | via ${m.launchedBy}`);
1527
1567
  if (instStatus.aidLastActivity.size > 0) {
1528
1568
  const now = Date.now();
1529
1569
  const aidLines = [];
@@ -1773,6 +1813,11 @@ async function cmdWatchAid() {
1773
1813
  const COL_LRECV = 10;
1774
1814
  const COL_LSENT = 10;
1775
1815
  const COL_PEERS = 5;
1816
+ // 表头跟随系统语言
1817
+ const isChinese = (process.env.LANG || process.env.LC_ALL || process.env.LANGUAGE || Intl.DateTimeFormat().resolvedOptions().locale || '').toLowerCase().includes('zh');
1818
+ const HEADERS = isChinese
1819
+ ? { aid: 'AID', status: '状态', uptime: '运行', state: '工作', reconn: '重连', recv: '收', sent: '发', sys: '系统', bin: '入流量', bout: '出流量', lrecv: '最后收', lsent: '最后发', peers: '对端' }
1820
+ : { aid: 'AID', status: 'STATUS', uptime: 'UPTIME', state: 'STATE', reconn: 'RECONN', recv: 'RECV', sent: 'SENT', sys: 'SYS R/S', bin: 'BYTES IN', bout: 'BYTES OUT', lrecv: 'LAST RECV', lsent: 'LAST SENT', peers: 'PEERS' };
1776
1821
  function formatDuration(ms) {
1777
1822
  const sec = Math.floor(ms / 1000);
1778
1823
  if (sec < 60)
@@ -1790,19 +1835,19 @@ async function cmdWatchAid() {
1790
1835
  }
1791
1836
  function renderHeader() {
1792
1837
  return ' ' +
1793
- padRight('AID', COL_AID) +
1794
- padRight('STATUS', COL_STATUS) +
1795
- padRight('UPTIME', COL_UPTIME) +
1796
- padRight('STATE', COL_STATE) +
1797
- padRight('RECONN', COL_RECONN) +
1798
- padRight('RECV', COL_RECV) +
1799
- padRight('SENT', COL_SENT) +
1800
- padRight('SYS R/S', COL_SYS) +
1801
- padRight('BYTES IN', COL_BIN) +
1802
- padRight('BYTES OUT', COL_BOUT) +
1803
- padRight('LAST RECV', COL_LRECV) +
1804
- padRight('LAST SENT', COL_LSENT) +
1805
- padRight('PEERS', COL_PEERS);
1838
+ padRight(HEADERS.aid, COL_AID) +
1839
+ padRight(HEADERS.status, COL_STATUS) +
1840
+ padRight(HEADERS.uptime, COL_UPTIME) +
1841
+ padRight(HEADERS.state, COL_STATE) +
1842
+ padRight(HEADERS.reconn, COL_RECONN) +
1843
+ padRight(HEADERS.recv, COL_RECV) +
1844
+ padRight(HEADERS.sent, COL_SENT) +
1845
+ padRight(HEADERS.sys, COL_SYS) +
1846
+ padRight(HEADERS.bin, COL_BIN) +
1847
+ padRight(HEADERS.bout, COL_BOUT) +
1848
+ padRight(HEADERS.lrecv, COL_LRECV) +
1849
+ padRight(HEADERS.lsent, COL_LSENT) +
1850
+ padRight(HEADERS.peers, COL_PEERS);
1806
1851
  }
1807
1852
  function renderRow(aid, stats, projectPath) {
1808
1853
  const aidLabel = aid.aid.length > COL_AID - 2 ? aid.aid.slice(0, COL_AID - 4) + '..' : aid.aid;
@@ -1841,6 +1886,13 @@ async function cmdWatchAid() {
1841
1886
  const nameReset = refreshedAids.has(aid.aid) ? '' : RST;
1842
1887
  const BLUE = useColor ? '\x1b[34m' : '';
1843
1888
  const ORANGE = useColor ? '\x1b[38;5;208m' : '';
1889
+ const MAGENTA = useColor ? '\x1b[35m' : '';
1890
+ // 标记生成:[明文/密文|自主/响应](紫色=工具渲染标记)
1891
+ const mkTags = (encrypt, chatmode) => {
1892
+ const enc = encrypt ? '密文' : '明文';
1893
+ const mode = chatmode === 'proactive' ? '自主' : '响应';
1894
+ return `${MAGENTA}[${enc}|${mode}]${RST}`;
1895
+ };
1844
1896
  let msgPreview = '';
1845
1897
  if (stats?.lastReceivedAt || stats?.lastSentAt) {
1846
1898
  const recvTs = stats.lastReceivedAt ?? 0;
@@ -1851,13 +1903,53 @@ async function cmdWatchAid() {
1851
1903
  }
1852
1904
  else if (stats.lastSentText) {
1853
1905
  const toShort = stats.lastSentTo ? stats.lastSentTo.split('.')[0] : '';
1854
- msgPreview = `${BLUE}↑ ${toShort ? `${ORANGE}${toShort}${RST}${BLUE}: ` : ''}${stats.lastSentText.replace(/\n/g, ' ').slice(0, 60)}${RST}`;
1906
+ const tags = mkTags(stats.lastSentEncrypt, stats.lastSentChatmode);
1907
+ // task 进行中时也显示计数(processing > 0 说明还在跑)
1908
+ const isWorking = (stats.processing ?? 0) > 0;
1909
+ const taskEnd = stats?.lastTaskEnd;
1910
+ const counts = isWorking && taskEnd
1911
+ ? `${MAGENTA}[大模型${taskEnd.numTurns}|调用${taskEnd.toolUseCount}|thought${taskEnd.thoughtPutCount}|msg${taskEnd.replyCount}]${RST}`
1912
+ : '';
1913
+ msgPreview = `${BLUE}↑${tags}${counts} ${toShort ? `${ORANGE}${toShort}${RST}${BLUE}: ` : ''}${stats.lastSentText.replace(/\n/g, ' ').slice(0, 60)}${RST}`;
1855
1914
  }
1856
1915
  else if (stats.lastReceivedText) {
1857
1916
  const fromShort = stats.lastReceivedFrom ? stats.lastReceivedFrom.split('.')[0] : '';
1858
1917
  msgPreview = `${GREEN}↓ ${fromShort ? `${ORANGE}${fromShort}${RST}${GREEN}: ` : ''}${stats.lastReceivedText.replace(/\n/g, ' ').slice(0, 60)}${RST}`;
1859
1918
  }
1860
1919
  }
1920
+ // 任务结束状态覆盖:仅当 taskEnd 比最后收发都新时才覆盖
1921
+ const taskEnd = stats?.lastTaskEnd;
1922
+ if (taskEnd && taskEnd.ts >= (stats?.lastSentAt ?? 0) && taskEnd.ts >= (stats?.lastReceivedAt ?? 0)) {
1923
+ const tags = mkTags(taskEnd.encrypt, taskEnd.chatmode);
1924
+ // 计数标记: [大模型N|调用N|thoughtN(streamN)|msgN]
1925
+ const thoughtLabel = taskEnd.thoughtPutCount > 0
1926
+ ? `thought${taskEnd.numTurns}(stream${taskEnd.thoughtPutCount})`
1927
+ : `thought${taskEnd.numTurns}`;
1928
+ const counts = `${MAGENTA}[大模型${taskEnd.numTurns}|调用${taskEnd.toolUseCount}|${thoughtLabel}|msg${taskEnd.replyCount}]${RST}`;
1929
+ if (taskEnd.status === 'error') {
1930
+ msgPreview = `${RED}${tags}${counts} 错误: ${taskEnd.errorType ?? '未知错误'}${RST}`;
1931
+ }
1932
+ else if (taskEnd.sentDuringTask) {
1933
+ // 有 message.send:蓝色加粗 + 内容
1934
+ const toShort = stats?.lastSentTo ? stats.lastSentTo.split('.')[0] : '';
1935
+ const textPreview = stats?.lastSentText ? stats.lastSentText.replace(/\n/g, ' ').slice(0, 60) : '';
1936
+ msgPreview = `${BOLD}${BLUE}↑${tags}${counts} ${toShort ? `${ORANGE}${toShort}${RST}${BOLD}${BLUE}: ` : ''}${textPreview}${RST}`;
1937
+ }
1938
+ else if (taskEnd.thoughtDuringTask) {
1939
+ // 只有 thought:普通蓝色 + thought 内容
1940
+ const textPreview = taskEnd.lastThoughtText
1941
+ ? taskEnd.lastThoughtText.replace(/\n/g, ' ').slice(0, 60)
1942
+ : (taskEnd.finalText ? taskEnd.finalText.replace(/\n/g, ' ').slice(0, 60) : '');
1943
+ msgPreview = `${BLUE}↑${tags}${counts} ${textPreview}${RST}`;
1944
+ }
1945
+ else {
1946
+ // 既没 send 也没 thought
1947
+ const textPreview = taskEnd.finalText
1948
+ ? taskEnd.finalText.replace(/\n/g, ' ').slice(0, 60)
1949
+ : '(无输出)';
1950
+ msgPreview = `${ORANGE}${tags}${counts} ${textPreview}${RST}`;
1951
+ }
1952
+ }
1861
1953
  const subLine1 = ` ${nameColor}${namePart}${nameReset}${msgPreview ? ' ' + msgPreview : ''}`;
1862
1954
  const dirLabel = projectPath || '—';
1863
1955
  const subLine2 = `${DIM} ${dirLabel}${RST}`;
@@ -2698,7 +2790,7 @@ async function cmdCtl(args) {
2698
2790
  async function cmdAgent(args) {
2699
2791
  const sub = args[0];
2700
2792
  const formatJson = args.includes('--format') && args[args.indexOf('--format') + 1] === 'json';
2701
- if (sub === 'help' || sub === '--help' || sub === '-h' || args.includes('--help') || args.includes('-h')) {
2793
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h' || args.includes('--help') || args.includes('-h')) {
2702
2794
  console.log(`用法: evolclaw agent <command>
2703
2795
 
2704
2796
  Commands:
@@ -2750,17 +2842,43 @@ Options:
2750
2842
  console.log('No agents configured.');
2751
2843
  return;
2752
2844
  }
2753
- console.log('NAME'.padEnd(14) + 'STATUS'.padEnd(10) + 'CHANNELS'.padEnd(24) +
2754
- 'PROJECT'.padEnd(22) + 'BASEAGENT'.padEnd(11) + 'LAST ACTIVE');
2845
+ // 表头跟随系统语言
2846
+ const isChinese = (process.env.LANG || process.env.LC_ALL || process.env.LANGUAGE || Intl.DateTimeFormat().resolvedOptions().locale || '').toLowerCase().includes('zh');
2847
+ const headers = isChinese
2848
+ ? { name: '名称', status: '状态', channels: '渠道', project: '项目', baseagent: '基座', lastActive: '最后活跃' }
2849
+ : { name: 'NAME', status: 'STATUS', channels: 'CHANNELS', project: 'PROJECT', baseagent: 'BASEAGENT', lastActive: 'LAST ACTIVE' };
2850
+ // 计算各列实际需要的宽度
2851
+ let maxNameLen = headers.name.length;
2852
+ let maxStatusLen = headers.status.length;
2853
+ let maxChannelsLen = headers.channels.length;
2854
+ let maxProjectLen = headers.project.length;
2855
+ let maxBaseagentLen = headers.baseagent.length;
2856
+ for (const info of result.agents) {
2857
+ maxNameLen = Math.max(maxNameLen, info.name.length);
2858
+ maxStatusLen = Math.max(maxStatusLen, (info.status || 'stopped').length);
2859
+ const channelsStr = info.channels?.length > 0 ? info.channels.join(', ') : '—';
2860
+ maxChannelsLen = Math.max(maxChannelsLen, channelsStr.length);
2861
+ const projectStr = info.projectPath ? path.basename(info.projectPath) : '—';
2862
+ maxProjectLen = Math.max(maxProjectLen, projectStr.length);
2863
+ maxBaseagentLen = Math.max(maxBaseagentLen, (info.baseagent || '—').length);
2864
+ }
2865
+ // 加 2 作为列间距
2866
+ const colName = maxNameLen + 2;
2867
+ const colStatus = maxStatusLen + 2;
2868
+ const colChannels = maxChannelsLen + 1;
2869
+ const colProject = maxProjectLen + 2;
2870
+ const colBaseagent = maxBaseagentLen + 2;
2871
+ console.log(headers.name.padEnd(colName) + headers.status.padEnd(colStatus) + headers.channels.padEnd(colChannels) +
2872
+ headers.project.padEnd(colProject) + headers.baseagent.padEnd(colBaseagent) + headers.lastActive);
2755
2873
  for (const info of result.agents) {
2756
2874
  const name = info.name;
2757
2875
  const status = info.status || 'stopped';
2758
- const channels = info.channels?.length > 0 ? info.channels.join(', ').slice(0, 22) : '—';
2876
+ const channels = info.channels?.length > 0 ? info.channels.join(', ') : '—';
2759
2877
  const project = info.projectPath ? path.basename(info.projectPath) : '—';
2760
2878
  const baseagent = info.baseagent || '—';
2761
2879
  const lastActive = info.lastActivity ? formatTimeAgo(Date.now() - info.lastActivity) : '—';
2762
- console.log(name.padEnd(14) + status.padEnd(10) + channels.padEnd(24) +
2763
- project.padEnd(22) + baseagent.padEnd(11) + lastActive);
2880
+ console.log(name.padEnd(colName) + status.padEnd(colStatus) + channels.padEnd(colChannels) +
2881
+ project.padEnd(colProject) + baseagent.padEnd(colBaseagent) + lastActive);
2764
2882
  }
2765
2883
  return;
2766
2884
  }
@@ -3161,7 +3279,7 @@ async function cmdAid(args) {
3161
3279
  const sub = args[0] || 'list';
3162
3280
  const formatJson = args.includes('--format') && args[args.indexOf('--format') + 1] === 'json';
3163
3281
  const aunPath = resolveAunPath(args);
3164
- if (sub === 'help' || sub === '--help' || sub === '-h' || args.includes('--help') || args.includes('-h')) {
3282
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h' || args.includes('--help') || args.includes('-h')) {
3165
3283
  console.log(`用法: evolclaw aid <command>
3166
3284
 
3167
3285
  Commands:
package/dist/cli/init.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import fs from 'fs';
2
- import path from 'path';
3
2
  import readline from 'readline';
4
3
  import { resolvePaths, ensureDataDirs } from '../paths.js';
5
4
  import { commandExists } from '../utils/cross-platform.js';
6
5
  import { scanInstances } from '../utils/instance-registry.js';
6
+ import { saveDefaultsSafe } from '../config-store.js';
7
7
  // ==================== Helpers ====================
8
8
  function ask(rl, question) {
9
9
  return new Promise(resolve => rl.question(question, resolve));
@@ -28,9 +28,8 @@ function buildDefaults(chosen) {
28
28
  baseagents: { [chosen]: env ? { apiKey: `$ENV:${env}` } : {} },
29
29
  };
30
30
  }
31
- function writeDefaults(defaultsPath, chosen) {
32
- fs.mkdirSync(path.dirname(defaultsPath), { recursive: true });
33
- fs.writeFileSync(defaultsPath, JSON.stringify(buildDefaults(chosen), null, 2) + '\n');
31
+ function writeDefaults(_defaultsPath, chosen) {
32
+ saveDefaultsSafe(buildDefaults(chosen));
34
33
  }
35
34
  // ==================== Main ====================
36
35
  export async function cmdInit(options) {
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { resolvePaths, getPackageRoot } from '../paths.js';
3
+ import { resolvePaths } from '../paths.js';
4
4
  import { decodeDirSegment, readAllJsonlLines } from '../core/session/session-fs-store.js';
5
5
  // ==================== ANSI ====================
6
6
  const isTTY = !!process.stdout.isTTY;
@@ -84,6 +84,34 @@ function formatTimeAgo(ms) {
84
84
  return `${hour}h`;
85
85
  return `${Math.floor(hour / 24)}d`;
86
86
  }
87
+ function getCodeTime(pkgRoot) {
88
+ let latestMtime = 0;
89
+ const scanDir = fs.existsSync(path.join(pkgRoot, 'dist')) ? path.join(pkgRoot, 'dist') : path.join(pkgRoot, 'src');
90
+ const scanRecursive = (dir) => {
91
+ try {
92
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
93
+ if (entry.name === 'node_modules')
94
+ continue;
95
+ const full = path.join(dir, entry.name);
96
+ if (entry.isDirectory()) {
97
+ scanRecursive(full);
98
+ continue;
99
+ }
100
+ if (entry.name.endsWith('.js') || entry.name.endsWith('.ts')) {
101
+ const mt = fs.statSync(full).mtimeMs;
102
+ if (mt > latestMtime)
103
+ latestMtime = mt;
104
+ }
105
+ }
106
+ }
107
+ catch { }
108
+ };
109
+ scanRecursive(scanDir);
110
+ if (!latestMtime)
111
+ return '?';
112
+ const d = new Date(latestMtime);
113
+ return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
114
+ }
87
115
  function formatNumber(n) {
88
116
  return n.toLocaleString('en-US');
89
117
  }
@@ -267,43 +295,71 @@ function renderStatsPanel(state, width, height) {
267
295
  // ==================== Messages Panel ====================
268
296
  function renderMessagesPanel(state, width, height) {
269
297
  const lines = [];
270
- const title = `${DIM}─ Messages ─${RST}`;
298
+ const lastTs = state.messages.length > 0 ? state.messages[state.messages.length - 1].ts : 0;
299
+ const lastTimeStr = lastTs ? formatDateTime(lastTs) : '--';
300
+ const title = `${DIM}─ Messages (${state.messages.length}, last: ${lastTimeStr}) ─${RST}`;
271
301
  lines.push(padRight(title, width));
272
302
  const contentHeight = height - 1;
273
303
  const msgs = state.messages;
274
304
  const totalMsgs = msgs.length;
275
- const visibleCount = contentHeight;
276
- const startIdx = Math.max(0, totalMsgs - visibleCount - state.messageScrollOffset);
277
- const endIdx = Math.min(totalMsgs, startIdx + visibleCount);
278
- const scrollbar = renderScrollbar(totalMsgs, visibleCount, state.messageScrollOffset, contentHeight);
279
305
  const msgWidth = width - 3;
280
306
  const contentLineWidth = msgWidth - 2;
281
307
  const maxContentLines = 3;
282
- for (let i = startIdx; i < endIdx; i++) {
283
- const m = msgs[i];
308
+ // 先构造每条消息的渲染行,从最末尾的可见消息往回收集,直到填满 contentHeight
309
+ function renderOneMsg(m) {
284
310
  const time = formatDateTime(m.ts);
285
311
  const dir = m.dir === 'in' ? `${GREEN}↓${RST}` : `${BLUE}↑${RST}`;
286
312
  const isGroup = m.chatType === 'group';
287
- const chatTag = isGroup ? `${MAGENTA}[群聊]${RST} ` : '';
313
+ const chatTag = isGroup ? `${MAGENTA}[群聊]${RST}` : '';
314
+ const encLabel = m.encrypt ? '密文' : '明文';
315
+ const modeLabel = m.chatmode === 'proactive' ? '自主' : '响应';
316
+ const metaTags = (m.encrypt != null || m.chatmode) ? `${MAGENTA}[${encLabel}|${modeLabel}]${RST}` : '';
317
+ let typeTag = '';
318
+ if (m.dir === 'out') {
319
+ const source = m.source === 'cli' ? 'cli' : 'daemon';
320
+ const method = m.msgType === 'thought' ? 'thought' : 'send';
321
+ typeTag = `${DIM}[${source}|${method}]${RST}`;
322
+ }
288
323
  const byteLen = Buffer.byteLength(m.content, 'utf-8');
289
324
  const lenTag = `${DIM}${formatNumber(byteLen)}B${RST}`;
290
- const fromDisplay = isGroup && m.groupId && m.dir === 'in' ? m.groupId : m.from;
291
- const toDisplay = isGroup && m.groupId && m.dir === 'out' ? m.groupId : m.to;
292
- const header = `${DIM}${time}${RST} ${dir}${chatTag} ${ORANGE}${fromDisplay}${RST}${DIM}→${RST}${GREEN}${toDisplay}${RST} ${lenTag}`;
293
- const headerLine = padRight(header, msgWidth);
294
- const sbIdx = lines.length - 1;
295
- lines.push(`${headerLine} ${scrollbar[sbIdx] || ' '}`);
296
- if (lines.length - 1 < contentHeight) {
297
- const rawContent = m.content.replace(/\n/g, ' ');
298
- const wrappedLines = wrapText(rawContent, contentLineWidth, maxContentLines);
299
- for (const wl of wrappedLines) {
300
- if (lines.length - 1 >= contentHeight)
301
- break;
302
- const contentLine = padRight(` ${wl}`, msgWidth);
303
- const sbIdx2 = lines.length - 1;
304
- lines.push(`${contentLine} ${scrollbar[sbIdx2] || ' '}`);
305
- }
325
+ const fromDisplay = isGroup && m.groupId && m.dir === 'in' ? m.groupId : m.from.split('.')[0];
326
+ const toDisplay = isGroup && m.groupId && m.dir === 'out' ? m.groupId : m.to.split('.')[0];
327
+ const header = `${DIM}${time}${RST} ${dir}${chatTag}${metaTags}${typeTag} ${ORANGE}${fromDisplay}${RST}${DIM}→${RST}${GREEN}${toDisplay}${RST} ${lenTag}`;
328
+ const out = [padRight(header, msgWidth)];
329
+ const rawContent = m.content.replace(/\n/g, ' ');
330
+ const wrappedLines = wrapText(rawContent, contentLineWidth, maxContentLines);
331
+ for (const wl of wrappedLines) {
332
+ out.push(padRight(` ${wl}`, msgWidth));
333
+ }
334
+ return out;
335
+ }
336
+ // 从 endIdx-1 开始倒序,往回累积,直到行数填满 contentHeight
337
+ const endIdx = Math.max(0, totalMsgs - state.messageScrollOffset);
338
+ const collected = []; // 每条消息的行数组
339
+ let totalLines = 0;
340
+ let firstShownIdx = endIdx; // 首条可见消息的下标
341
+ for (let i = endIdx - 1; i >= 0; i--) {
342
+ const rendered = renderOneMsg(msgs[i]);
343
+ if (totalLines + rendered.length > contentHeight && collected.length > 0)
344
+ break;
345
+ collected.unshift(rendered);
346
+ totalLines += rendered.length;
347
+ firstShownIdx = i;
348
+ if (totalLines >= contentHeight)
349
+ break;
350
+ }
351
+ const visibleMsgCount = endIdx - firstShownIdx;
352
+ const scrollbar = renderScrollbar(totalMsgs, visibleMsgCount, state.messageScrollOffset, contentHeight);
353
+ // 正序输出(旧→新)
354
+ for (const rendered of collected) {
355
+ for (const line of rendered) {
356
+ if (lines.length - 1 >= contentHeight)
357
+ break;
358
+ const sbIdx = lines.length - 1;
359
+ lines.push(`${line} ${scrollbar[sbIdx] || ' '}`);
306
360
  }
361
+ if (lines.length - 1 >= contentHeight)
362
+ break;
307
363
  }
308
364
  while (lines.length < height) {
309
365
  const sbIdx = lines.length - 1;
@@ -314,11 +370,11 @@ function renderMessagesPanel(state, width, height) {
314
370
  // ==================== Main Render ====================
315
371
  function renderFrame(state) {
316
372
  const cols = process.stdout.columns || 120;
317
- const rows = (process.stdout.rows || 40) - 3;
373
+ const rows = (process.stdout.rows || 40);
374
+ const bodyHeight = rows - 4;
318
375
  const leftW = Math.max(20, Math.floor(cols * 0.20));
319
376
  const midW = Math.max(24, Math.floor(cols * 0.22));
320
377
  const rightW = Math.max(40, cols - leftW - midW - 4);
321
- const bodyHeight = rows - 2;
322
378
  const leftLines = renderScopePanel(state, leftW, bodyHeight);
323
379
  const midLines = renderStatsPanel(state, midW, bodyHeight);
324
380
  const msgLines = renderMessagesPanel(state, rightW, bodyHeight);
@@ -334,11 +390,11 @@ function renderFrame(state) {
334
390
  }
335
391
  const bottomBorder = `${DIM}├${'─'.repeat(leftW)}┴${'─'.repeat(midW)}┴${'─'.repeat(rightW + 1)}┤${RST}`;
336
392
  buf += `\x1b[2K${bottomBorder}\n`;
337
- const pkgRoot = getPackageRoot();
338
- const helpLine = `${DIM}│ Tab: panel ↑↓: nav Enter: select Backspace: back ESC: exit ${pkgRoot}${RST}`;
393
+ const helpText = `Tab: panel ↑↓: nav Enter: select Backspace: back ESC: exit`;
394
+ const helpLine = `${DIM}│ ${helpText.slice(0, cols - 4)} ${RST}`;
339
395
  buf += `\x1b[2K${helpLine}\n`;
340
396
  const closeBorder = `${DIM}└${'─'.repeat(cols - 2)}┘${RST}`;
341
- buf += `\x1b[2K${closeBorder}\n`;
397
+ buf += `\x1b[2K${closeBorder}`;
342
398
  return buf;
343
399
  }
344
400
  // ==================== Main ====================
@@ -409,6 +465,7 @@ export async function cmdWatchMsg() {
409
465
  if (!state.selectedLocalAid)
410
466
  return;
411
467
  state.peers = loadPeerInfos(aunDir, state.selectedLocalAid);
468
+ const prevCount = state.messages.length;
412
469
  if (state.selectedPeer) {
413
470
  state.messages = readMessages(aunDir, state.selectedLocalAid, state.selectedPeer);
414
471
  if (state.messages.length > 1000)
@@ -417,6 +474,10 @@ export async function cmdWatchMsg() {
417
474
  else {
418
475
  state.messages = loadAllMessages(aunDir, state.selectedLocalAid);
419
476
  }
477
+ // 有新消息时自动滚到底部
478
+ if (state.messages.length > prevCount) {
479
+ state.messageScrollOffset = 0;
480
+ }
420
481
  // Also refresh scope stats for the selected AID
421
482
  const idx = state.localAids.findIndex(a => a.aid === state.selectedLocalAid);
422
483
  if (idx >= 0) {
@@ -431,6 +492,7 @@ export async function cmdWatchMsg() {
431
492
  watcher.close();
432
493
  watcher = null;
433
494
  }
495
+ clearInterval(pollTimer);
434
496
  if (process.stdin.isTTY)
435
497
  try {
436
498
  process.stdin.setRawMode(false);
@@ -579,6 +641,21 @@ export async function cmdWatchMsg() {
579
641
  loadScope();
580
642
  process.stdout.write('\x1b[?25l\x1b[2J\x1b[H');
581
643
  render();
644
+ // 定时轮询:5 秒检查一次,有变化才刷新
645
+ let lastMsgCount = state.messages.length;
646
+ let lastMsgTs = state.messages.length > 0 ? state.messages[state.messages.length - 1].ts : 0;
647
+ const pollTimer = setInterval(() => {
648
+ if (!state.selectedLocalAid)
649
+ return;
650
+ refreshData();
651
+ const newCount = state.messages.length;
652
+ const newTs = newCount > 0 ? state.messages[newCount - 1].ts : 0;
653
+ if (newCount !== lastMsgCount || newTs !== lastMsgTs) {
654
+ lastMsgCount = newCount;
655
+ lastMsgTs = newTs;
656
+ render();
657
+ }
658
+ }, 5000);
582
659
  if (process.stdin.isTTY) {
583
660
  process.stdin.setRawMode(true);
584
661
  process.stdin.resume();
@@ -75,8 +75,53 @@ export function loadDefaults() {
75
75
  return expandEnvRefs(raw);
76
76
  }
77
77
  export function saveDefaults(value) {
78
+ backupDefaults(resolvePaths().defaultsConfig);
78
79
  atomicWriteJson(resolvePaths().defaultsConfig, value);
79
80
  }
81
+ /**
82
+ * 备份 defaults.json 为 defaults_YYYYMMDDhhmmss.json。文件不存在时为 no-op。
83
+ * 同秒重复调用会被覆盖(同一秒内的内容相同,可接受)。
84
+ */
85
+ function backupDefaults(filePath) {
86
+ if (!fs.existsSync(filePath))
87
+ return;
88
+ const now = new Date();
89
+ const ts = now.getFullYear().toString()
90
+ + String(now.getMonth() + 1).padStart(2, '0')
91
+ + String(now.getDate()).padStart(2, '0')
92
+ + String(now.getHours()).padStart(2, '0')
93
+ + String(now.getMinutes()).padStart(2, '0')
94
+ + String(now.getSeconds()).padStart(2, '0');
95
+ const backupPath = path.join(path.dirname(filePath), `defaults_${ts}.json`);
96
+ try {
97
+ fs.copyFileSync(filePath, backupPath);
98
+ }
99
+ catch (e) {
100
+ logger.warn(`[config] backup failed: ${backupPath}: ${e}`);
101
+ }
102
+ }
103
+ /**
104
+ * 安全写入 defaults.json:备份现有文件 → 深合并 patch → 原子写入。
105
+ *
106
+ * 与 saveDefaults() 不同,本函数保留现有字段,仅覆盖 patch 中显式指定的字段。
107
+ * 适用场景:evolclaw init 仅修改 active_baseagent/baseagents 时,不应丢失 chatmode/projects 等其它字段。
108
+ */
109
+ export function saveDefaultsSafe(patch) {
110
+ const p = resolvePaths().defaultsConfig;
111
+ let existing = null;
112
+ try {
113
+ existing = atomicReadJson(p);
114
+ }
115
+ catch (e) {
116
+ logger.warn(`[config] existing defaults.json unparsable, will be backed up and replaced: ${e}`);
117
+ }
118
+ fs.mkdirSync(path.dirname(p), { recursive: true });
119
+ backupDefaults(p);
120
+ const merged = existing
121
+ ? deepMergeObject(existing, patch)
122
+ : { $schema_version: CONFIG_SCHEMA_VERSION, ...patch };
123
+ atomicWriteJson(p, merged);
124
+ }
80
125
  export function loadProcessConfig() {
81
126
  const raw = atomicReadJson(resolvePaths().processConfig);
82
127
  if (raw === null)