ec-web 0.0.1 → 0.0.3
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/README.md +15 -3
- package/dist/debug-log.js +7 -0
- package/dist/fs-utils.js +189 -0
- package/dist/index.js +137 -0
- package/dist/ipc-client.js +40 -0
- package/dist/paths.js +55 -0
- package/dist/process-utils.js +241 -0
- package/dist/server.js +820 -0
- package/dist/sources/aid.js +139 -0
- package/dist/sources/baseagent-detector.js +72 -0
- package/dist/sources/cache.js +43 -0
- package/dist/sources/gateway.js +44 -0
- package/dist/sources/logs.js +396 -0
- package/dist/sources/models.js +75 -0
- package/dist/sources/monitor.js +179 -0
- package/dist/sources/msg.js +625 -0
- package/dist/sources/parent-package.js +77 -0
- package/dist/sources/relation-roles.js +903 -0
- package/dist/sources/role-definitions.js +1195 -0
- package/dist/sources/session-codex.js +731 -0
- package/dist/sources/session.js +489 -0
- package/dist/sources/stats.js +481 -0
- package/dist/sources/system.js +83 -0
- package/dist/sources/triggers.js +138 -0
- package/dist/sources/types.js +10 -0
- package/dist/static/app.js +10753 -0
- package/dist/static/index.html +386 -0
- package/dist/static/style.css +4138 -0
- package/dist/types/cli-permissions.js +90 -0
- package/package.json +20 -11
- package/index.js +0 -7
package/README.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# EC Web
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Web console and monitoring dashboard for EvolCore.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g ec-web
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Run
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
ec-web --home /path/to/runtime-home
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use `ec watch web` when EC Web is managed by the main daemon.
|
package/dist/fs-utils.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件系统工具层(独立版)— 内联自 evolcore session-fs-store + watch-msg 数据层。
|
|
3
|
+
*
|
|
4
|
+
* 只包含 watch-web 实际用到的函数,无任何 npm 依赖。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { resolvePaths } from './paths.js';
|
|
9
|
+
// ── 编解码(与 evolcore encodeSegment / decodeDirSegment 一致)──
|
|
10
|
+
const UNSAFE_CHARS_RE = /[<>:"/\\|?*\x00-\x1F%]/g;
|
|
11
|
+
export function encodeSegment(s) {
|
|
12
|
+
return s.replace(UNSAFE_CHARS_RE, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
|
|
13
|
+
}
|
|
14
|
+
export function decodeDirSegment(seg) {
|
|
15
|
+
return seg.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
16
|
+
}
|
|
17
|
+
/** 与 evolcore cross-platform.ts encodePath 一致(CC transcript 目录名) */
|
|
18
|
+
export function encodePath(p) {
|
|
19
|
+
const isWindows = process.platform === 'win32';
|
|
20
|
+
const norm = isWindows ? p.replace(/\\/g, '/') : p;
|
|
21
|
+
return encodeURIComponent(norm).replace(/%2F/gi, '-').replace(/%3A/gi, '-').replace(/^-/, '');
|
|
22
|
+
}
|
|
23
|
+
export function scanChatDirs(sessionsDir) {
|
|
24
|
+
const results = [];
|
|
25
|
+
let channelTypes;
|
|
26
|
+
try {
|
|
27
|
+
channelTypes = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
for (const ct of channelTypes) {
|
|
33
|
+
if (!ct.isDirectory())
|
|
34
|
+
continue;
|
|
35
|
+
const channelType = ct.name;
|
|
36
|
+
const ctDir = path.join(sessionsDir, channelType);
|
|
37
|
+
let level2;
|
|
38
|
+
try {
|
|
39
|
+
level2 = fs.readdirSync(ctDir, { withFileTypes: true });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (channelType === 'aun') {
|
|
45
|
+
for (const selfEnc of level2) {
|
|
46
|
+
if (!selfEnc.isDirectory())
|
|
47
|
+
continue;
|
|
48
|
+
const selfAID = decodeDirSegment(selfEnc.name);
|
|
49
|
+
const selfDir = path.join(ctDir, selfEnc.name);
|
|
50
|
+
let peers;
|
|
51
|
+
try {
|
|
52
|
+
peers = fs.readdirSync(selfDir, { withFileTypes: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (const peerEnc of peers) {
|
|
58
|
+
if (!peerEnc.isDirectory() || peerEnc.name.startsWith('_'))
|
|
59
|
+
continue;
|
|
60
|
+
results.push({
|
|
61
|
+
dirPath: path.join(selfDir, peerEnc.name),
|
|
62
|
+
channelType,
|
|
63
|
+
channelId: decodeDirSegment(peerEnc.name),
|
|
64
|
+
selfAID,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
for (const chanEnc of level2) {
|
|
71
|
+
if (!chanEnc.isDirectory())
|
|
72
|
+
continue;
|
|
73
|
+
results.push({
|
|
74
|
+
dirPath: path.join(ctDir, chanEnc.name),
|
|
75
|
+
channelType,
|
|
76
|
+
channelId: decodeDirSegment(chanEnc.name),
|
|
77
|
+
selfAID: '',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
export function readJsonFile(filePath) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function readAllJsonlLines(filePath) {
|
|
93
|
+
try {
|
|
94
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
95
|
+
.split('\n')
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.map(l => { try {
|
|
98
|
+
return JSON.parse(l);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
} })
|
|
103
|
+
.filter((x) => x !== null);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function isTransientProtocolMessage(entry) {
|
|
110
|
+
if (!entry)
|
|
111
|
+
return false;
|
|
112
|
+
const transient = (value) => {
|
|
113
|
+
const type = value?.trim().toLowerCase();
|
|
114
|
+
if (!type)
|
|
115
|
+
return false;
|
|
116
|
+
return [
|
|
117
|
+
'status', 'event', 'events', 'task.status', 'activity', 'thought',
|
|
118
|
+
'handoff_state', 'handoff_result',
|
|
119
|
+
].includes(type)
|
|
120
|
+
|| ['menu.', 'status.', 'event.', 'events.', 'task.status.', 'activity.']
|
|
121
|
+
.some(prefix => type.startsWith(prefix));
|
|
122
|
+
};
|
|
123
|
+
return transient(entry.payloadType) || transient(entry.msgType);
|
|
124
|
+
}
|
|
125
|
+
export function getSessionsAunDir() {
|
|
126
|
+
return path.join(resolvePaths().sessionsDir, 'aun');
|
|
127
|
+
}
|
|
128
|
+
export function listLocalAids(aunDir) {
|
|
129
|
+
try {
|
|
130
|
+
return fs.readdirSync(aunDir, { withFileTypes: true })
|
|
131
|
+
.filter(e => e.isDirectory())
|
|
132
|
+
.map(e => decodeDirSegment(e.name));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function listPeers(aunDir, localAid) {
|
|
139
|
+
const aidDir = path.join(aunDir, encodeSegment(localAid));
|
|
140
|
+
try {
|
|
141
|
+
return fs.readdirSync(aidDir, { withFileTypes: true })
|
|
142
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('_'))
|
|
143
|
+
.map(e => decodeDirSegment(e.name));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function readMessages(aunDir, localAid, peerId) {
|
|
150
|
+
return readAllJsonlLines(path.join(aunDir, encodeSegment(localAid), encodeSegment(peerId), 'messages.jsonl')).filter(m => !isTransientProtocolMessage(m));
|
|
151
|
+
}
|
|
152
|
+
function readPeerName(aunDir, localAid, peerId) {
|
|
153
|
+
return readJsonFile(path.join(aunDir, encodeSegment(localAid), encodeSegment(peerId), 'active.json'))?.metadata?.peerName ?? null;
|
|
154
|
+
}
|
|
155
|
+
export function loadAidInfo(aunDir, aid) {
|
|
156
|
+
const peers = listPeers(aunDir, aid);
|
|
157
|
+
let totalIn = 0, totalOut = 0;
|
|
158
|
+
for (const peer of peers) {
|
|
159
|
+
for (const m of readMessages(aunDir, aid, peer)) {
|
|
160
|
+
if (m.dir === 'in')
|
|
161
|
+
totalIn++;
|
|
162
|
+
else
|
|
163
|
+
totalOut++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { aid, totalIn, totalOut, peerCount: peers.length };
|
|
167
|
+
}
|
|
168
|
+
export function loadPeerInfos(aunDir, localAid) {
|
|
169
|
+
return listPeers(aunDir, localAid).map(peerId => {
|
|
170
|
+
const msgs = readMessages(aunDir, localAid, peerId);
|
|
171
|
+
let inbound = 0, outbound = 0, lastAt = 0;
|
|
172
|
+
for (const m of msgs) {
|
|
173
|
+
if (m.dir === 'in')
|
|
174
|
+
inbound++;
|
|
175
|
+
else
|
|
176
|
+
outbound++;
|
|
177
|
+
if (m.ts > lastAt)
|
|
178
|
+
lastAt = m.ts;
|
|
179
|
+
}
|
|
180
|
+
return { peerId, peerName: readPeerName(aunDir, localAid, peerId), inbound, outbound, lastAt };
|
|
181
|
+
}).sort((a, b) => b.lastAt - a.lastAt);
|
|
182
|
+
}
|
|
183
|
+
export function loadAllMessages(aunDir, localAid) {
|
|
184
|
+
const all = [];
|
|
185
|
+
for (const peer of listPeers(aunDir, localAid))
|
|
186
|
+
all.push(...readMessages(aunDir, localAid, peer));
|
|
187
|
+
all.sort((a, b) => a.ts - b.ts);
|
|
188
|
+
return all.length > 1000 ? all.slice(-1000) : all;
|
|
189
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ec-web — EvolCore 监控面板独立程序。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* ec-web [--port 42705] [--home <EVOLCORE_HOME>]
|
|
7
|
+
*
|
|
8
|
+
* 通过 EVOLCORE_HOME 定位 evolcore 数据目录,启动 HTTP+WS 服务,浏览器配对码登录。
|
|
9
|
+
* 与 evolcore daemon 通过 IPC socket(live 状态)+ 文件系统(历史数据)旁路通信。
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
// ── 解析参数 ──
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
let port;
|
|
17
|
+
let home;
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === '--port' || a === '-p')
|
|
21
|
+
port = Number(argv[++i]);
|
|
22
|
+
else if (a === '--home' || a === '-h')
|
|
23
|
+
home = argv[++i];
|
|
24
|
+
else if (a === '--help') {
|
|
25
|
+
process.stdout.write(`ec-web — EvolCore 监控面板\n\n用法:\n ec-web [--port 42705] [--home <EVOLCORE_HOME>]\n\n选项:\n --port, -p 监听端口(默认 42705,占用则自动 +1)\n --home 当前运行时数据目录(现阶段默认读 EVOLCORE_HOME 或 ~/.evolcore)\n`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// --home 优先级最高:写入环境变量,paths.ts 读取
|
|
30
|
+
if (home)
|
|
31
|
+
process.env.EVOLCORE_HOME = path.resolve(home);
|
|
32
|
+
// 延迟到环境变量设置后再导入(paths.ts 缓存 root)
|
|
33
|
+
const { resolvePaths } = await import('./paths.js');
|
|
34
|
+
const { startWatchWebServer } = await import('./server.js');
|
|
35
|
+
const p = resolvePaths();
|
|
36
|
+
// 校验 home 目录存在
|
|
37
|
+
if (!fs.existsSync(p.root)) {
|
|
38
|
+
process.stderr.write(`❌ EVOLCORE_HOME 不存在: ${p.root}\n 用 --home 指定正确路径,或设置 EVOLCORE_HOME 环境变量。\n`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const useColor = !!process.stdout.isTTY;
|
|
42
|
+
const RST = useColor ? '\x1b[0m' : '';
|
|
43
|
+
const DIM = useColor ? '\x1b[2m' : '';
|
|
44
|
+
const BOLD = useColor ? '\x1b[1m' : '';
|
|
45
|
+
const CYAN = useColor ? '\x1b[36m' : '';
|
|
46
|
+
const GREEN = useColor ? '\x1b[32m' : '';
|
|
47
|
+
const YELLOW = useColor ? '\x1b[33m' : '';
|
|
48
|
+
const logLine = (line) => {
|
|
49
|
+
const t = new Date();
|
|
50
|
+
const ts = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}:${String(t.getSeconds()).padStart(2, '0')}`;
|
|
51
|
+
process.stdout.write(`${DIM}${ts}${RST} ${line}\n`);
|
|
52
|
+
};
|
|
53
|
+
// 调试日志文件
|
|
54
|
+
const logFile = path.join(p.logs, 'watch-web.log');
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(p.logs, { recursive: true });
|
|
57
|
+
fs.writeFileSync(logFile, `# ecweb debug log\n# started ${new Date().toISOString()} pid=${process.pid}\n`);
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
const fileLog = (line) => {
|
|
61
|
+
const t = new Date();
|
|
62
|
+
const ts = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
|
63
|
+
try {
|
|
64
|
+
fs.appendFileSync(logFile, `${ts} ${line.replace(/\x1b\[[0-9;]*m/g, '')}\n`);
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
};
|
|
68
|
+
const log = (line) => { logLine(line); fileLog(line); };
|
|
69
|
+
// 单实例保护:
|
|
70
|
+
// 1) 按 instance 文件杀掉登记在册的旧 ecweb 进程
|
|
71
|
+
const { writeEcweb, removeEcweb, cleanupEcwebs, cleanupEcwebByPort } = await import('./process-utils.js');
|
|
72
|
+
const killedWebs = cleanupEcwebs();
|
|
73
|
+
for (const r of killedWebs)
|
|
74
|
+
logLine(`${YELLOW}↺ 已清理旧 ecweb 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
|
|
75
|
+
// 2) 兜底:按端口杀掉 instance 文件已丢失的孤儿进程(杀不掉的僵尸)
|
|
76
|
+
const WATCH_WEB_PORT = port ?? 42705;
|
|
77
|
+
const killedByPort = cleanupEcwebByPort(WATCH_WEB_PORT);
|
|
78
|
+
for (const pid of killedByPort)
|
|
79
|
+
logLine(`${YELLOW}↺ 已强占端口 ${WATCH_WEB_PORT}:杀掉占用进程 PID ${pid}${RST}`);
|
|
80
|
+
if (killedWebs.length > 0 || killedByPort.length > 0) {
|
|
81
|
+
// 给 OS 释放端口的时间
|
|
82
|
+
await new Promise(r => setTimeout(r, 400));
|
|
83
|
+
}
|
|
84
|
+
let handle;
|
|
85
|
+
try {
|
|
86
|
+
handle = await startWatchWebServer({ port, log });
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
process.stderr.write(`❌ 启动失败: ${e?.message || e}\n`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
// 注册 instance 文件
|
|
93
|
+
writeEcweb(handle.port);
|
|
94
|
+
// 列出访问地址
|
|
95
|
+
const ifaces = os.networkInterfaces();
|
|
96
|
+
const lanIps = [];
|
|
97
|
+
for (const list of Object.values(ifaces)) {
|
|
98
|
+
for (const ni of list || []) {
|
|
99
|
+
if (ni.family === 'IPv4' && !ni.internal)
|
|
100
|
+
lanIps.push(ni.address);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(`\n${BOLD}${CYAN}EC Web — EvolCore${RST} ${DIM}(home: ${p.root})${RST}\n\n`);
|
|
104
|
+
process.stdout.write(` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(5 分钟内有效,配对后 token 缓存 30 天,有访问自动续期)${RST}\n\n`);
|
|
105
|
+
process.stdout.write(` ${BOLD}本机:${RST} http://localhost:${handle.port}\n`);
|
|
106
|
+
for (const ip of lanIps)
|
|
107
|
+
process.stdout.write(` ${BOLD}局域网:${RST} http://${ip}:${handle.port}\n`);
|
|
108
|
+
if (handle.displaced) {
|
|
109
|
+
process.stdout.write(`\n ${YELLOW}⚠ 端口 ${WATCH_WEB_PORT} 被非 watch 进程占用,已切换到 ${handle.port}${RST}\n`);
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write(`\n ${DIM}绑定 0.0.0.0,远程可访问。Ctrl-C 退出。${RST}\n`);
|
|
112
|
+
process.stdout.write(` ${DIM}调试日志: ${logFile}${RST}\n\n`);
|
|
113
|
+
let cleaningUp = false;
|
|
114
|
+
const cleanup = () => {
|
|
115
|
+
if (cleaningUp)
|
|
116
|
+
return; // 幂等:raw 模式下连按 Ctrl-C/q 不应重复触发
|
|
117
|
+
cleaningUp = true;
|
|
118
|
+
logLine(`${YELLOW}退出中…${RST}`);
|
|
119
|
+
removeEcweb();
|
|
120
|
+
// 兜底:close() 万一卡住也强制退出,避免进程挂死
|
|
121
|
+
const force = setTimeout(() => process.exit(0), 2000);
|
|
122
|
+
force.unref();
|
|
123
|
+
handle.close().finally(() => process.exit(0));
|
|
124
|
+
};
|
|
125
|
+
process.on('exit', () => removeEcweb());
|
|
126
|
+
process.on('SIGINT', cleanup);
|
|
127
|
+
process.on('SIGTERM', cleanup);
|
|
128
|
+
if (process.stdin.isTTY) {
|
|
129
|
+
process.stdin.setRawMode(true);
|
|
130
|
+
process.stdin.resume();
|
|
131
|
+
process.stdin.on('data', (key) => {
|
|
132
|
+
// Ctrl-C (0x03) 或 q 退出
|
|
133
|
+
if (key[0] === 0x03 || key.toString() === 'q') {
|
|
134
|
+
cleanup();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evolcore IPC client(独立版)— 通过 Unix socket / 命名管道查询运行中的 daemon。
|
|
3
|
+
*
|
|
4
|
+
* 协议:newline-delimited JSON,一问一答即断。
|
|
5
|
+
* 与 evolcore src/ipc.ts 的 ipcQuery 一致;连不上返回 null(daemon 未运行)。
|
|
6
|
+
*
|
|
7
|
+
* watch 用到的只读查询:aun-aids / aun-aid-stats / status / evolagent.list / ping
|
|
8
|
+
*/
|
|
9
|
+
import net from 'net';
|
|
10
|
+
export function ipcQuery(socketPath, cmd, timeoutMs = 3000) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const conn = net.connect(socketPath);
|
|
13
|
+
let buf = '';
|
|
14
|
+
const timer = setTimeout(() => {
|
|
15
|
+
conn.destroy();
|
|
16
|
+
resolve(null);
|
|
17
|
+
}, timeoutMs);
|
|
18
|
+
conn.on('connect', () => {
|
|
19
|
+
conn.write(JSON.stringify(cmd) + '\n');
|
|
20
|
+
});
|
|
21
|
+
conn.on('data', (data) => {
|
|
22
|
+
buf += data.toString();
|
|
23
|
+
const idx = buf.indexOf('\n');
|
|
24
|
+
if (idx !== -1) {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
try {
|
|
27
|
+
resolve(JSON.parse(buf.slice(0, idx)));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
resolve(null);
|
|
31
|
+
}
|
|
32
|
+
conn.destroy();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
conn.on('error', () => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
resolve(null);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evolcore 路径解析(独立版)。
|
|
3
|
+
*
|
|
4
|
+
* watch 进程只需要 EVOLCORE_HOME 根目录,所有地址从它派生。
|
|
5
|
+
* 逻辑与 evolcore src/paths.ts 对齐,但不依赖 evolcore 代码。
|
|
6
|
+
*
|
|
7
|
+
* 根目录解析优先级:
|
|
8
|
+
* 1. --home 命令行参数(由 index.ts 写入 process.env.EVOLCORE_HOME)
|
|
9
|
+
* 2. EVOLCORE_HOME 环境变量
|
|
10
|
+
* 3. cwd/agents/defaults.json 存在 → cwd(开发模式)
|
|
11
|
+
* 4. ~/.evolcore
|
|
12
|
+
*/
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import crypto from 'crypto';
|
|
17
|
+
const isWindows = process.platform === 'win32';
|
|
18
|
+
let _root = null;
|
|
19
|
+
export function resolveRoot() {
|
|
20
|
+
if (_root)
|
|
21
|
+
return _root;
|
|
22
|
+
if (process.env.EVOLCORE_HOME) {
|
|
23
|
+
_root = process.env.EVOLCORE_HOME;
|
|
24
|
+
}
|
|
25
|
+
else if (fs.existsSync(path.join(process.cwd(), 'agents', 'defaults.json'))) {
|
|
26
|
+
_root = process.cwd();
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
_root = path.join(os.homedir(), '.evolcore');
|
|
30
|
+
}
|
|
31
|
+
return _root;
|
|
32
|
+
}
|
|
33
|
+
/** socket 路径派生:与 evolcore resolveInstanceSocketPath 完全一致 */
|
|
34
|
+
function resolveInstanceSocketPath(root) {
|
|
35
|
+
if (isWindows) {
|
|
36
|
+
const hash = crypto.createHash('sha1').update(root).digest('hex').slice(0, 12);
|
|
37
|
+
return `\\\\.\\pipe\\daemon-${hash}`;
|
|
38
|
+
}
|
|
39
|
+
return path.join(root, 'data', 'instance', 'daemon.sock');
|
|
40
|
+
}
|
|
41
|
+
export function resolvePaths() {
|
|
42
|
+
const root = resolveRoot();
|
|
43
|
+
return {
|
|
44
|
+
root,
|
|
45
|
+
sessionsDir: path.join(root, 'data', 'sessions'),
|
|
46
|
+
instanceDir: path.join(root, 'data', 'instance'),
|
|
47
|
+
dataDir: path.join(root, 'data'),
|
|
48
|
+
logs: path.join(root, 'logs'),
|
|
49
|
+
socket: resolveInstanceSocketPath(root),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** CC(Claude Code / Agent SDK)transcript 根目录 */
|
|
53
|
+
export function ccProjectsDir() {
|
|
54
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
55
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 进程工具(独立版)— 内联自 evolcore cross-platform + process-introspect + instance-registry。
|
|
3
|
+
*
|
|
4
|
+
* 只包含 ecweb 单实例保护实际用到的函数,无任何 evolcore 代码依赖。
|
|
5
|
+
* 关键不变量:杀进程前用启动时间比对防 PID 复用,按端口兜底时只杀确认是 ecweb 的进程。
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import { resolvePaths } from './paths.js';
|
|
11
|
+
const isWindows = process.platform === 'win32';
|
|
12
|
+
const isMacOS = process.platform === 'darwin';
|
|
13
|
+
/** 启动时间容差:2 秒(覆盖 macOS 秒级精度 + 时钟漂移) */
|
|
14
|
+
const START_TIME_TOLERANCE_MS = 2000;
|
|
15
|
+
// ── 进程存活 / 终止 ──
|
|
16
|
+
function isProcessRunning(pid) {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
return e.code === 'EPERM';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function killPid(pid) {
|
|
26
|
+
if (isWindows) {
|
|
27
|
+
try {
|
|
28
|
+
spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true });
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 'SIGKILL');
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ── 启动时间(PID 复用检测)──
|
|
40
|
+
export function getProcessStartTime(pid) {
|
|
41
|
+
try {
|
|
42
|
+
if (isWindows)
|
|
43
|
+
return getStartTimeWindows(pid);
|
|
44
|
+
if (isMacOS)
|
|
45
|
+
return getStartTimeMacOS(pid);
|
|
46
|
+
return getStartTimeLinux(pid);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function startTimeMatches(recorded, actual) {
|
|
53
|
+
if (actual === null)
|
|
54
|
+
return false;
|
|
55
|
+
return Math.abs(recorded - actual) < START_TIME_TOLERANCE_MS;
|
|
56
|
+
}
|
|
57
|
+
function getStartTimeLinux(pid) {
|
|
58
|
+
let stat;
|
|
59
|
+
try {
|
|
60
|
+
stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const tail = stat.slice(stat.lastIndexOf(')') + 2);
|
|
66
|
+
const starttimeJiffies = parseInt(tail.split(' ')[19], 10);
|
|
67
|
+
if (isNaN(starttimeJiffies))
|
|
68
|
+
return null;
|
|
69
|
+
let uptimeSec;
|
|
70
|
+
try {
|
|
71
|
+
uptimeSec = parseFloat(fs.readFileSync('/proc/uptime', 'utf-8').split(' ')[0]);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const clkTck = 100; // 几乎所有 Linux 为 100
|
|
77
|
+
const bootMs = Date.now() - uptimeSec * 1000;
|
|
78
|
+
return Math.round(bootMs + (starttimeJiffies / clkTck) * 1000);
|
|
79
|
+
}
|
|
80
|
+
function getStartTimeMacOS(pid) {
|
|
81
|
+
const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf-8', timeout: 3000 });
|
|
82
|
+
const out = (r.stdout || '').trim();
|
|
83
|
+
if (!out)
|
|
84
|
+
return null;
|
|
85
|
+
const ms = Date.parse(out);
|
|
86
|
+
return isNaN(ms) ? null : ms;
|
|
87
|
+
}
|
|
88
|
+
function getStartTimeWindows(pid) {
|
|
89
|
+
const r = spawnSync('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CreationDate', '/value'], { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
90
|
+
const m = (r.stdout || '').match(/CreationDate=(\d{14})/);
|
|
91
|
+
if (!m)
|
|
92
|
+
return null;
|
|
93
|
+
const s = m[1]; // yyyymmddHHMMSS
|
|
94
|
+
const ms = Date.parse(`${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(8, 10)}:${s.slice(10, 12)}:${s.slice(12, 14)}`);
|
|
95
|
+
return isNaN(ms) ? null : ms;
|
|
96
|
+
}
|
|
97
|
+
// ── 端口 → PID ──
|
|
98
|
+
export function findPidByPort(port) {
|
|
99
|
+
const pids = new Set();
|
|
100
|
+
try {
|
|
101
|
+
if (isWindows) {
|
|
102
|
+
const result = spawnSync('netstat', ['-ano', '-p', 'TCP'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
103
|
+
for (const line of (result.stdout || '').split('\n')) {
|
|
104
|
+
if (!/LISTENING/i.test(line))
|
|
105
|
+
continue;
|
|
106
|
+
const m = line.match(/[:\]](\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
|
|
107
|
+
if (m && parseInt(m[1], 10) === port) {
|
|
108
|
+
const pid = parseInt(m[2], 10);
|
|
109
|
+
if (!isNaN(pid) && pid !== process.pid)
|
|
110
|
+
pids.add(pid);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const out = spawnSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8' }).stdout || '';
|
|
116
|
+
for (const l of out.split('\n')) {
|
|
117
|
+
const pid = parseInt(l.trim(), 10);
|
|
118
|
+
if (!isNaN(pid) && pid !== process.pid)
|
|
119
|
+
pids.add(pid);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
return [...pids];
|
|
125
|
+
}
|
|
126
|
+
function readCmdline(pid) {
|
|
127
|
+
if (isWindows) {
|
|
128
|
+
try {
|
|
129
|
+
const result = spawnSync('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/value'], { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
130
|
+
const m = (result.stdout || '').match(/CommandLine=([^\r\n]+)/);
|
|
131
|
+
return m ? m[1].trim() : '';
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8').replace(/\0/g, ' ').trim();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
try {
|
|
142
|
+
const r = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
143
|
+
return r.stdout?.trim() || '';
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// ── instance 文件读写 ──
|
|
151
|
+
function instanceDir() {
|
|
152
|
+
return resolvePaths().instanceDir;
|
|
153
|
+
}
|
|
154
|
+
function isEcwebInstanceFile(file) {
|
|
155
|
+
return /^(ecweb|watch-web)-\d+\.json$/.test(file);
|
|
156
|
+
}
|
|
157
|
+
function ecwebFile(pid) {
|
|
158
|
+
return path.join(instanceDir(), `ecweb-${pid}.json`);
|
|
159
|
+
}
|
|
160
|
+
export function writeEcweb(port) {
|
|
161
|
+
const dir = instanceDir();
|
|
162
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
163
|
+
const startedAt = getProcessStartTime(process.pid) ?? Date.now();
|
|
164
|
+
const record = { pid: process.pid, startedAt, startedAtIso: new Date(startedAt).toISOString(), port };
|
|
165
|
+
const filePath = ecwebFile(process.pid);
|
|
166
|
+
const tmp = filePath + '.tmp';
|
|
167
|
+
fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
|
|
168
|
+
fs.renameSync(tmp, filePath);
|
|
169
|
+
return filePath;
|
|
170
|
+
}
|
|
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 { }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function safeParseJson(filePath) {
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 杀掉所有非自己 PID 的存活 ecweb 进程并清理文件。
|
|
191
|
+
* 兼容清理迁移前遗留的 watch-web-*.json。
|
|
192
|
+
* 用启动时间比对防 PID 复用。返回被杀的记录列表。
|
|
193
|
+
*/
|
|
194
|
+
export function cleanupEcwebs() {
|
|
195
|
+
const dir = instanceDir();
|
|
196
|
+
if (!fs.existsSync(dir))
|
|
197
|
+
return [];
|
|
198
|
+
let files;
|
|
199
|
+
try {
|
|
200
|
+
files = fs.readdirSync(dir);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
const killed = [];
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
if (!isEcwebInstanceFile(file))
|
|
208
|
+
continue;
|
|
209
|
+
const filePath = path.join(dir, file);
|
|
210
|
+
const record = safeParseJson(filePath);
|
|
211
|
+
if (record?.pid && record.pid !== process.pid) {
|
|
212
|
+
if (isProcessRunning(record.pid) && startTimeMatches(record.startedAt, getProcessStartTime(record.pid))) {
|
|
213
|
+
killPid(record.pid);
|
|
214
|
+
killed.push(record);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
fs.unlinkSync(filePath);
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return killed;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 兜底:按端口找占用进程,确认是 ecweb 进程后 SIGKILL。
|
|
226
|
+
* 用于清理 instance 文件已丢失的孤儿进程(杀不掉的僵尸)。返回被杀的 PID 列表。
|
|
227
|
+
*/
|
|
228
|
+
export function cleanupEcwebByPort(port) {
|
|
229
|
+
const killed = [];
|
|
230
|
+
for (const pid of findPidByPort(port)) {
|
|
231
|
+
if (pid === process.pid || !isProcessRunning(pid))
|
|
232
|
+
continue;
|
|
233
|
+
const cmdline = readCmdline(pid);
|
|
234
|
+
// 只杀确认是 ecweb 的进程,避免误杀别人占的端口
|
|
235
|
+
if (/ecweb|dist[\\/]index\.js/i.test(cmdline)) {
|
|
236
|
+
killPid(pid);
|
|
237
|
+
killed.push(pid);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return killed;
|
|
241
|
+
}
|