evolclaw-web 1.1.0 → 1.2.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.
package/dist/index.js CHANGED
@@ -67,14 +67,14 @@ const fileLog = (line) => {
67
67
  };
68
68
  const log = (line) => { logLine(line); fileLog(line); };
69
69
  // 单实例保护:
70
- // 1) 按 instance 文件杀掉登记在册的旧 watch-web 进程
71
- const { writeWatchWeb, removeWatchWeb, cleanupWatchWebs, cleanupWatchWebByPort } = await import('./process-utils.js');
72
- const killedWebs = cleanupWatchWebs();
70
+ // 1) 按 instance 文件杀掉登记在册的旧 ecweb 进程
71
+ const { writeEcweb, removeEcweb, cleanupEcwebs, cleanupEcwebByPort } = await import('./process-utils.js');
72
+ const killedWebs = cleanupEcwebs();
73
73
  for (const r of killedWebs)
74
- logLine(`${YELLOW}↺ 已清理旧 watch 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
74
+ logLine(`${YELLOW}↺ 已清理旧 ecweb 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
75
75
  // 2) 兜底:按端口杀掉 instance 文件已丢失的孤儿进程(杀不掉的僵尸)
76
76
  const WATCH_WEB_PORT = port ?? 42705;
77
- const killedByPort = cleanupWatchWebByPort(WATCH_WEB_PORT);
77
+ const killedByPort = cleanupEcwebByPort(WATCH_WEB_PORT);
78
78
  for (const pid of killedByPort)
79
79
  logLine(`${YELLOW}↺ 已强占端口 ${WATCH_WEB_PORT}:杀掉占用进程 PID ${pid}${RST}`);
80
80
  if (killedWebs.length > 0 || killedByPort.length > 0) {
@@ -90,7 +90,7 @@ catch (e) {
90
90
  process.exit(1);
91
91
  }
92
92
  // 注册 instance 文件
93
- writeWatchWeb(handle.port);
93
+ writeEcweb(handle.port);
94
94
  // 列出访问地址
95
95
  const ifaces = os.networkInterfaces();
96
96
  const lanIps = [];
@@ -101,7 +101,7 @@ for (const list of Object.values(ifaces)) {
101
101
  }
102
102
  }
103
103
  process.stdout.write(`\n${BOLD}${CYAN}🔭 EvolClaw Watch${RST} ${DIM}(home: ${p.root})${RST}\n\n`);
104
- process.stdout.write(` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(5 分钟内有效,配对后 token 缓存 24h 自动续期)${RST}\n\n`);
104
+ process.stdout.write(` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(5 分钟内有效,配对后 token 缓存 30 天,有访问自动续期)${RST}\n\n`);
105
105
  process.stdout.write(` ${BOLD}本机:${RST} http://localhost:${handle.port}\n`);
106
106
  for (const ip of lanIps)
107
107
  process.stdout.write(` ${BOLD}局域网:${RST} http://${ip}:${handle.port}\n`);
@@ -116,13 +116,13 @@ const cleanup = () => {
116
116
  return; // 幂等:raw 模式下连按 Ctrl-C/q 不应重复触发
117
117
  cleaningUp = true;
118
118
  logLine(`${YELLOW}退出中…${RST}`);
119
- removeWatchWeb();
119
+ removeEcweb();
120
120
  // 兜底:close() 万一卡住也强制退出,避免进程挂死
121
121
  const force = setTimeout(() => process.exit(0), 2000);
122
122
  force.unref();
123
123
  handle.close().finally(() => process.exit(0));
124
124
  };
125
- process.on('exit', () => removeWatchWeb());
125
+ process.on('exit', () => removeEcweb());
126
126
  process.on('SIGINT', cleanup);
127
127
  process.on('SIGTERM', cleanup);
128
128
  if (process.stdin.isTTY) {
@@ -151,25 +151,32 @@ function readCmdline(pid) {
151
151
  function instanceDir() {
152
152
  return resolvePaths().instanceDir;
153
153
  }
154
- function watchWebFile(pid) {
155
- return path.join(instanceDir(), `watch-web-${pid}.json`);
154
+ function isEcwebInstanceFile(file) {
155
+ return /^(ecweb|watch-web)-\d+\.json$/.test(file);
156
156
  }
157
- export function writeWatchWeb(port) {
157
+ function ecwebFile(pid) {
158
+ return path.join(instanceDir(), `ecweb-${pid}.json`);
159
+ }
160
+ export function writeEcweb(port) {
158
161
  const dir = instanceDir();
159
162
  fs.mkdirSync(dir, { recursive: true });
160
163
  const startedAt = getProcessStartTime(process.pid) ?? Date.now();
161
164
  const record = { pid: process.pid, startedAt, startedAtIso: new Date(startedAt).toISOString(), port };
162
- const filePath = watchWebFile(process.pid);
165
+ const filePath = ecwebFile(process.pid);
163
166
  const tmp = filePath + '.tmp';
164
167
  fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
165
168
  fs.renameSync(tmp, filePath);
166
169
  return filePath;
167
170
  }
168
- export function removeWatchWeb(pid) {
169
- try {
170
- fs.unlinkSync(watchWebFile(pid ?? process.pid));
171
+ export function removeEcweb(pid) {
172
+ const target = pid ?? process.pid;
173
+ const dir = instanceDir();
174
+ for (const prefix of ['ecweb', 'watch-web']) {
175
+ try {
176
+ fs.unlinkSync(path.join(dir, `${prefix}-${target}.json`));
177
+ }
178
+ catch { }
171
179
  }
172
- catch { }
173
180
  }
174
181
  function safeParseJson(filePath) {
175
182
  try {
@@ -180,10 +187,11 @@ function safeParseJson(filePath) {
180
187
  }
181
188
  }
182
189
  /**
183
- * 杀掉所有非自己 PID 的存活 watch-web 进程并清理文件。
190
+ * 杀掉所有非自己 PID 的存活 ecweb 进程并清理文件。
191
+ * 兼容清理迁移前遗留的 watch-web-*.json。
184
192
  * 用启动时间比对防 PID 复用。返回被杀的记录列表。
185
193
  */
186
- export function cleanupWatchWebs() {
194
+ export function cleanupEcwebs() {
187
195
  const dir = instanceDir();
188
196
  if (!fs.existsSync(dir))
189
197
  return [];
@@ -196,7 +204,7 @@ export function cleanupWatchWebs() {
196
204
  }
197
205
  const killed = [];
198
206
  for (const file of files) {
199
- if (!file.startsWith('watch-web-') || !file.endsWith('.json'))
207
+ if (!isEcwebInstanceFile(file))
200
208
  continue;
201
209
  const filePath = path.join(dir, file);
202
210
  const record = safeParseJson(filePath);
@@ -217,7 +225,7 @@ export function cleanupWatchWebs() {
217
225
  * 兜底:按端口找占用进程,确认是 ecweb 进程后 SIGKILL。
218
226
  * 用于清理 instance 文件已丢失的孤儿进程(杀不掉的僵尸)。返回被杀的 PID 列表。
219
227
  */
220
- export function cleanupWatchWebByPort(port) {
228
+ export function cleanupEcwebByPort(port) {
221
229
  const killed = [];
222
230
  for (const pid of findPidByPort(port)) {
223
231
  if (pid === process.pid || !isProcessRunning(pid))
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * - HTTP: 静态资源 + 配对 API
5
5
  * - WebSocket: 订阅式实时推送(aid / msg / session)
6
- * - 鉴权: 6 位配对码(5 分钟有效)→ token(24h,有访问自动续期),持久化到磁盘
6
+ * - 鉴权: 6 位配对码(5 分钟有效)→ token(30 天,有访问自动续期),持久化到磁盘
7
7
  * - 安全: 绑定 0.0.0.0(支持远程访问),token 校验,只读
8
8
  *
9
9
  * 与 evolclaw 的唯一通信:启动时发 ping 检查 protocolVersion(soft 校验)。
@@ -12,6 +12,7 @@ import http from 'http';
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
14
  import crypto from 'crypto';
15
+ import { spawnSync } from 'child_process';
15
16
  import { fileURLToPath } from 'url';
16
17
  import { WebSocketServer } from 'ws';
17
18
  import { resolvePaths } from './paths.js';
@@ -23,16 +24,17 @@ import { sessionSource } from './sources/session.js';
23
24
  import { cacheSource } from './sources/cache.js';
24
25
  import { systemSource } from './sources/system.js';
25
26
  import { triggersSource } from './sources/triggers.js';
27
+ import { monitorSource } from './sources/monitor.js';
26
28
  import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsByAgent, queryStatsOverview } from './sources/stats.js';
27
29
  import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
28
30
  import { ccProjectsDir } from './paths.js';
29
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
32
  const STATIC_DIR = path.join(__dirname, 'static');
31
- const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24h
33
+ const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
32
34
  const PAIRING_TTL_MS = 5 * 60 * 1000; // 5min
33
35
  const DEFAULT_PORT = 42705;
34
36
  const PROTOCOL_VERSION = 1; // 与 evolclaw ping response 对齐的软校验版本
35
- const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource };
37
+ const SOURCES = { agents: aidSource, msg: msgSource, session: sessionSource, cache: cacheSource, system: systemSource, triggers: triggersSource, monitor: monitorSource };
36
38
  // ECWeb 自身版本:渲染 System 页时随快照下发(不走 daemon IPC,ECWeb 就是这个进程)。
37
39
  function readEcwebVersion() {
38
40
  try {
@@ -52,7 +54,16 @@ const MIME = {
52
54
  '.svg': 'image/svg+xml',
53
55
  };
54
56
  function tokenStorePath() {
55
- return path.join(resolvePaths().instanceDir, 'watch-web-tokens.json');
57
+ const dir = resolvePaths().instanceDir;
58
+ const current = path.join(dir, 'ecweb-tokens.json');
59
+ const legacy = path.join(dir, 'watch-web-tokens.json');
60
+ if (!fs.existsSync(current) && fs.existsSync(legacy)) {
61
+ try {
62
+ fs.renameSync(legacy, current);
63
+ }
64
+ catch { }
65
+ }
66
+ return current;
56
67
  }
57
68
  function loadTokens() {
58
69
  try {
@@ -267,7 +278,7 @@ function handlePair(req, res, pairingCode, pairingExpiry, log) {
267
278
  store.tokens.push({ token, createdAt: now, lastActive: now, label: ip });
268
279
  saveTokens(store);
269
280
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, token }));
270
- log(`✓ 配对成功 from ${ip}(token 缓存 24h)`);
281
+ log(`✓ 配对成功 from ${ip}(token 缓存 30 天,有访问自动续期)`);
271
282
  });
272
283
  }
273
284
  // ── WebSocket connection ──
@@ -372,19 +383,69 @@ function handleConnection(ws, req, log) {
372
383
  ws.on('error', () => { });
373
384
  }
374
385
  // ── Port binding ──
375
- function bindPort(server, preferred) {
386
+ /** 跨平台:查端口上的 LISTENING 进程 PID(清理被占端口用)。 */
387
+ function findPidsOnPort(port) {
388
+ const pids = new Set();
389
+ try {
390
+ if (process.platform === 'win32') {
391
+ const out = spawnSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8', windowsHide: true }).stdout || '';
392
+ for (const line of out.split('\n')) {
393
+ if (!/LISTENING/i.test(line))
394
+ continue;
395
+ const m = line.match(/:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
396
+ if (m && Number(m[1]) === port) {
397
+ const pid = Number(m[2]);
398
+ if (pid && pid !== process.pid)
399
+ pids.add(pid);
400
+ }
401
+ }
402
+ }
403
+ else {
404
+ const out = spawnSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8' }).stdout || '';
405
+ for (const l of out.split('\n')) {
406
+ const pid = parseInt(l.trim(), 10);
407
+ if (pid && pid !== process.pid)
408
+ pids.add(pid);
409
+ }
410
+ }
411
+ }
412
+ catch { /* netstat/lsof 缺失或无匹配 */ }
413
+ return [...pids];
414
+ }
415
+ /** 杀掉占用目标端口的旧进程(强制)。 */
416
+ function killPidsOnPort(port, log) {
417
+ let killed = false;
418
+ for (const pid of findPidsOnPort(port)) {
419
+ try {
420
+ if (process.platform === 'win32')
421
+ spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true });
422
+ else
423
+ process.kill(pid, 'SIGKILL');
424
+ log(`⚠ 端口 ${port} 被旧进程 PID ${pid} 占用,已终止`);
425
+ killed = true;
426
+ }
427
+ catch { /* 已退出或无权限 */ }
428
+ }
429
+ return killed;
430
+ }
431
+ function sleepMs(ms) { return new Promise(r => setTimeout(r, ms)); }
432
+ function bindPort(server, preferred, log) {
376
433
  return new Promise((resolve, reject) => {
377
- let attempt = 0;
434
+ let killedOnce = false;
378
435
  const tryBind = (port) => {
379
- server.once('error', (err) => {
380
- if (err.code === 'EADDRINUSE' && attempt < 10) {
381
- attempt++;
382
- tryBind(port + 1);
436
+ server.once('error', async (err) => {
437
+ if (err.code === 'EADDRINUSE' && !killedOnce) {
438
+ // 默认行为:杀掉占用端口的旧进程并重试本端口(而非 +1 漂移),避免端口被占。
439
+ killedOnce = true;
440
+ killPidsOnPort(port, log);
441
+ await sleepMs(600);
442
+ tryBind(port);
383
443
  }
384
- else
444
+ else {
385
445
  reject(err);
446
+ }
386
447
  });
387
- server.listen(port, '0.0.0.0', () => resolve({ port, displaced: port !== preferred }));
448
+ server.listen(port, '0.0.0.0', () => resolve({ port, displaced: false }));
388
449
  };
389
450
  tryBind(preferred);
390
451
  });
@@ -453,7 +514,7 @@ export async function startWatchWebServer(opts = {}) {
453
514
  handleConnection(ws, req, log);
454
515
  });
455
516
  });
456
- const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT);
517
+ const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
457
518
  return {
458
519
  url: `http://0.0.0.0:${port}`,
459
520
  port,
@@ -64,13 +64,31 @@ function scanAidActivity() {
64
64
  }
65
65
  return result;
66
66
  }
67
+ // evolclaw 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
68
+ // 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
69
+ let _cachedVersion = null;
70
+ async function fetchVersion(socket) {
71
+ if (_cachedVersion)
72
+ return _cachedVersion;
73
+ try {
74
+ const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' } }, 4000);
75
+ const v = r?.ok ? r.response?.data?.version : null;
76
+ if (v)
77
+ _cachedVersion = v;
78
+ return v ?? null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
67
84
  async function buildSnapshot() {
68
85
  const p = resolvePaths();
69
- const [aidsResp, statsResp, statusResp, agentsResp] = await Promise.all([
86
+ const [aidsResp, statsResp, statusResp, agentsResp, version] = await Promise.all([
70
87
  ipcQuery(p.socket, { type: 'aun-aids' }),
71
88
  ipcQuery(p.socket, { type: 'aun-aid-stats' }),
72
89
  ipcQuery(p.socket, { type: 'status' }),
73
90
  ipcQuery(p.socket, { type: 'evolagent.list' }),
91
+ fetchVersion(p.socket),
74
92
  ]);
75
93
  const daemonRunning = aidsResp !== null || statusResp !== null;
76
94
  if (!daemonRunning) {
@@ -89,6 +107,7 @@ async function buildSnapshot() {
89
107
  stats: statsResp?.stats ?? [],
90
108
  status: statusResp ?? null,
91
109
  agents: agentsResp?.agents ?? [],
110
+ version: version ?? null,
92
111
  };
93
112
  }
94
113
  export const aidSource = {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Monitor 数据源 — 复用 daemon 的 IPC socket(`monitor-snapshot` 命令)。
3
+ *
4
+ * 拉进程级 + 系统级运行指标(CPU/内存/uptime/loadAvg)+ 全局 stats(含最近错误)
5
+ * + per-agent 汇总。IPC 无推送能力,故 2s 轮询 + JSON diff,仅在变化时 push。
6
+ *
7
+ * 时序数据在源侧维护「三档分辨率」滚动缓冲区,随快照一并下发,前端按所选周期切换:
8
+ * - fine : 2s 桶 × 60 ≈ 近 2 分钟
9
+ * - mid : 10s 桶 × 60 ≈ 近 10 分钟
10
+ * - coarse : 60s 桶 × 60 ≈ 近 1 小时
11
+ * 每个桶内对 CPU/内存做均值聚合,避免长周期下点数爆炸。
12
+ */
13
+ import { resolvePaths } from '../paths.js';
14
+ import { ipcQuery } from '../ipc-client.js';
15
+ // 模块级缓冲区——所有订阅者共享同一份累积时序(单一事实源)。
16
+ const resolutions = {
17
+ fine: { bucketMs: 2_000, cap: 60, buf: [] },
18
+ mid: { bucketMs: 10_000, cap: 60, buf: [] },
19
+ coarse: { bucketMs: 60_000, cap: 60, buf: [] },
20
+ };
21
+ /** 把一个原始样本并入指定分辨率的桶(同桶内做增量均值,跨桶则新建)。 */
22
+ function ingest(res, sample) {
23
+ const bucketTs = Math.floor(sample.ts / res.bucketMs) * res.bucketMs;
24
+ const last = res.buf[res.buf.length - 1];
25
+ if (last && last.ts === bucketTs) {
26
+ const n = last._n + 1;
27
+ last.procCpu = last.procCpu + (sample.procCpu - last.procCpu) / n;
28
+ last.sysCpu = last.sysCpu + (sample.sysCpu - last.sysCpu) / n;
29
+ last.procRss = last.procRss + (sample.procRss - last.procRss) / n;
30
+ last.sysMemUsed = last.sysMemUsed + (sample.sysMemUsed - last.sysMemUsed) / n;
31
+ last._n = n;
32
+ }
33
+ else {
34
+ res.buf.push({ ts: bucketTs, procCpu: sample.procCpu, sysCpu: sample.sysCpu, procRss: sample.procRss, sysMemUsed: sample.sysMemUsed, _n: 1 });
35
+ if (res.buf.length > res.cap)
36
+ res.buf.shift();
37
+ }
38
+ }
39
+ /** 导出某分辨率的纯数据点(剥掉内部 _n 字段)。 */
40
+ function exportBuf(res) {
41
+ return res.buf.map((b) => ({ ts: b.ts, procCpu: round1(b.procCpu), sysCpu: round1(b.sysCpu), procRss: Math.round(b.procRss), sysMemUsed: Math.round(b.sysMemUsed) }));
42
+ }
43
+ function round1(n) { return Math.round(n * 10) / 10; }
44
+ async function fetchSnapshot() {
45
+ const p = resolvePaths();
46
+ const resp = await ipcQuery(p.socket, { type: 'monitor-snapshot' }, 3000);
47
+ // resp === null → daemon 离线或 socket 不可达;resp.ok === false → 命令不受支持(旧 daemon)
48
+ if (!resp?.ok) {
49
+ return { daemonRunning: resp !== null, snapshot: null, history: { fine: [], mid: [], coarse: [] } };
50
+ }
51
+ const s = resp.snapshot;
52
+ const sample = {
53
+ ts: s.ts,
54
+ procCpu: s.cpuPercent ?? 0,
55
+ sysCpu: s.system?.cpuPercent ?? 0,
56
+ procRss: s.memory?.rss ?? 0,
57
+ sysMemUsed: s.system?.memUsed ?? 0,
58
+ };
59
+ ingest(resolutions.fine, sample);
60
+ ingest(resolutions.mid, sample);
61
+ ingest(resolutions.coarse, sample);
62
+ return {
63
+ daemonRunning: true,
64
+ snapshot: s,
65
+ history: {
66
+ fine: exportBuf(resolutions.fine),
67
+ mid: exportBuf(resolutions.mid),
68
+ coarse: exportBuf(resolutions.coarse),
69
+ },
70
+ };
71
+ }
72
+ export const monitorSource = {
73
+ kind: 'monitor',
74
+ async snapshot() {
75
+ return fetchSnapshot();
76
+ },
77
+ subscribe(_params, push) {
78
+ let lastJson = '';
79
+ let stopped = false;
80
+ const tick = async () => {
81
+ if (stopped)
82
+ return;
83
+ try {
84
+ const snap = await fetchSnapshot();
85
+ const json = JSON.stringify(snap);
86
+ if (json !== lastJson) {
87
+ lastJson = json;
88
+ push(snap);
89
+ }
90
+ }
91
+ catch { /* ignore transient IPC errors */ }
92
+ };
93
+ const timer = setInterval(tick, 2000);
94
+ return () => { stopped = true; clearInterval(timer); };
95
+ },
96
+ };
@@ -25,7 +25,16 @@ function dlog(line) { if (_dlog)
25
25
  const CACHE_VERSION = 2;
26
26
  const _metaCache = new Map();
27
27
  function cacheDir(encoded) {
28
- return path.join(resolvePaths().dataDir, 'watch-web-cache', encoded);
28
+ const dataDir = resolvePaths().dataDir;
29
+ const currentRoot = path.join(dataDir, 'ecweb-cache');
30
+ const legacyRoot = path.join(dataDir, 'watch-web-cache');
31
+ if (!fs.existsSync(currentRoot) && fs.existsSync(legacyRoot)) {
32
+ try {
33
+ fs.renameSync(legacyRoot, currentRoot);
34
+ }
35
+ catch { }
36
+ }
37
+ return path.join(currentRoot, encoded);
29
38
  }
30
39
  function readDiskCache(encoded, id, mtime, size) {
31
40
  try {
@@ -5,7 +5,7 @@
5
5
  * 另注入 ecwebVersion(ECWeb 进程自身 package.json 版本,由 server.ts 合并,不走 daemon IPC)。
6
6
  * check / upgrade 结果不在快照里——由前端按钮触发 menu.action 后写入 state。
7
7
  *
8
- * subscribe: 3s 轮询 + JSON diff,仅变化时 push。
8
+ * subscribe: 30s 轮询 + JSON diff,仅变化时 push。
9
9
  */
10
10
  import { resolvePaths } from '../paths.js';
11
11
  import { ipcQuery } from '../ipc-client.js';
@@ -45,7 +45,7 @@ export const systemSource = {
45
45
  }
46
46
  catch { /* ignore transient IPC errors */ }
47
47
  };
48
- const timer = setInterval(tick, 3000);
48
+ const timer = setInterval(tick, 30000);
49
49
  return () => { stopped = true; clearInterval(timer); };
50
50
  },
51
51
  };