evolclaw-web 1.0.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.
@@ -0,0 +1,7 @@
1
+ let _writer = null;
2
+ export function setDebugLog(writer) { _writer = writer; }
3
+ export function dlog(line) { if (_writer)
4
+ try {
5
+ _writer(line);
6
+ }
7
+ catch { } }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * 文件系统工具层(独立版)— 内联自 evolclaw 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
+ // ── 编解码(与 evolclaw 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
+ /** 与 evolclaw 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 getSessionsAunDir() {
110
+ return path.join(resolvePaths().sessionsDir, 'aun');
111
+ }
112
+ export function listLocalAids(aunDir) {
113
+ try {
114
+ return fs.readdirSync(aunDir, { withFileTypes: true })
115
+ .filter(e => e.isDirectory())
116
+ .map(e => decodeDirSegment(e.name));
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
122
+ export function listPeers(aunDir, localAid) {
123
+ const aidDir = path.join(aunDir, encodeSegment(localAid));
124
+ try {
125
+ return fs.readdirSync(aidDir, { withFileTypes: true })
126
+ .filter(e => e.isDirectory() && !e.name.startsWith('_'))
127
+ .map(e => decodeDirSegment(e.name));
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ }
133
+ export function readMessages(aunDir, localAid, peerId) {
134
+ return readAllJsonlLines(path.join(aunDir, encodeSegment(localAid), encodeSegment(peerId), 'messages.jsonl'));
135
+ }
136
+ function readPeerName(aunDir, localAid, peerId) {
137
+ return readJsonFile(path.join(aunDir, encodeSegment(localAid), encodeSegment(peerId), 'active.json'))?.metadata?.peerName ?? null;
138
+ }
139
+ export function loadAidInfo(aunDir, aid) {
140
+ const peers = listPeers(aunDir, aid);
141
+ let totalIn = 0, totalOut = 0;
142
+ for (const peer of peers) {
143
+ for (const m of readMessages(aunDir, aid, peer)) {
144
+ if (m.dir === 'in')
145
+ totalIn++;
146
+ else
147
+ totalOut++;
148
+ }
149
+ }
150
+ return { aid, totalIn, totalOut, peerCount: peers.length };
151
+ }
152
+ export function loadPeerInfos(aunDir, localAid) {
153
+ return listPeers(aunDir, localAid).map(peerId => {
154
+ const msgs = readMessages(aunDir, localAid, peerId);
155
+ let inbound = 0, outbound = 0, lastAt = 0;
156
+ for (const m of msgs) {
157
+ if (m.dir === 'in')
158
+ inbound++;
159
+ else
160
+ outbound++;
161
+ if (m.ts > lastAt)
162
+ lastAt = m.ts;
163
+ }
164
+ return { peerId, peerName: readPeerName(aunDir, localAid, peerId), inbound, outbound, lastAt };
165
+ }).sort((a, b) => b.lastAt - a.lastAt);
166
+ }
167
+ export function loadAllMessages(aunDir, localAid) {
168
+ const all = [];
169
+ for (const peer of listPeers(aunDir, localAid))
170
+ all.push(...readMessages(aunDir, localAid, peer));
171
+ all.sort((a, b) => a.ts - b.ts);
172
+ return all.length > 1000 ? all.slice(-1000) : all;
173
+ }
package/dist/index.js ADDED
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ecweb — EvolClaw 监控面板独立程序。
4
+ *
5
+ * 用法:
6
+ * ecweb [--port 20030] [--home <EVOLCLAW_HOME>]
7
+ *
8
+ * 通过 EVOLCLAW_HOME 定位 evolclaw 数据目录,启动 HTTP+WS 服务,浏览器配对码登录。
9
+ * 与 evolclaw 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(`ecweb — EvolClaw 监控面板\n\n用法:\n ecweb [--port 20030] [--home <EVOLCLAW_HOME>]\n\n选项:\n --port, -p 监听端口(默认 20030,占用则自动 +1)\n --home EVOLCLAW_HOME 数据目录(默认读环境变量或 ~/.evolclaw)\n`);
26
+ process.exit(0);
27
+ }
28
+ }
29
+ // --home 优先级最高:写入环境变量,paths.ts 读取
30
+ if (home)
31
+ process.env.EVOLCLAW_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(`❌ EVOLCLAW_HOME 不存在: ${p.root}\n 用 --home 指定正确路径,或设置 EVOLCLAW_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 文件杀掉登记在册的旧 watch-web 进程
71
+ const { writeWatchWeb, removeWatchWeb, cleanupWatchWebs, cleanupWatchWebByPort } = await import('./process-utils.js');
72
+ const killedWebs = cleanupWatchWebs();
73
+ for (const r of killedWebs)
74
+ logLine(`${YELLOW}↺ 已清理旧 watch 进程 PID ${r.pid}(端口 ${r.port})${RST}`);
75
+ // 2) 兜底:按端口杀掉 instance 文件已丢失的孤儿进程(杀不掉的僵尸)
76
+ const WATCH_WEB_PORT = port ?? 20030;
77
+ const killedByPort = cleanupWatchWebByPort(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
+ writeWatchWeb(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}🔭 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`);
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
+ const cleanup = () => {
114
+ removeWatchWeb();
115
+ handle.close().finally(() => process.exit(0));
116
+ };
117
+ process.on('exit', () => removeWatchWeb());
118
+ process.on('SIGINT', cleanup);
119
+ process.on('SIGTERM', cleanup);
120
+ if (process.stdin.isTTY) {
121
+ process.stdin.setRawMode(true);
122
+ process.stdin.resume();
123
+ process.stdin.on('data', (key) => {
124
+ // Ctrl-C (0x03) 或 q 退出
125
+ if (key[0] === 0x03 || key.toString() === 'q') {
126
+ logLine(`${YELLOW}退出中…${RST}`);
127
+ cleanup();
128
+ }
129
+ });
130
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * evolclaw IPC client(独立版)— 通过 Unix socket / 命名管道查询运行中的 daemon。
3
+ *
4
+ * 协议:newline-delimited JSON,一问一答即断。
5
+ * 与 evolclaw 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
+ * evolclaw 路径解析(独立版)。
3
+ *
4
+ * watch 进程只需要 EVOLCLAW_HOME 根目录,所有地址从它派生。
5
+ * 逻辑与 evolclaw src/paths.ts 对齐,但不依赖 evolclaw 代码。
6
+ *
7
+ * 根目录解析优先级:
8
+ * 1. --home 命令行参数(由 index.ts 写入 process.env.EVOLCLAW_HOME)
9
+ * 2. EVOLCLAW_HOME 环境变量
10
+ * 3. cwd/agents/defaults.json 存在 → cwd(开发模式)
11
+ * 4. ~/.evolclaw
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.EVOLCLAW_HOME) {
23
+ _root = process.env.EVOLCLAW_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(), '.evolclaw');
30
+ }
31
+ return _root;
32
+ }
33
+ /** socket 路径派生:与 evolclaw resolveInstanceSocketPath 完全一致 */
34
+ function resolveInstanceSocketPath(root) {
35
+ if (isWindows) {
36
+ const hash = crypto.createHash('sha1').update(root).digest('hex').slice(0, 12);
37
+ return `\\\\.\\pipe\\evolclaw-${hash}`;
38
+ }
39
+ return path.join(root, 'data', 'instance', 'evolclaw.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,233 @@
1
+ /**
2
+ * 进程工具(独立版)— 内联自 evolclaw cross-platform + process-introspect + instance-registry。
3
+ *
4
+ * 只包含 ecweb 单实例保护实际用到的函数,无任何 evolclaw 代码依赖。
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 watchWebFile(pid) {
155
+ return path.join(instanceDir(), `watch-web-${pid}.json`);
156
+ }
157
+ export function writeWatchWeb(port) {
158
+ const dir = instanceDir();
159
+ fs.mkdirSync(dir, { recursive: true });
160
+ const startedAt = getProcessStartTime(process.pid) ?? Date.now();
161
+ const record = { pid: process.pid, startedAt, startedAtIso: new Date(startedAt).toISOString(), port };
162
+ const filePath = watchWebFile(process.pid);
163
+ const tmp = filePath + '.tmp';
164
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
165
+ fs.renameSync(tmp, filePath);
166
+ return filePath;
167
+ }
168
+ export function removeWatchWeb(pid) {
169
+ try {
170
+ fs.unlinkSync(watchWebFile(pid ?? process.pid));
171
+ }
172
+ catch { }
173
+ }
174
+ function safeParseJson(filePath) {
175
+ try {
176
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /**
183
+ * 杀掉所有非自己 PID 的存活 watch-web 进程并清理文件。
184
+ * 用启动时间比对防 PID 复用。返回被杀的记录列表。
185
+ */
186
+ export function cleanupWatchWebs() {
187
+ const dir = instanceDir();
188
+ if (!fs.existsSync(dir))
189
+ return [];
190
+ let files;
191
+ try {
192
+ files = fs.readdirSync(dir);
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ const killed = [];
198
+ for (const file of files) {
199
+ if (!file.startsWith('watch-web-') || !file.endsWith('.json'))
200
+ continue;
201
+ const filePath = path.join(dir, file);
202
+ const record = safeParseJson(filePath);
203
+ if (record?.pid && record.pid !== process.pid) {
204
+ if (isProcessRunning(record.pid) && startTimeMatches(record.startedAt, getProcessStartTime(record.pid))) {
205
+ killPid(record.pid);
206
+ killed.push(record);
207
+ }
208
+ try {
209
+ fs.unlinkSync(filePath);
210
+ }
211
+ catch { }
212
+ }
213
+ }
214
+ return killed;
215
+ }
216
+ /**
217
+ * 兜底:按端口找占用进程,确认是 ecweb 进程后 SIGKILL。
218
+ * 用于清理 instance 文件已丢失的孤儿进程(杀不掉的僵尸)。返回被杀的 PID 列表。
219
+ */
220
+ export function cleanupWatchWebByPort(port) {
221
+ const killed = [];
222
+ for (const pid of findPidByPort(port)) {
223
+ if (pid === process.pid || !isProcessRunning(pid))
224
+ continue;
225
+ const cmdline = readCmdline(pid);
226
+ // 只杀确认是 ecweb 的进程,避免误杀别人占的端口
227
+ if (/ecweb|dist[\\/]index\.js/i.test(cmdline)) {
228
+ killPid(pid);
229
+ killed.push(pid);
230
+ }
231
+ }
232
+ return killed;
233
+ }