evolclaw 3.3.0 → 3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +7 -3
  3. package/dist/agents/claude-runner.js +23 -27
  4. package/dist/agents/codex-runner.js +90 -6
  5. package/dist/agents/runner-types.js +30 -0
  6. package/dist/aun/outbox.js +14 -2
  7. package/dist/channels/aun.js +506 -108
  8. package/dist/channels/feishu.js +29 -5
  9. package/dist/cli/agent-command.js +591 -0
  10. package/dist/cli/agent.js +15 -3
  11. package/dist/cli/aun-commands.js +1444 -0
  12. package/dist/cli/ctl-command.js +78 -0
  13. package/dist/cli/daemon-commands.js +2707 -0
  14. package/dist/cli/index.js +12 -5027
  15. package/dist/cli/restart-monitor.js +539 -0
  16. package/dist/cli/watch-logs.js +33 -0
  17. package/dist/core/channel-loader.js +4 -1
  18. package/dist/core/command/command-handler.js +1189 -0
  19. package/dist/core/command/menu-handler.js +1478 -0
  20. package/dist/core/command/slash-gate.js +142 -0
  21. package/dist/core/command/slash-handler.js +2090 -0
  22. package/dist/core/evolagent-registry.js +81 -0
  23. package/dist/core/evolagent.js +16 -0
  24. package/dist/core/message/im-renderer.js +67 -49
  25. package/dist/core/message/message-bridge.js +30 -9
  26. package/dist/core/message/message-processor.js +200 -122
  27. package/dist/core/message/message-queue.js +68 -0
  28. package/dist/core/permission.js +16 -0
  29. package/dist/core/session/session-manager.js +59 -13
  30. package/dist/core/stats/db.js +20 -0
  31. package/dist/core/stats/writer.js +3 -3
  32. package/dist/data/error-dict.json +7 -0
  33. package/dist/index.js +49 -6
  34. package/dist/ipc.js +99 -0
  35. package/dist/utils/cross-platform.js +35 -0
  36. package/dist/utils/ecweb-launch.js +49 -0
  37. package/dist/utils/error-utils.js +18 -5
  38. package/dist/utils/npm-ops.js +38 -8
  39. package/dist/utils/stats.js +63 -6
  40. package/kits/eck_manifest.json +0 -12
  41. package/package.json +2 -3
  42. package/dist/core/command-handler.js +0 -4235
  43. package/dist/core/message/response-depth.js +0 -56
  44. package/kits/templates/system-fragments/response-depth.md +0 -16
@@ -143,6 +143,12 @@ export function summarizeToolInput(toolName, input) {
143
143
  /** 为 Edit 工具生成 diff 风格摘要 */
144
144
  function formatEditSummary(input) {
145
145
  const filePath = input.file_path || '';
146
+ const protocolDiff = typeof input.unified_diff === 'string' ? input.unified_diff
147
+ : typeof input.unifiedDiff === 'string' ? input.unifiedDiff
148
+ : typeof input.diff === 'string' ? input.diff
149
+ : '';
150
+ if (protocolDiff)
151
+ return formatProtocolDiffSummary(filePath, protocolDiff);
146
152
  const oldStr = typeof input.old_string === 'string' ? input.old_string : '';
147
153
  const newStr = typeof input.new_string === 'string' ? input.new_string : '';
148
154
  if (!oldStr && !newStr)
@@ -215,6 +221,16 @@ function formatEditSummary(input) {
215
221
  }
216
222
  return `${filePath}\n\`\`\`\n${diffLines.join('\n')}\n\`\`\``;
217
223
  }
224
+ /** 展示 runner/协议已返回的 unified diff;不在 EvolClaw 内重新计算 diff。 */
225
+ function formatProtocolDiffSummary(filePath, diff) {
226
+ const MAX_DIFF_LINES = 32;
227
+ const lines = diff.trimEnd().split('\n');
228
+ const displayLines = lines.length > MAX_DIFF_LINES
229
+ ? [...lines.slice(0, MAX_DIFF_LINES), `...(省略 ${lines.length - MAX_DIFF_LINES} 行)`]
230
+ : lines;
231
+ const body = displayLines.join('\n');
232
+ return `${filePath}\n\`\`\`diff\n${body}\n\`\`\``;
233
+ }
218
234
  export class PermissionGateway {
219
235
  pending = new Map();
220
236
  eventBus;
@@ -5,6 +5,7 @@ import { encodePath } from '../../utils/cross-platform.js';
5
5
  import { chatDirPath, generateSessionId, formatTimestamp, atomicWriteJson, appendJsonl, readJsonFile, readLastJsonlLine, readAllJsonlLines, scanChatDirs, scanMetaFiles, ensureChatDir, readThreadIndex, writeThreadIndex, } from './session-fs-store.js';
6
6
  import { sessionToFile, fileToSession } from './session-mapper.js';
7
7
  import { formatSessionKey, DEFAULT_THREAD_ID } from './session-key.js';
8
+ import { tryParseChannelKey } from '../channel-loader.js';
8
9
  import path from 'path';
9
10
  import fs from 'fs';
10
11
  import os from 'os';
@@ -110,6 +111,26 @@ export class SessionManager {
110
111
  }
111
112
  return chatDirPath(this.sessionsDir, session.channelType, session.channelId, session.selfAID);
112
113
  }
114
+ deriveChannelIdentity(channel, channelId) {
115
+ const parsed = tryParseChannelKey(channel);
116
+ if (parsed) {
117
+ return { channelType: parsed.type, selfAID: parsed.type === 'aun' ? parsed.selfAID : '' };
118
+ }
119
+ const existingDir = this.findExistingChatDir(channel, channelId);
120
+ if (existingDir) {
121
+ const active = readJsonFile(path.join(existingDir, 'active.json'));
122
+ if (active) {
123
+ return { channelType: active.channelType || channel, selfAID: active.selfAID || '' };
124
+ }
125
+ for (const metaFile of scanMetaFiles(existingDir)) {
126
+ const meta = readLastJsonlLine(path.join(existingDir, metaFile));
127
+ if (meta) {
128
+ return { channelType: meta.channelType || channel, selfAID: meta.selfAID || '' };
129
+ }
130
+ }
131
+ }
132
+ return { channelType: channel, selfAID: '' };
133
+ }
113
134
  /** Public accessor: get the chat directory path for a session (for message log etc.) */
114
135
  getChatDir(session) {
115
136
  return this.resolveChatDirFromSession(session);
@@ -127,6 +148,30 @@ export class SessionManager {
127
148
  * 用于不知道 channelType/selfAID 的 caller 在调用 resolveChatDir 前定位已有目录。
128
149
  */
129
150
  findExistingChatDir(channel, channelId) {
151
+ const parsed = tryParseChannelKey(channel);
152
+ if (parsed) {
153
+ const exactDir = this.resolveChatDir(channel, channelId, parsed.type, parsed.type === 'aun' ? parsed.selfAID : undefined);
154
+ const active = readJsonFile(path.join(exactDir, 'active.json'));
155
+ if (active && active.channel === channel)
156
+ return exactDir;
157
+ for (const mf of scanMetaFiles(exactDir)) {
158
+ const meta = readLastJsonlLine(path.join(exactDir, mf));
159
+ if (meta && meta.channel === channel)
160
+ return exactDir;
161
+ }
162
+ const threadsDir = path.join(exactDir, '_threads');
163
+ if (fs.existsSync(threadsDir)) {
164
+ const threadMetas = scanMetaFiles(threadsDir);
165
+ for (const mf of threadMetas) {
166
+ const meta = readLastJsonlLine(path.join(threadsDir, mf));
167
+ if (meta && meta.channel === channel)
168
+ return exactDir;
169
+ }
170
+ }
171
+ if (fs.existsSync(exactDir))
172
+ return exactDir;
173
+ return undefined;
174
+ }
130
175
  const dirs = scanChatDirs(this.sessionsDir);
131
176
  for (const d of dirs) {
132
177
  if (d.channelId !== channelId)
@@ -686,7 +731,8 @@ export class SessionManager {
686
731
  const agentId = currentAgentId || 'claude';
687
732
  logger.info(`[SessionManager] switchProject: channel=${channel} channelId=${channelId} newPath=${newProjectPath} agent=${agentId}`);
688
733
  const inheritedChatType = this.getActiveChatType(channel, channelId);
689
- const chatDir = this.ensureResolvedChatDirSafe(channel, channelId);
734
+ const identity = this.deriveChannelIdentity(channel, channelId);
735
+ const chatDir = this.ensureResolvedChatDir(channel, channelId, identity.channelType, identity.selfAID);
690
736
  const allSessions = this.findAllSessionsInChat(chatDir, false);
691
737
  const target = allSessions
692
738
  .filter(s => s.projectPath === newProjectPath && (s.agentId || 'claude') === agentId && !s.threadId)
@@ -699,8 +745,8 @@ export class SessionManager {
699
745
  }
700
746
  // Derive selfAID and channelType from existing sessions in this chatDir
701
747
  const existingAny = allSessions[0];
702
- const selfAID = existingAny?.selfAID || '';
703
- const channelType = existingAny?.channelType || channel;
748
+ const selfAID = existingAny?.selfAID || identity.selfAID;
749
+ const channelType = existingAny?.channelType || identity.channelType;
704
750
  const session = {
705
751
  id: generateSessionId(),
706
752
  channel,
@@ -750,12 +796,10 @@ export class SessionManager {
750
796
  }
751
797
  async switchAgent(channel, channelId, projectPath, newAgentId) {
752
798
  const inheritedChatType = this.getActiveChatType(channel, channelId);
753
- // Derive channelType/selfAID from existing sessions; fall back to channel name
754
- const probeChatDir = this.resolveChatDir(channel, channelId, channel, '');
755
- const probeSessions = fs.existsSync(probeChatDir) ? this.findAllSessionsInChat(probeChatDir, false) : [];
756
- const existingAny = probeSessions[0];
757
- const channelType = existingAny?.channelType || channel;
758
- const selfAID = existingAny?.selfAID || '';
799
+ const identity = this.deriveChannelIdentity(channel, channelId);
800
+ // Derive channelType/selfAID from existing sessions; fall back to parsed channel key.
801
+ const channelType = identity.channelType;
802
+ const selfAID = identity.selfAID;
759
803
  const chatDir = this.ensureResolvedChatDir(channel, channelId, channelType, selfAID);
760
804
  const allSessions = this.findAllSessionsInChat(chatDir, false);
761
805
  const target = allSessions
@@ -1030,8 +1074,9 @@ export class SessionManager {
1030
1074
  const inheritedChatType = this.getActiveChatType(channel, channelId);
1031
1075
  // Derive selfAID and channelType from existing sessions
1032
1076
  const existingDir = this.findExistingChatDir(channel, channelId);
1033
- let channelType = channel;
1034
- let selfAID = '';
1077
+ const identity = this.deriveChannelIdentity(channel, channelId);
1078
+ let channelType = identity.channelType;
1079
+ let selfAID = identity.selfAID;
1035
1080
  let inheritedRole = 'guest';
1036
1081
  if (existingDir) {
1037
1082
  const active = readJsonFile(path.join(existingDir, 'active.json'));
@@ -1160,8 +1205,9 @@ export class SessionManager {
1160
1205
  const name = fileInfo.title || `CLI会话-${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`;
1161
1206
  // Derive selfAID and channelType from existing sessions
1162
1207
  const existingDir = this.findExistingChatDir(channel, channelId);
1163
- let channelType = channel;
1164
- let selfAID = '';
1208
+ const identity = this.deriveChannelIdentity(channel, channelId);
1209
+ let channelType = identity.channelType;
1210
+ let selfAID = identity.selfAID;
1165
1211
  if (existingDir) {
1166
1212
  const active = readJsonFile(path.join(existingDir, 'active.json'));
1167
1213
  if (active) {
@@ -176,6 +176,9 @@ function _initTables(db) {
176
176
  output_tokens INTEGER NOT NULL DEFAULT 0,
177
177
  cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
178
178
  cache_read_tokens INTEGER NOT NULL DEFAULT 0,
179
+ context_tokens INTEGER,
180
+ max_tokens INTEGER,
181
+ auto_compact_tokens INTEGER,
179
182
  degraded INTEGER NOT NULL DEFAULT 0
180
183
  );
181
184
  CREATE INDEX IF NOT EXISTS idx_mc_task ON model_calls(task_id);
@@ -194,6 +197,23 @@ function _initTables(db) {
194
197
  catch (e) {
195
198
  logger.warn(`[StatsDB] usage_daily peer_type 迁移检测失败: ${e}`);
196
199
  }
200
+ // 轻量迁移:model_calls 早期只记录 usage 原始 token,task.start 自动压缩需要
201
+ // 使用上轮真实 context/max/compact window,避免按模型名猜窗口。
202
+ try {
203
+ const cols = db.prepare(`PRAGMA table_info(model_calls)`).all();
204
+ if (!cols.some(c => c.name === 'context_tokens')) {
205
+ db.exec(`ALTER TABLE model_calls ADD COLUMN context_tokens INTEGER`);
206
+ }
207
+ if (!cols.some(c => c.name === 'max_tokens')) {
208
+ db.exec(`ALTER TABLE model_calls ADD COLUMN max_tokens INTEGER`);
209
+ }
210
+ if (!cols.some(c => c.name === 'auto_compact_tokens')) {
211
+ db.exec(`ALTER TABLE model_calls ADD COLUMN auto_compact_tokens INTEGER`);
212
+ }
213
+ }
214
+ catch (e) {
215
+ logger.warn(`[StatsDB] model_calls context window 迁移检测失败: ${e}`);
216
+ }
197
217
  }
198
218
  /**
199
219
  * 全量重建 usage_daily:从 usage_events 明细按天聚合重算。
@@ -96,12 +96,12 @@ export function insertModelCalls(evolclawHome, rows) {
96
96
  INSERT INTO model_calls
97
97
  (ts, task_id, session_id, agent_session_id, agent_aid, peer_key, call_index, model,
98
98
  request_id, message_id, input_tokens, output_tokens, cache_creation_tokens,
99
- cache_read_tokens, degraded)
100
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
99
+ cache_read_tokens, context_tokens, max_tokens, auto_compact_tokens, degraded)
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
101
101
  `);
102
102
  db.exec('BEGIN');
103
103
  for (const r of rows) {
104
- stmt.run(r.ts, r.task_id, r.session_id ?? null, r.agent_session_id ?? null, r.agent_aid, r.peer_key, r.call_index, r.model, r.request_id ?? null, r.message_id ?? null, r.input_tokens, r.output_tokens, r.cache_creation_tokens, r.cache_read_tokens, r.degraded);
104
+ stmt.run(r.ts, r.task_id, r.session_id ?? null, r.agent_session_id ?? null, r.agent_aid, r.peer_key, r.call_index, r.model, r.request_id ?? null, r.message_id ?? null, r.input_tokens, r.output_tokens, r.cache_creation_tokens, r.cache_read_tokens, r.context_tokens ?? null, r.max_tokens ?? null, r.auto_compact_tokens ?? null, r.degraded);
105
105
  }
106
106
  db.exec('COMMIT');
107
107
  }
@@ -95,6 +95,13 @@
95
95
  "type": "api_error",
96
96
  "message": "⚠️ API 返回异常响应,正在重试..."
97
97
  },
98
+ {
99
+ "id": "json-parse-error",
100
+ "match": "json parse error",
101
+ "action": "retry",
102
+ "type": "api_error",
103
+ "message": "⚠️ API 返回 JSON 解析异常,正在重试..."
104
+ },
98
105
  {
99
106
  "id": "feishu-permission",
100
107
  "match": "im:resource",
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import { MessageProcessor, buildEnvelope } from './core/message/message-processo
20
20
  import { MessageQueue } from './core/message/message-queue.js';
21
21
  import { MessageBridge } from './core/message/message-bridge.js';
22
22
  import { MessageCache } from './core/message/message-cache.js';
23
- import { CommandHandler, isProcessLevelOwner } from './core/command-handler.js';
23
+ import { CommandHandler, isProcessLevelOwner } from './core/command/command-handler.js';
24
24
  import { EventBus } from './core/event-bus.js';
25
25
  import { StatsCollector } from './utils/stats.js';
26
26
  import { AidStatsCollector } from './utils/stats.js';
@@ -59,6 +59,11 @@ function summarizeOutboundPayload(payload) {
59
59
  s.isFinal = payload.isFinal;
60
60
  s.text = payload.text;
61
61
  break;
62
+ case 'command.result':
63
+ case 'command.error':
64
+ case 'result.error':
65
+ s.text = payload.text;
66
+ break;
62
67
  case 'result.file':
63
68
  s.filePath = payload.filePath;
64
69
  break;
@@ -72,6 +77,8 @@ function summarizeOutboundPayload(payload) {
72
77
  s.interactionKind = payload.interaction?.kind?.kind;
73
78
  break;
74
79
  case 'status.started':
80
+ case 'status.progress':
81
+ case 'status.queued':
75
82
  case 'status.completed':
76
83
  case 'status.interrupted':
77
84
  case 'status.error':
@@ -809,9 +816,10 @@ async function main() {
809
816
  catch (e) {
810
817
  logger.warn(`控制 AID 首连失败(后台自动重连,不影响 daemon 主流程): ${e?.message || e}`);
811
818
  }
812
- // 控制 AID 接收 owner 指令:本轮只做 ECWeb 登录入口(/pair 取配对码)。
813
- // 发送方身份由 AUN X.509 证书链验证,非 owner 完全静默。daemon 直接处理,
814
- // 不转回 ecweb——仅「配对码是 ecweb 持有的状态」需经 localhost 取一次。
819
+ // 控制 AID 接收 owner 指令:
820
+ // 1. /pair ECWeb 配对码(文本快路径)
821
+ // 2. menu.* JSON 路由到 cmdHandler.execMenuForControl(进程级 + 全量权限)
822
+ // 发送方身份由 AUN X.509 证书链验证,非 owner 完全静默。
815
823
  controlChannel.onMessage(async (opts) => {
816
824
  try {
817
825
  if (!isProcessLevelOwner(opts.peerId, evolclawCfg.owners)) {
@@ -833,7 +841,20 @@ async function main() {
833
841
  await controlChannel.sendMessage(opts.channelId, reply);
834
842
  return;
835
843
  }
836
- // owner 发的其他内容:提示可用指令,避免无响应让 owner 困惑
844
+ // menu.* JSON 路由:owner 已在上方校验,转交 execMenuForControl(fromControlChannel=true)
845
+ let parsed;
846
+ try {
847
+ parsed = JSON.parse(text);
848
+ }
849
+ catch {
850
+ parsed = null;
851
+ }
852
+ if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string' && parsed.type.startsWith('menu.')) {
853
+ const response = await cmdHandler.execMenuForControl(parsed, opts.peerId);
854
+ await controlChannel.sendMessage(opts.channelId, JSON.stringify(response));
855
+ return;
856
+ }
857
+ // owner 发的其他内容:提示可用指令
837
858
  await controlChannel.sendMessage(opts.channelId, '可用指令:/pair(获取 ECWeb 登录配对码)');
838
859
  }
839
860
  catch (e) {
@@ -1085,16 +1106,30 @@ async function main() {
1085
1106
  aidStatsCollector.setQueueStatsProvider((agentName) => ({
1086
1107
  processing: messageQueue.getProcessingCountByAgent(agentName),
1087
1108
  queued: messageQueue.getQueueLengthByAgent(agentName),
1109
+ muted: messageQueue.isAgentMuted(agentName),
1088
1110
  }));
1089
1111
  ipcServer.setAunAidStatsProvider(() => aidStatsCollector.getAllSnapshots());
1090
1112
  ipcServer.setAunAidStatsRecorder((params) => {
1091
- aidStatsCollector.recordOutbound(params.aid, params.toPeer, Buffer.byteLength(params.text || '', 'utf-8'), params.text, false, params.encrypt, params.chatmode);
1113
+ aidStatsCollector.recordOutbound(params.aid, params.toPeer, Buffer.byteLength(params.text || '', 'utf-8'), params.text, false, params.encrypt, params.chatmode, 'send');
1092
1114
  });
1093
1115
  // ── Reload hooks: enable agentRegistry.reload() to drain/disconnect/restart channels ──
1094
1116
  const reloadHooks = buildReloadHooks({
1095
1117
  channelLoader,
1096
1118
  channelInstances,
1097
1119
  registerChannelInstance,
1120
+ unregisterChannelInstance: (channelName) => {
1121
+ processor.unregisterChannel(channelName);
1122
+ cmdHandler.unregisterChannel(channelName);
1123
+ msgBridge.removeChannel(channelName);
1124
+ },
1125
+ onChannelStarted: (inst) => {
1126
+ // startChannel 重建渠道时重新注入 AidStatsCollector(与 hot-load 路径对齐)
1127
+ if (inst.channelType === 'aun') {
1128
+ const ch = inst.channel;
1129
+ if (typeof ch?.setAidStatsCollector === 'function')
1130
+ ch.setAidStatsCollector(aidStatsCollector);
1131
+ }
1132
+ },
1098
1133
  messageQueue,
1099
1134
  });
1100
1135
  // Make reload hooks accessible to IPC handler & ctl handler (both run in this process)
@@ -1108,6 +1143,11 @@ async function main() {
1108
1143
  const instances = await channelLoader.createForAgent(agent);
1109
1144
  for (const inst of instances) {
1110
1145
  registerChannelInstance(inst);
1146
+ if (inst.channelType === 'aun') {
1147
+ const ch = inst.channel;
1148
+ if (typeof ch?.setAidStatsCollector === 'function')
1149
+ ch.setAidStatsCollector(aidStatsCollector);
1150
+ }
1111
1151
  agent.channels.set(inst.adapter.channelKey, inst.adapter);
1112
1152
  channelInstances.push(inst);
1113
1153
  }
@@ -1184,6 +1224,8 @@ async function main() {
1184
1224
  };
1185
1225
  // I3: start IPC server LAST, after all hook setup, to eliminate race window
1186
1226
  ipcServer.start();
1227
+ ipcServer.setStatsProvider(() => statsCollector.getSnapshot());
1228
+ ipcServer.startCpuTracking();
1187
1229
  // 配置 reload 走 IPC `evolagent.reload` 触发,不再用 watchFile。
1188
1230
  // 双 rename 原子写下 watchFile 的语义会被破坏,且新结构有 N 个 config.json 要监控;
1189
1231
  // 显式触发更可控。
@@ -1195,6 +1237,7 @@ async function main() {
1195
1237
  const pid = process.pid;
1196
1238
  const ppid = process.ppid;
1197
1239
  logger.info(`\n\nShutting down gracefully... (signal=${shutdownSignal}, pid=${pid}, ppid=${ppid})`);
1240
+ ipcServer.stopCpuTracking();
1198
1241
  ipcServer.stop();
1199
1242
  eventBus.publish({
1200
1243
  type: 'system:shutdown',
package/dist/ipc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import net from 'net';
2
2
  import fs from 'fs';
3
+ import os from 'os';
3
4
  import { logger } from './utils/logger.js';
4
5
  import { fileCache } from './core/daemon-file-cache.js';
5
6
  const isWindows = process.platform === 'win32';
@@ -14,6 +15,17 @@ export class IpcServer {
14
15
  aunAidStatsProvider;
15
16
  aunAidStatsRecorder;
16
17
  menuExecutor;
18
+ statsProvider;
19
+ // CPU 占用追踪:IPC handler 是一次性同步调用,无法在响应里做 200ms 异步采样,
20
+ // 故用后台 1s interval 累积 process.cpuUsage() 增量,handler 直接读最近值。
21
+ // procCpuPercent = 本 daemon 进程占单核的百分比(可 >100% 仅当多核,已 clamp 到 100);
22
+ // sysCpuPercent = 整机所有核平均忙碌百分比(由 os.cpus() times 增量算出)。
23
+ lastCpuUsage = process.cpuUsage();
24
+ lastCpuTs = Date.now();
25
+ cpuPercent = 0; // 进程级
26
+ sysCpuPercent = 0; // 系统级
27
+ lastCpuTimes = null;
28
+ cpuTimer = null;
17
29
  constructor(socketPath, getStatus, commandExecutor) {
18
30
  this.socketPath = socketPath;
19
31
  this.getStatus = getStatus;
@@ -39,6 +51,52 @@ export class IpcServer {
39
51
  setAunAidStatsRecorder(recorder) {
40
52
  this.aunAidStatsRecorder = recorder;
41
53
  }
54
+ /** Inject global StatsSnapshot provider for monitor-snapshot IPC handler */
55
+ setStatsProvider(provider) {
56
+ this.statsProvider = provider;
57
+ }
58
+ /** Start the 1s background CPU sampling loop (for monitor-snapshot). Call after start(). */
59
+ startCpuTracking() {
60
+ if (this.cpuTimer)
61
+ return;
62
+ this.cpuTimer = setInterval(() => {
63
+ const now = Date.now();
64
+ const elapsedUs = (now - this.lastCpuTs) * 1000; // wall time in microseconds
65
+ if (elapsedUs > 0) {
66
+ const usage = process.cpuUsage(this.lastCpuUsage); // delta since last sample
67
+ this.cpuPercent = Math.min(100, ((usage.user + usage.system) / elapsedUs) * 100);
68
+ }
69
+ this.lastCpuUsage = process.cpuUsage();
70
+ this.lastCpuTs = now;
71
+ // 系统级 CPU:os.cpus() 累计 times 的增量 → 整机平均忙碌率
72
+ try {
73
+ const cpus = os.cpus();
74
+ let idle = 0, total = 0;
75
+ for (const c of cpus) {
76
+ idle += c.times.idle;
77
+ total += c.times.user + c.times.nice + c.times.sys + c.times.idle + c.times.irq;
78
+ }
79
+ if (this.lastCpuTimes) {
80
+ const idleDelta = idle - this.lastCpuTimes.idle;
81
+ const totalDelta = total - this.lastCpuTimes.total;
82
+ if (totalDelta > 0) {
83
+ this.sysCpuPercent = Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
84
+ }
85
+ }
86
+ this.lastCpuTimes = { idle, total };
87
+ }
88
+ catch { /* os.cpus() 理论上不抛 */ }
89
+ }, 1000);
90
+ // Don't keep the event loop alive for sampling alone.
91
+ this.cpuTimer.unref?.();
92
+ }
93
+ /** Stop the CPU sampling loop. */
94
+ stopCpuTracking() {
95
+ if (this.cpuTimer) {
96
+ clearInterval(this.cpuTimer);
97
+ this.cpuTimer = null;
98
+ }
99
+ }
42
100
  start() {
43
101
  // Remove stale socket file (Unix only — named pipes auto-cleanup on process exit)
44
102
  if (!isNamedPipe(this.socketPath)) {
@@ -222,6 +280,47 @@ export class IpcServer {
222
280
  return { ok: false, error: e?.message ?? String(e) };
223
281
  }
224
282
  }
283
+ case 'monitor-snapshot': {
284
+ // watch web Monitor 页用:进程级 + 系统级运行指标 + 全局 stats + per-agent 汇总。
285
+ const mem = process.memoryUsage();
286
+ const totalMem = os.totalmem();
287
+ const freeMem = os.freemem();
288
+ const aids = this.aunAidProvider ? this.aunAidProvider() : [];
289
+ const aidStats = this.aunAidStatsProvider ? this.aunAidStatsProvider() : [];
290
+ const statsMap = new Map(aidStats.map((s) => [s.aid, s]));
291
+ return {
292
+ ok: true,
293
+ snapshot: {
294
+ ts: Date.now(),
295
+ uptimeMs: Math.round(process.uptime() * 1000),
296
+ cpuCount: os.cpus().length,
297
+ // 进程级:本 daemon 进程
298
+ memory: {
299
+ rss: mem.rss,
300
+ heapUsed: mem.heapUsed,
301
+ heapTotal: mem.heapTotal,
302
+ external: mem.external,
303
+ },
304
+ cpuPercent: Math.round(this.cpuPercent * 10) / 10,
305
+ // 系统级:整机
306
+ system: {
307
+ memTotal: totalMem,
308
+ memUsed: totalMem - freeMem,
309
+ memFree: freeMem,
310
+ cpuPercent: Math.round(this.sysCpuPercent * 10) / 10,
311
+ loadAvg: os.loadavg(), // [1m, 5m, 15m](Windows 恒 0)
312
+ },
313
+ stats: this.statsProvider ? this.statsProvider() : null,
314
+ agents: aids.map((a) => ({
315
+ aid: a.aid,
316
+ agentName: a.agentName,
317
+ channelName: a.channelName,
318
+ status: a.status,
319
+ stats: statsMap.get(a.aid) ?? null,
320
+ })),
321
+ },
322
+ };
323
+ }
225
324
  default:
226
325
  return { error: `unknown command: ${cmd.type}` };
227
326
  }
@@ -85,6 +85,41 @@ export function findProcesses(pattern) {
85
85
  return [];
86
86
  }
87
87
  }
88
+ /**
89
+ * Cross-platform: find PIDs listening on a TCP port.
90
+ * Used to clean up stale/orphaned listeners (e.g. manually-spawned ecweb
91
+ * that never registered a pid file) before binding the port again.
92
+ */
93
+ export function findProcessByPort(port) {
94
+ const pids = new Set();
95
+ try {
96
+ if (isWindows) {
97
+ const result = spawnSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8', windowsHide: true });
98
+ const output = result.stdout || '';
99
+ for (const line of output.split('\n')) {
100
+ // 形如: TCP 0.0.0.0:42705 0.0.0.0:0 LISTENING 23004
101
+ if (!/LISTENING/i.test(line))
102
+ continue;
103
+ const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
104
+ if (m && Number(m[1]) === port) {
105
+ const pid = Number(m[2]);
106
+ if (pid && pid !== process.pid)
107
+ pids.add(pid);
108
+ }
109
+ }
110
+ }
111
+ else {
112
+ const output = execFileSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8' }).trim();
113
+ for (const line of output.split('\n')) {
114
+ const pid = parseInt(line.trim(), 10);
115
+ if (pid && pid !== process.pid)
116
+ pids.add(pid);
117
+ }
118
+ }
119
+ }
120
+ catch { /* netstat/lsof missing or no match */ }
121
+ return [...pids];
122
+ }
88
123
  export function getProcessInfo(pid) {
89
124
  try {
90
125
  if (isWindows) {
@@ -0,0 +1,49 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { resolveGlobalPkg } from './npm-ops.js';
4
+ import { isWindows as platformIsWindows, resolveCommandPath } from './cross-platform.js';
5
+ const ECWEB_PKG = 'evolclaw-web';
6
+ const ECWEB_BIN = 'evolclaw-web';
7
+ function readEcwebPackageEntry(pkgJsonPath) {
8
+ try {
9
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
10
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[ECWEB_BIN];
11
+ if (!bin)
12
+ return null;
13
+ const entry = path.resolve(path.dirname(pkgJsonPath), bin);
14
+ return fs.existsSync(entry) ? entry : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function packageJsonBesideNpmShim(commandPath, isWindows) {
21
+ if (!isWindows || !/\.(cmd|bat)$/i.test(commandPath))
22
+ return null;
23
+ const pkgJsonPath = path.join(path.dirname(commandPath), 'node_modules', ECWEB_PKG, 'package.json');
24
+ return fs.existsSync(pkgJsonPath) ? pkgJsonPath : null;
25
+ }
26
+ /**
27
+ * Resolve how to launch ecweb as a real background process.
28
+ *
29
+ * On Windows, npm bins are usually .cmd shims. Launching that shim through a
30
+ * shell can create a visible console window; launching the package's JS entry
31
+ * through the current Node executable avoids that wrapper entirely.
32
+ */
33
+ export function resolveEcwebLaunchCommand(ecwebArgs, opts = {}) {
34
+ const nodePath = opts.nodePath ?? process.execPath;
35
+ const installed = opts.installedPkg !== undefined ? opts.installedPkg : resolveGlobalPkg(ECWEB_PKG);
36
+ let entry = installed?.path ? readEcwebPackageEntry(installed.path) : null;
37
+ if (entry) {
38
+ return { command: nodePath, args: [entry, ...ecwebArgs], entry, source: 'package-bin' };
39
+ }
40
+ const commandPath = opts.commandPath !== undefined ? opts.commandPath : resolveCommandPath(ECWEB_BIN);
41
+ if (!commandPath)
42
+ return null;
43
+ const shimPkgJson = packageJsonBesideNpmShim(commandPath, opts.isWindows ?? platformIsWindows);
44
+ entry = shimPkgJson ? readEcwebPackageEntry(shimPkgJson) : null;
45
+ if (entry) {
46
+ return { command: nodePath, args: [entry, ...ecwebArgs], entry, source: 'package-bin' };
47
+ }
48
+ return { command: commandPath, args: ecwebArgs, source: 'command' };
49
+ }
@@ -191,6 +191,22 @@ export function _setDictPath(p) {
191
191
  _dictPath = p;
192
192
  _lastMtime = 0; // 强制下次刷新
193
193
  }
194
+ // ── 上下文过长检测(统一真相源)─────────────────────────────────────
195
+ //
196
+ // 覆盖所有已知的「上下文/输入超限」错误措辞,来源包括:
197
+ // - Anthropic 标准:prompt is too long / input is too long
198
+ // - OpenAI 兼容:context_length_exceeded / maximum context length
199
+ // - 网关自定义:reached its context window limit / context window limit
200
+ // - 中文:上下文过长
201
+ //
202
+ // ⚠️ 新增措辞统一往这里加,不要再在各模块本地复制正则。
203
+ export const CONTEXT_TOO_LONG_PATTERN = /prompt is too long|input is too long|context too long|context limit|context_length_exceeded|context_window_exceeded|context window limit|reached its context window|exceed(?:s|ed)? the context window|maximum context length|上下文过长/i;
204
+ /** 判断一段文本是否为「上下文过长」类错误。空文本返回 false。 */
205
+ export function isContextTooLongText(text) {
206
+ if (!text)
207
+ return false;
208
+ return CONTEXT_TOO_LONG_PATTERN.test(text);
209
+ }
194
210
  // ── 错误分类 / 重试 / 消息 ──────────────────────────────────────────
195
211
  export function classifyError(error) {
196
212
  const msg = (error?.message || '').toLowerCase();
@@ -206,9 +222,7 @@ export function classifyError(error) {
206
222
  return ErrorType.UNKNOWN;
207
223
  }
208
224
  // 内置兜底规则(结构性、稳定的错误模式)
209
- if (msg.includes('context_length_exceeded') || msg.includes('context_compact_failed')
210
- || msg.includes('context limit') || msg.includes('input is too long')
211
- || msg.includes('上下文过长')) {
225
+ if (msg.includes('context_compact_failed') || isContextTooLongText(msg)) {
212
226
  return ErrorType.CONTEXT_TOO_LONG;
213
227
  }
214
228
  if (msg.includes('invalid_model') || msg.includes('model_not_found')
@@ -285,8 +299,7 @@ export function getErrorMessage(error, terminalReason, includeEmoji = true) {
285
299
  // 内置兜底规则(结构性错误)
286
300
  const warnPrefix = includeEmoji ? '⚠️ ' : '';
287
301
  const errPrefix = includeEmoji ? '❌ ' : '';
288
- if (msg.includes('CONTEXT_COMPACT_FAILED') || msg.includes('context_length_exceeded')
289
- || msg.includes('Context limit')) {
302
+ if (msg.includes('CONTEXT_COMPACT_FAILED') || isContextTooLongText(msg)) {
290
303
  return `${warnPrefix}上下文过长,自动压缩失败,请手动输入 /compact 重试`;
291
304
  }
292
305
  if (msg.includes('401') || msg.includes('authentication_error')) {