evolclaw 3.2.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 (95) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +7 -4
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -31
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1152 -140
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +58 -0
  9. package/dist/aun/aid/store.js +1 -1
  10. package/dist/aun/outbox.js +14 -2
  11. package/dist/aun/storage/download.js +1 -1
  12. package/dist/aun/storage/upload.js +13 -1
  13. package/dist/channels/aun.js +869 -358
  14. package/dist/channels/dingtalk.js +77 -140
  15. package/dist/channels/feishu.js +125 -154
  16. package/dist/channels/qqbot.js +75 -138
  17. package/dist/channels/wechat.js +75 -136
  18. package/dist/channels/wecom.js +75 -138
  19. package/dist/cli/agent-command.js +591 -0
  20. package/dist/cli/agent.js +23 -8
  21. package/dist/cli/aun-commands.js +1444 -0
  22. package/dist/cli/ctl-command.js +78 -0
  23. package/dist/cli/daemon-commands.js +2707 -0
  24. package/dist/cli/index.js +23 -4905
  25. package/dist/cli/init.js +33 -6
  26. package/dist/cli/model.js +1 -1
  27. package/dist/cli/restart-monitor.js +539 -0
  28. package/dist/cli/stats.js +558 -0
  29. package/dist/cli/version.js +87 -0
  30. package/dist/cli/watch-logs.js +33 -0
  31. package/dist/cli/watch-msg.js +5 -2
  32. package/dist/config-store.js +12 -6
  33. package/dist/core/channel-loader.js +88 -83
  34. package/dist/core/command/command-handler.js +1189 -0
  35. package/dist/core/command/menu-handler.js +1478 -0
  36. package/dist/core/command/slash-gate.js +142 -0
  37. package/dist/core/command/slash-handler.js +2090 -0
  38. package/dist/core/evolagent-registry.js +82 -0
  39. package/dist/core/evolagent.js +17 -1
  40. package/dist/core/interaction-router.js +8 -0
  41. package/dist/core/message/command-handler-agent-control.js +63 -1
  42. package/dist/core/message/im-renderer.js +91 -51
  43. package/dist/core/message/items-formatter.js +9 -1
  44. package/dist/core/message/message-bridge.js +73 -24
  45. package/dist/core/message/message-log.js +1 -0
  46. package/dist/core/message/message-processor.js +432 -94
  47. package/dist/core/message/message-queue.js +70 -2
  48. package/dist/core/message/pending-hints.js +232 -0
  49. package/dist/core/model/model-catalog.js +1 -1
  50. package/dist/core/model/model-scope.js +2 -2
  51. package/dist/core/permission.js +25 -12
  52. package/dist/core/relation/peer-identity.js +16 -1
  53. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  54. package/dist/core/session/session-manager.js +86 -26
  55. package/dist/core/session/session-title.js +26 -0
  56. package/dist/core/stats/billing.js +151 -0
  57. package/dist/core/stats/budget.js +93 -0
  58. package/dist/core/stats/db.js +334 -0
  59. package/dist/core/stats/eck-vars.js +84 -0
  60. package/dist/core/stats/index.js +10 -0
  61. package/dist/core/stats/normalizer.js +78 -0
  62. package/dist/core/stats/query.js +760 -0
  63. package/dist/core/stats/writer.js +115 -0
  64. package/dist/core/trigger/manager.js +34 -0
  65. package/dist/core/trigger/parser.js +9 -3
  66. package/dist/core/trigger/scheduler.js +20 -17
  67. package/dist/data/error-dict.json +7 -0
  68. package/dist/{agents → eck}/manifest-engine.js +20 -1
  69. package/dist/{agents → eck}/message-renderer.js +24 -1
  70. package/dist/index.js +174 -9
  71. package/dist/ipc.js +116 -1
  72. package/dist/utils/cross-platform.js +58 -5
  73. package/dist/utils/ecweb-launch.js +49 -0
  74. package/dist/utils/ecweb-pair.js +20 -0
  75. package/dist/utils/error-utils.js +18 -5
  76. package/dist/utils/npm-ops.js +38 -8
  77. package/dist/utils/stats.js +77 -6
  78. package/kits/docs/evolclaw/INDEX.md +3 -1
  79. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  80. package/kits/docs/evolclaw/fs.md +131 -0
  81. package/kits/docs/evolclaw/group-fs.md +209 -0
  82. package/kits/docs/evolclaw/stats.md +70 -0
  83. package/kits/docs/venues/aun-group.md +29 -6
  84. package/kits/docs/venues/group.md +5 -4
  85. package/kits/eck_message_manifest.json +30 -3
  86. package/kits/rules/05-venue.md +1 -1
  87. package/kits/templates/message-fragments/inject-default.md +2 -0
  88. package/package.json +5 -6
  89. package/dist/agents/baseagent-normalize.js +0 -19
  90. package/dist/core/command-handler.js +0 -3876
  91. package/dist/core/relation/peer-key.js +0 -16
  92. package/dist/evolclaw-config.js +0 -11
  93. package/dist/utils/channel-helpers.js +0 -46
  94. /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
  95. /package/dist/{agents → eck}/kit-renderer.js +0 -0
package/dist/index.js CHANGED
@@ -2,9 +2,8 @@ import { ClaudeSessionFileAdapter } from './core/session/adapters/claude-session
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
4
  import { ensureDataDirs, resolvePaths, getPackageRoot, agentMdPath } from './paths.js';
5
- import { resolveAnthropicConfig } from './agents/resolve.js';
6
- import { loadDefaults, autoMigrateIfNeeded, migrateIdentitiesIfNeeded, migrateProcessConfigIfNeeded } from './config-store.js';
7
- import { loadEvolclawConfig } from './evolclaw-config.js';
5
+ import { resolveAnthropicConfig } from './agents/baseagent.js';
6
+ import { loadDefaults, autoMigrateIfNeeded, migrateIdentitiesIfNeeded, migrateProcessConfigIfNeeded, loadEvolclawConfig } from './config-store.js';
8
7
  import { CONFIG_SCHEMA_VERSION } from './types.js';
9
8
  import dotenv from 'dotenv';
10
9
  import { SessionManager } from './core/session/session-manager.js';
@@ -21,7 +20,7 @@ import { MessageProcessor, buildEnvelope } from './core/message/message-processo
21
20
  import { MessageQueue } from './core/message/message-queue.js';
22
21
  import { MessageBridge } from './core/message/message-bridge.js';
23
22
  import { MessageCache } from './core/message/message-cache.js';
24
- import { CommandHandler } from './core/command-handler.js';
23
+ import { CommandHandler, isProcessLevelOwner } from './core/command/command-handler.js';
25
24
  import { EventBus } from './core/event-bus.js';
26
25
  import { StatsCollector } from './utils/stats.js';
27
26
  import { AidStatsCollector } from './utils/stats.js';
@@ -33,9 +32,10 @@ import { EvolAgentRegistry } from './core/evolagent-registry.js';
33
32
  import { buildReloadHooks } from './core/channel-loader.js';
34
33
  import { IpcServer } from './ipc.js';
35
34
  import { logger, setLogLevel } from './utils/logger.js';
35
+ import { fetchEcwebPairCode } from './utils/ecweb-pair.js';
36
36
  import { writeMain, removeAll, isMainWinner, scanInstances } from './utils/instance-registry.js';
37
37
  import { detectDuplicates } from './core/evolagent-registry.js';
38
- import { loadKitManifest, cleanEckDebug, invalidateKitCache } from './agents/kit-renderer.js';
38
+ import { loadKitManifest, cleanEckDebug, invalidateKitCache } from './eck/kit-renderer.js';
39
39
  import { initEck } from './eck/init.js';
40
40
  import { TriggerManager } from './core/trigger/manager.js';
41
41
  import { TriggerScheduler, calcNextFireAt } from './core/trigger/scheduler.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':
@@ -316,6 +323,40 @@ async function main() {
316
323
  // Per-AID 消息统计收集器(累计,供 watch aid 实时展示)
317
324
  const aidStatsCollector = new AidStatsCollector(eventBus);
318
325
  aidStatsCollector.setSessionsDir(paths.sessionsDir);
326
+ // 持久化网络流量到 message_events 表
327
+ aidStatsCollector.onMessage = (ev) => {
328
+ import('./core/stats/writer.js').then(({ insertMessageEvent }) => {
329
+ insertMessageEvent(paths.root, ev);
330
+ }).catch(() => { });
331
+ };
332
+ // 日聚合表 usage_daily:首次启动回填 + 每日自愈。
333
+ // 首次:表为空但明细非空时全量回填历史数据;之后靠 writer 写时增量维护。
334
+ // 自愈:每日全量重建一次,纠正任何写时漂移。
335
+ import('./core/stats/db.js').then(({ getDb, rebuildDailyRollup }) => {
336
+ const db = getDb(paths.root);
337
+ if (!db)
338
+ return;
339
+ try {
340
+ const daily = db.prepare('SELECT COUNT(*) AS n FROM usage_daily').get();
341
+ const events = db.prepare('SELECT COUNT(*) AS n FROM usage_events').get();
342
+ if (daily.n === 0 && events.n > 0) {
343
+ logger.info('[Stats] usage_daily 为空,回填历史数据…');
344
+ rebuildDailyRollup(paths.root);
345
+ }
346
+ }
347
+ catch (e) {
348
+ logger.warn(`[Stats] usage_daily 回填检测失败(非致命): ${e}`);
349
+ }
350
+ // 每日自愈(24h),纠正写时增量漂移。
351
+ setInterval(() => {
352
+ try {
353
+ rebuildDailyRollup(paths.root);
354
+ }
355
+ catch (e) {
356
+ logger.warn(`[Stats] usage_daily 自愈失败(非致命): ${e}`);
357
+ }
358
+ }, 24 * 60 * 60 * 1000);
359
+ }).catch(() => { });
319
360
  // 初始化 SessionManager(文件系统后端)
320
361
  const sessionManager = new SessionManager(paths.sessionsDir, eventBus, (channel, userId) => agentRegistry.isOwner(channel, userId), (channel, userId) => agentRegistry.isAdmin(channel, userId));
321
362
  // sessionMode 解析:从 channel 路由到具体 agent,按 agent.config.chatmode
@@ -449,7 +490,7 @@ async function main() {
449
490
  const evol = evolagentName || primaryAgent.aid;
450
491
  const agent = agentMap.get(`${evol}::${baseagent}`)
451
492
  || agentMap.get(primaryRunnerKey);
452
- if (agent?.hasActiveStream(sessionKey)) {
493
+ if (agent) {
453
494
  await agent.interrupt(sessionKey);
454
495
  }
455
496
  });
@@ -497,6 +538,17 @@ async function main() {
497
538
  }
498
539
  }
499
540
  scheduler.setFireCallback((msg, trigger) => {
541
+ const onEnqueueFailed = (err) => {
542
+ const error = err instanceof Error ? err.message : String(err);
543
+ logger.error(`[Trigger] Enqueue failed ${trigger.id}: ${error}`);
544
+ eventBus.publish({
545
+ type: 'trigger:failed', triggerId: trigger.id, name: trigger.name,
546
+ messageId: msg.messageId || '', error,
547
+ targetChannel: trigger.targetChannel, targetChannelId: trigger.targetChannelId,
548
+ fireTime: msg.triggerMeta?.fireTime ?? Date.now(), phase: 'enqueue',
549
+ });
550
+ scheduler.onTriggerComplete(trigger.id, 'failed');
551
+ };
500
552
  if (trigger.targetSessionStrategy === 'current' && trigger.boundSessionId) {
501
553
  const boundId = trigger.boundSessionId;
502
554
  if (messageQueue.isProcessing(boundId)) {
@@ -509,12 +561,12 @@ async function main() {
509
561
  return;
510
562
  }
511
563
  messageQueue.enqueue(boundId, msg, bound.projectPath, { interruptible: false })
512
- .catch(err => logger.error(`[Trigger] Enqueue failed ${trigger.id}: ${err}`));
564
+ .catch(onEnqueueFailed);
513
565
  });
514
566
  return;
515
567
  }
516
568
  messageQueue.enqueue(`${msg.channel}:${msg.channelId}`, msg, primaryProjectPath, { interruptible: false })
517
- .catch(err => logger.error(`[Trigger] Enqueue failed ${trigger.id}: ${err}`));
569
+ .catch(onEnqueueFailed);
518
570
  });
519
571
  // Subscribe to trigger:completed/failed/skipped to update cron inflight state
520
572
  eventBus.subscribe('trigger:completed', (ev) => scheduler.onTriggerComplete(ev.triggerId, 'completed'));
@@ -523,6 +575,41 @@ async function main() {
523
575
  if (ev.reason === 'interrupted')
524
576
  scheduler.onTriggerComplete(ev.triggerId, 'interrupted');
525
577
  });
578
+ // ── Trigger 失败/跳过通知:向 targetChannel 发送告警消息 ──
579
+ eventBus.subscribe('trigger:failed', (ev) => {
580
+ const adapter = processor.getAdapter(ev.targetChannel);
581
+ if (!adapter)
582
+ return;
583
+ const phaseLabel = ev.phase === 'enqueue' ? '入队' : '执行';
584
+ const timeStr = ev.fireTime ? new Date(ev.fireTime).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) : '未知';
585
+ const text = `⚠️ 定时任务 [${ev.name || ev.triggerId}] 执行失败\n阶段:${phaseLabel}\n原因:${ev.error}\n触发时间:${timeStr}`;
586
+ const envelope = {
587
+ taskId: `trigger-notify:${ev.triggerId}`,
588
+ channel: ev.targetChannel,
589
+ channelId: ev.targetChannelId,
590
+ agentName: 'system',
591
+ chatmode: 'interactive',
592
+ timestamp: Date.now(),
593
+ };
594
+ adapter.send(envelope, { kind: 'result.text', text, isFinal: true, format: 'plain' }).catch(() => { });
595
+ });
596
+ eventBus.subscribe('trigger:skipped', (ev) => {
597
+ if (ev.reason !== 'overlap')
598
+ return;
599
+ const adapter = processor.getAdapter(ev.targetChannel);
600
+ if (!adapter)
601
+ return;
602
+ const text = `⚠️ 定时任务 [${ev.name || ev.triggerId}] 本次跳过(上次执行仍在进行中)`;
603
+ const envelope = {
604
+ taskId: `trigger-notify:${ev.triggerId}`,
605
+ channel: ev.targetChannel,
606
+ channelId: ev.targetChannelId,
607
+ agentName: 'system',
608
+ chatmode: 'interactive',
609
+ timestamp: Date.now(),
610
+ };
611
+ adapter.send(envelope, { kind: 'result.text', text, isFinal: true, format: 'plain' }).catch(() => { });
612
+ });
526
613
  // Note: only the primary agent's scheduler is wired to cmdHandler.
527
614
  // Non-primary agent channels will receive "⚠️ 触发器功能未启用" when using /trigger.
528
615
  // Full per-channel scheduler routing is a future improvement.
@@ -651,6 +738,7 @@ async function main() {
651
738
  createdByPeerId: '__system__',
652
739
  createdByChannel: '__system__',
653
740
  fireCount: 0,
741
+ failCount: 0,
654
742
  createdAt: Date.now(),
655
743
  updatedAt: Date.now(),
656
744
  };
@@ -700,6 +788,15 @@ async function main() {
700
788
  });
701
789
  }
702
790
  // ── 控制 AID(daemon 进程身份):pureIdentity 接入 AUN,独立于 evolagent ──
791
+ // 证书缺失检测/生成在 CLI 侧(evolclaw start)完成。daemon 是后台进程无终端,
792
+ // 这里只做兜底:证书缺失时 warn 并继续(AUNChannel 内部后台重连),绝不阻塞。
793
+ if (evolclawCfg.aid) {
794
+ const aunPath = resolvePaths().root;
795
+ const certKey = path.join(aunPath, 'AIDs', evolclawCfg.aid, 'private', 'key.json');
796
+ if (!fs.existsSync(certKey)) {
797
+ logger.warn(`控制 AID 证书缺失:${evolclawCfg.aid}(AUN 控制通道后台重连;如需重建运行 evolclaw init)`);
798
+ }
799
+ }
703
800
  let controlChannel;
704
801
  if (evolclawCfg.aid) {
705
802
  controlChannel = new AUNChannel({
@@ -719,6 +816,51 @@ async function main() {
719
816
  catch (e) {
720
817
  logger.warn(`控制 AID 首连失败(后台自动重连,不影响 daemon 主流程): ${e?.message || e}`);
721
818
  }
819
+ // 控制 AID 接收 owner 指令:
820
+ // 1. /pair — ECWeb 配对码(文本快路径)
821
+ // 2. menu.* JSON — 路由到 cmdHandler.execMenuForControl(进程级 + 全量权限)
822
+ // 发送方身份由 AUN X.509 证书链验证,非 owner 完全静默。
823
+ controlChannel.onMessage(async (opts) => {
824
+ try {
825
+ if (!isProcessLevelOwner(opts.peerId, evolclawCfg.owners)) {
826
+ logger.debug(`控制 AID 收到非 owner 消息,忽略: from=${opts.peerId}`);
827
+ return;
828
+ }
829
+ const text = (opts.content || '').trim();
830
+ if (text.toLowerCase() === '/pair') {
831
+ const port = evolclawCfg.ecweb?.port ?? 42705;
832
+ const pair = await fetchEcwebPairCode(port);
833
+ let reply;
834
+ if (pair) {
835
+ const mins = Math.max(0, Math.round((pair.expiresAt - Date.now()) / 60000));
836
+ reply = `ECWeb 配对码:${pair.code}(约 ${mins} 分钟内有效)\n在浏览器打开 ECWeb 后输入此码登录`;
837
+ }
838
+ else {
839
+ reply = 'ECWeb 未运行或暂不可达。请在主机运行 ec watch web 启动后重试。';
840
+ }
841
+ await controlChannel.sendMessage(opts.channelId, reply);
842
+ return;
843
+ }
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 发的其他内容:提示可用指令
858
+ await controlChannel.sendMessage(opts.channelId, '可用指令:/pair(获取 ECWeb 登录配对码)');
859
+ }
860
+ catch (e) {
861
+ logger.warn(`控制 AID 消息处理失败: ${e?.message || e}`);
862
+ }
863
+ });
722
864
  }
723
865
  // 上线通知:延迟 1-3 秒后向 owner 发送上线消息(带 name + 工作目录)
724
866
  // 需在配置中 debug.upmsg: true 手动开启
@@ -934,6 +1076,7 @@ async function main() {
934
1076
  }, async (cmd, sessionId) => cmdHandler.handleCtl(cmd, sessionId));
935
1077
  // M3: direct call (not cast) — wire EvolAgentRegistry into IPC for evolagent.* handlers
936
1078
  ipcServer.setAgentRegistry(agentRegistry);
1079
+ ipcServer.setMenuExecutor((payload) => cmdHandler.execMenuForEcweb(payload));
937
1080
  // 注入 AUN AID 状态聚合器:遍历所有 aun 类型 channel,调 getAidState() 收集
938
1081
  ipcServer.setAunAidProvider(() => {
939
1082
  const out = [];
@@ -963,16 +1106,30 @@ async function main() {
963
1106
  aidStatsCollector.setQueueStatsProvider((agentName) => ({
964
1107
  processing: messageQueue.getProcessingCountByAgent(agentName),
965
1108
  queued: messageQueue.getQueueLengthByAgent(agentName),
1109
+ muted: messageQueue.isAgentMuted(agentName),
966
1110
  }));
967
1111
  ipcServer.setAunAidStatsProvider(() => aidStatsCollector.getAllSnapshots());
968
1112
  ipcServer.setAunAidStatsRecorder((params) => {
969
- 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');
970
1114
  });
971
1115
  // ── Reload hooks: enable agentRegistry.reload() to drain/disconnect/restart channels ──
972
1116
  const reloadHooks = buildReloadHooks({
973
1117
  channelLoader,
974
1118
  channelInstances,
975
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
+ },
976
1133
  messageQueue,
977
1134
  });
978
1135
  // Make reload hooks accessible to IPC handler & ctl handler (both run in this process)
@@ -986,6 +1143,11 @@ async function main() {
986
1143
  const instances = await channelLoader.createForAgent(agent);
987
1144
  for (const inst of instances) {
988
1145
  registerChannelInstance(inst);
1146
+ if (inst.channelType === 'aun') {
1147
+ const ch = inst.channel;
1148
+ if (typeof ch?.setAidStatsCollector === 'function')
1149
+ ch.setAidStatsCollector(aidStatsCollector);
1150
+ }
989
1151
  agent.channels.set(inst.adapter.channelKey, inst.adapter);
990
1152
  channelInstances.push(inst);
991
1153
  }
@@ -1062,6 +1224,8 @@ async function main() {
1062
1224
  };
1063
1225
  // I3: start IPC server LAST, after all hook setup, to eliminate race window
1064
1226
  ipcServer.start();
1227
+ ipcServer.setStatsProvider(() => statsCollector.getSnapshot());
1228
+ ipcServer.startCpuTracking();
1065
1229
  // 配置 reload 走 IPC `evolagent.reload` 触发,不再用 watchFile。
1066
1230
  // 双 rename 原子写下 watchFile 的语义会被破坏,且新结构有 N 个 config.json 要监控;
1067
1231
  // 显式触发更可控。
@@ -1073,6 +1237,7 @@ async function main() {
1073
1237
  const pid = process.pid;
1074
1238
  const ppid = process.ppid;
1075
1239
  logger.info(`\n\nShutting down gracefully... (signal=${shutdownSignal}, pid=${pid}, ppid=${ppid})`);
1240
+ ipcServer.stopCpuTracking();
1076
1241
  ipcServer.stop();
1077
1242
  eventBus.publish({
1078
1243
  type: 'system:shutdown',
package/dist/ipc.js CHANGED
@@ -1,7 +1,8 @@
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
- import { fileCache } from './core/cache/file-cache.js';
5
+ import { fileCache } from './core/daemon-file-cache.js';
5
6
  const isWindows = process.platform === 'win32';
6
7
  const isNamedPipe = (p) => isWindows && p.startsWith('\\\\.\\pipe\\');
7
8
  export class IpcServer {
@@ -13,6 +14,18 @@ export class IpcServer {
13
14
  aunAidProvider;
14
15
  aunAidStatsProvider;
15
16
  aunAidStatsRecorder;
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;
16
29
  constructor(socketPath, getStatus, commandExecutor) {
17
30
  this.socketPath = socketPath;
18
31
  this.getStatus = getStatus;
@@ -22,6 +35,10 @@ export class IpcServer {
22
35
  setAgentRegistry(registry) {
23
36
  this.agentRegistry = registry;
24
37
  }
38
+ /** Inject menu.* executor (ECWeb Control proxies menu requests through this) */
39
+ setMenuExecutor(executor) {
40
+ this.menuExecutor = executor;
41
+ }
25
42
  /** Inject AUN AID state aggregator for aun-aids IPC handler */
26
43
  setAunAidProvider(provider) {
27
44
  this.aunAidProvider = provider;
@@ -34,6 +51,52 @@ export class IpcServer {
34
51
  setAunAidStatsRecorder(recorder) {
35
52
  this.aunAidStatsRecorder = recorder;
36
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
+ }
37
100
  start() {
38
101
  // Remove stale socket file (Unix only — named pipes auto-cleanup on process exit)
39
102
  if (!isNamedPipe(this.socketPath)) {
@@ -206,6 +269,58 @@ export class IpcServer {
206
269
  return { ok: false, error: e?.message || String(e) };
207
270
  }
208
271
  }
272
+ case 'menu.exec': {
273
+ if (!this.menuExecutor)
274
+ return { ok: false, error: 'menu.exec not configured' };
275
+ try {
276
+ const response = await this.menuExecutor(cmd.payload);
277
+ return { ok: true, response };
278
+ }
279
+ catch (e) {
280
+ return { ok: false, error: e?.message ?? String(e) };
281
+ }
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
+ }
209
324
  default:
210
325
  return { error: `unknown command: ${cmd.type}` };
211
326
  }
@@ -5,15 +5,33 @@ import { promisify } from 'util';
5
5
  import fs from 'fs';
6
6
  const execFileAsync = promisify(execFile);
7
7
  export const isWindows = process.platform === 'win32';
8
+ const ENCODE_PATH_MAX = 200;
9
+ function encodePathHash(s) {
10
+ let h = 0;
11
+ for (let i = 0; i < s.length; i++)
12
+ h = (h << 5) - h + s.charCodeAt(i) | 0;
13
+ return Math.abs(h).toString(36);
14
+ }
8
15
  /**
9
16
  * Encode project path as directory name (Claude SDK convention).
10
- * Replace all path separators with '-'.
11
- * e.g. /home/user/project -> -home-user-project
12
- * C:\Users\project -> C--Users-project
17
+ * Mirrors the SDK's F0(L_(path)) logic:
18
+ * 1. path.resolve realpath (if exists) → Unicode NFC
19
+ * 2. replace every non-alphanumeric character with '-'
20
+ * 3. truncate to 200 chars + hash suffix for long paths
21
+ * This must stay in sync with the SDK so evolclaw can locate session files.
13
22
  */
14
23
  export function encodePath(projectPath) {
15
- const normalized = projectPath.replace(/[/\\]+$/, '');
16
- return normalized.replace(/[/\\:]/g, '-');
24
+ const resolved = path.resolve(projectPath);
25
+ let real = resolved;
26
+ try {
27
+ real = fs.realpathSync(resolved);
28
+ }
29
+ catch { }
30
+ const nfc = real.normalize('NFC');
31
+ const encoded = nfc.replace(/[^a-zA-Z0-9]/g, '-');
32
+ if (encoded.length <= ENCODE_PATH_MAX)
33
+ return encoded;
34
+ return `${encoded.slice(0, ENCODE_PATH_MAX)}-${encodePathHash(nfc)}`;
17
35
  }
18
36
  /**
19
37
  * Cross-platform process liveness check.
@@ -67,6 +85,41 @@ export function findProcesses(pattern) {
67
85
  return [];
68
86
  }
69
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
+ }
70
123
  export function getProcessInfo(pid) {
71
124
  try {
72
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
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * ECWeb 配对码取码 helper(共享给 daemon 和 CLI)。
3
+ *
4
+ * 配对码是 ecweb 进程自己生成并持有的内部状态。daemon/CLI 通过 ecweb 的
5
+ * localhost-only HTTP 接口 GET /api/pair-code 取当前码(远程访问被 ecweb 403 拒绝)。
6
+ */
7
+ /** 经 localhost 拉取 ecweb 当前配对码(仅本机可取)。失败返回 null。 */
8
+ export async function fetchEcwebPairCode(port) {
9
+ try {
10
+ const resp = await fetch(`http://127.0.0.1:${port}/api/pair-code`, {
11
+ signal: AbortSignal.timeout(2000),
12
+ });
13
+ if (!resp.ok)
14
+ return null;
15
+ return await resp.json();
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
@@ -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')) {