evolclaw 2.8.2 → 3.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.
- package/README.md +21 -12
- package/dist/agents/claude-runner.js +105 -30
- package/dist/agents/codex-runner.js +15 -7
- package/dist/agents/gemini-runner.js +14 -5
- package/dist/agents/resolve.js +134 -0
- package/dist/agents/templates.js +3 -3
- package/dist/aun/aid/agentmd.js +186 -0
- package/dist/aun/aid/client.js +134 -0
- package/dist/aun/aid/identity.js +131 -0
- package/dist/aun/aid/index.js +3 -0
- package/dist/aun/aid/types.js +1 -0
- package/dist/aun/aid/validation.js +21 -0
- package/dist/aun/msg/group.js +291 -0
- package/dist/aun/msg/index.js +4 -0
- package/dist/aun/msg/p2p.js +144 -0
- package/dist/aun/msg/payload-type.js +27 -0
- package/dist/aun/msg/upload.js +98 -0
- package/dist/aun/outbox.js +138 -0
- package/dist/aun/rpc/caller.js +42 -0
- package/dist/aun/rpc/connection.js +34 -0
- package/dist/aun/rpc/index.js +2 -0
- package/dist/aun/storage/download.js +29 -0
- package/dist/aun/storage/index.js +3 -0
- package/dist/aun/storage/manage.js +10 -0
- package/dist/aun/storage/upload.js +35 -0
- package/dist/channels/aun.js +1064 -279
- package/dist/channels/dingtalk.js +58 -5
- package/dist/channels/feishu.js +266 -30
- package/dist/channels/qqbot.js +67 -12
- package/dist/channels/wechat.js +61 -4
- package/dist/channels/wecom.js +58 -5
- package/dist/cli/agent.js +800 -0
- package/dist/cli/index.js +4253 -0
- package/dist/{utils → cli}/init-channel.js +211 -621
- package/dist/cli/init.js +178 -0
- package/dist/config-store.js +613 -0
- package/dist/core/baseagent-loader.js +48 -0
- package/dist/core/channel-loader.js +162 -11
- package/dist/core/command-handler.js +1090 -838
- package/dist/core/evolagent-registry.js +191 -360
- package/dist/core/evolagent.js +203 -234
- package/dist/core/interaction-router.js +52 -5
- package/dist/core/message/im-renderer.js +480 -0
- package/dist/core/message/items-formatter.js +61 -0
- package/dist/core/message/message-bridge.js +104 -56
- package/dist/core/message/message-log.js +91 -0
- package/dist/core/message/message-processor.js +326 -145
- package/dist/core/message/message-queue.js +5 -5
- package/dist/core/permission.js +21 -8
- package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
- package/dist/core/session/session-fs-store.js +230 -0
- package/dist/core/session/session-manager.js +704 -775
- package/dist/core/session/session-mapper.js +87 -0
- package/dist/core/trigger/manager.js +122 -0
- package/dist/core/trigger/parser.js +128 -0
- package/dist/core/trigger/scheduler.js +224 -0
- package/dist/{templates → data}/prompts.md +34 -1
- package/dist/index.js +437 -273
- package/dist/ipc.js +49 -0
- package/dist/paths.js +82 -9
- package/dist/types.js +8 -2
- package/dist/utils/atomic-write.js +79 -0
- package/dist/utils/channel-helpers.js +46 -0
- package/dist/utils/cross-platform.js +0 -18
- package/dist/utils/instance-registry.js +433 -0
- package/dist/utils/log-writer.js +216 -0
- package/dist/utils/logger.js +24 -77
- package/dist/utils/media-cache.js +23 -0
- package/dist/utils/{upgrade.js → npm-ops.js} +52 -21
- package/dist/utils/process-introspect.js +144 -0
- package/dist/utils/stats.js +192 -0
- package/dist/watch-msg.js +529 -0
- package/evolclaw-install-aun.md +114 -46
- package/kits/aun/meta.md +25 -0
- package/kits/aun/role.md +25 -0
- package/kits/channels/aun.md +25 -0
- package/kits/evolclaw/commands.md +31 -0
- package/kits/evolclaw/identity-tools.md +26 -0
- package/kits/evolclaw/self-summary.md +29 -0
- package/kits/evolclaw/tools.md +25 -0
- package/kits/templates/group.md +20 -0
- package/kits/templates/private.md +9 -0
- package/kits/templates/system-fragments/personal-context.md +3 -0
- package/kits/templates/system-fragments/self-intro.md +5 -0
- package/kits/templates/system-fragments/speaker-intro.md +5 -0
- package/kits/templates/system-fragments/venue-intro.md +5 -0
- package/package.json +7 -5
- package/data/evolclaw.sample.json +0 -60
- package/dist/channels/aun-ops.js +0 -275
- package/dist/cli.js +0 -2178
- package/dist/config.js +0 -576
- package/dist/core/agent-loader.js +0 -39
- package/dist/core/agent-registry.js +0 -450
- package/dist/core/evolagent-schema.js +0 -72
- package/dist/core/message/stream-flusher.js +0 -238
- package/dist/core/message/thought-emitter.js +0 -162
- package/dist/core/reload-hooks.js +0 -87
- package/dist/prompts/templates.js +0 -122
- package/dist/templates/skills.md +0 -66
- package/dist/utils/channel-fingerprint.js +0 -59
- package/dist/utils/error-dict.js +0 -63
- package/dist/utils/format.js +0 -32
- package/dist/utils/init.js +0 -645
- package/dist/utils/migrate-project.js +0 -122
- package/dist/utils/reload-hooks.js +0 -87
- package/dist/utils/stats-collector.js +0 -99
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance registry — manages {HOME}/data/instance/ directory.
|
|
3
|
+
*
|
|
4
|
+
* 真相源:每个进程写一份带 PID 的 record 文件。判定"是否已有实例运行"时,
|
|
5
|
+
* 遍历所有 record,根据 (pid, startedAt) 对每个 record 做存活校验。
|
|
6
|
+
*
|
|
7
|
+
* 不使用 lock 文件——record 文件本身就是登记簿,"互斥"由 post-write 自检
|
|
8
|
+
* (写完 record 立刻扫一遍,比自己早的赢)保证。这样 6 个并发进程同时启动
|
|
9
|
+
* 也不会互相覆盖(PID 不同名字也不同),最终通过 startedAt 比较选出唯一赢家。
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
14
|
+
import { resolvePaths } from '../paths.js';
|
|
15
|
+
import { isProcessRunning, killProcess, isWindows, findProcesses } from './cross-platform.js';
|
|
16
|
+
import { getProcessStartTime, startTimeMatches } from './process-introspect.js';
|
|
17
|
+
// ── Helpers ──
|
|
18
|
+
function instanceDir() {
|
|
19
|
+
return resolvePaths().instanceDir;
|
|
20
|
+
}
|
|
21
|
+
function writeAtomic(filePath, data) {
|
|
22
|
+
const tmp = filePath + '.tmp';
|
|
23
|
+
fs.writeFileSync(tmp, data);
|
|
24
|
+
fs.renameSync(tmp, filePath);
|
|
25
|
+
}
|
|
26
|
+
function safeParseJson(filePath) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isAlive(pid, recordedStartedAt) {
|
|
35
|
+
if (!isProcessRunning(pid))
|
|
36
|
+
return false;
|
|
37
|
+
const actual = getProcessStartTime(pid);
|
|
38
|
+
// 拿不到启动时间时保守认为是我们的进程(宁可误判活着,不误杀)
|
|
39
|
+
if (actual === null)
|
|
40
|
+
return true;
|
|
41
|
+
return startTimeMatches(recordedStartedAt, actual);
|
|
42
|
+
}
|
|
43
|
+
// ── Main record ──
|
|
44
|
+
function mainFileName(pid) {
|
|
45
|
+
return `main-${pid}.json`;
|
|
46
|
+
}
|
|
47
|
+
export function writeMain(launchedBy) {
|
|
48
|
+
const dir = instanceDir();
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
const startedAt = getProcessStartTime(process.pid) ?? Date.now();
|
|
51
|
+
const record = {
|
|
52
|
+
pid: process.pid,
|
|
53
|
+
startedAt,
|
|
54
|
+
startedAtIso: new Date(startedAt).toISOString(),
|
|
55
|
+
launchedBy,
|
|
56
|
+
};
|
|
57
|
+
const filePath = path.join(dir, mainFileName(process.pid));
|
|
58
|
+
writeAtomic(filePath, JSON.stringify(record, null, 2));
|
|
59
|
+
return filePath;
|
|
60
|
+
}
|
|
61
|
+
export function removeMain(pid) {
|
|
62
|
+
const target = pid ?? process.pid;
|
|
63
|
+
const filePath = path.join(instanceDir(), mainFileName(target));
|
|
64
|
+
try {
|
|
65
|
+
fs.unlinkSync(filePath);
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Post-write 自检:写完 main record 后立刻扫一次目录。
|
|
71
|
+
* 如果发现别的活 main,按 (startedAt, pid) 选最早的赢家。
|
|
72
|
+
*
|
|
73
|
+
* @returns 当前进程是否赢家。false 表示应该让出(删自己的 record + exit)。
|
|
74
|
+
*/
|
|
75
|
+
export function isMainWinner() {
|
|
76
|
+
const status = scanInstances();
|
|
77
|
+
const aliveMains = status.mains.filter(m => m.alive);
|
|
78
|
+
if (aliveMains.length <= 1)
|
|
79
|
+
return { winner: true };
|
|
80
|
+
const self = aliveMains.find(m => m.record.pid === process.pid);
|
|
81
|
+
if (!self)
|
|
82
|
+
return { winner: true }; // 自己的记录都没了,让别人去争吧
|
|
83
|
+
// (startedAt, pid) 字典序最小者赢
|
|
84
|
+
const winnerEntry = aliveMains.reduce((best, cur) => {
|
|
85
|
+
if (cur.record.startedAt < best.record.startedAt)
|
|
86
|
+
return cur;
|
|
87
|
+
if (cur.record.startedAt > best.record.startedAt)
|
|
88
|
+
return best;
|
|
89
|
+
return cur.record.pid < best.record.pid ? cur : best;
|
|
90
|
+
});
|
|
91
|
+
if (winnerEntry.record.pid === process.pid)
|
|
92
|
+
return { winner: true };
|
|
93
|
+
return { winner: false, conflictingPid: winnerEntry.record.pid };
|
|
94
|
+
}
|
|
95
|
+
// ── Restart monitor record ──
|
|
96
|
+
function restartMonitorFileName(pid) {
|
|
97
|
+
return `restart-monitor-${pid}.json`;
|
|
98
|
+
}
|
|
99
|
+
export function writeRestartMonitor() {
|
|
100
|
+
const dir = instanceDir();
|
|
101
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
102
|
+
const startedAt = getProcessStartTime(process.pid) ?? Date.now();
|
|
103
|
+
const record = {
|
|
104
|
+
pid: process.pid,
|
|
105
|
+
startedAt,
|
|
106
|
+
startedAtIso: new Date(startedAt).toISOString(),
|
|
107
|
+
launchedBy: 'restart-monitor',
|
|
108
|
+
};
|
|
109
|
+
const filePath = path.join(dir, restartMonitorFileName(process.pid));
|
|
110
|
+
writeAtomic(filePath, JSON.stringify(record, null, 2));
|
|
111
|
+
return filePath;
|
|
112
|
+
}
|
|
113
|
+
export function removeRestartMonitor(pid) {
|
|
114
|
+
const target = pid ?? process.pid;
|
|
115
|
+
const filePath = path.join(instanceDir(), restartMonitorFileName(target));
|
|
116
|
+
try {
|
|
117
|
+
fs.unlinkSync(filePath);
|
|
118
|
+
}
|
|
119
|
+
catch { }
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Post-write 自检:与 isMainWinner 同语义,用于 restart-monitor。
|
|
123
|
+
*/
|
|
124
|
+
export function isRestartMonitorWinner() {
|
|
125
|
+
const status = scanInstances();
|
|
126
|
+
const alive = status.restartMonitors.filter(m => m.alive);
|
|
127
|
+
if (alive.length <= 1)
|
|
128
|
+
return { winner: true };
|
|
129
|
+
const self = alive.find(m => m.record.pid === process.pid);
|
|
130
|
+
if (!self)
|
|
131
|
+
return { winner: true };
|
|
132
|
+
const winnerEntry = alive.reduce((best, cur) => {
|
|
133
|
+
if (cur.record.startedAt < best.record.startedAt)
|
|
134
|
+
return cur;
|
|
135
|
+
if (cur.record.startedAt > best.record.startedAt)
|
|
136
|
+
return best;
|
|
137
|
+
return cur.record.pid < best.record.pid ? cur : best;
|
|
138
|
+
});
|
|
139
|
+
if (winnerEntry.record.pid === process.pid)
|
|
140
|
+
return { winner: true };
|
|
141
|
+
return { winner: false, conflictingPid: winnerEntry.record.pid };
|
|
142
|
+
}
|
|
143
|
+
// ─ AID event log ──
|
|
144
|
+
function aidFileName(pid) {
|
|
145
|
+
return `aid-${pid}.jsonl`;
|
|
146
|
+
}
|
|
147
|
+
export function appendAidEvent(event, pid) {
|
|
148
|
+
const target = pid ?? process.pid;
|
|
149
|
+
const dir = instanceDir();
|
|
150
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
151
|
+
const filePath = path.join(dir, aidFileName(target));
|
|
152
|
+
const line = JSON.stringify(event) + '\n';
|
|
153
|
+
fs.appendFileSync(filePath, line);
|
|
154
|
+
}
|
|
155
|
+
export function removeAidLog(pid) {
|
|
156
|
+
const target = pid ?? process.pid;
|
|
157
|
+
const filePath = path.join(instanceDir(), aidFileName(target));
|
|
158
|
+
try {
|
|
159
|
+
fs.unlinkSync(filePath);
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 读取 aid jsonl 文件尾部,提取每个 AID 的最后活动时间和事件类型。
|
|
165
|
+
*/
|
|
166
|
+
export function readAidLastActivity(pid) {
|
|
167
|
+
const filePath = path.join(instanceDir(), aidFileName(pid));
|
|
168
|
+
const result = new Map();
|
|
169
|
+
if (!fs.existsSync(filePath))
|
|
170
|
+
return result;
|
|
171
|
+
try {
|
|
172
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
173
|
+
const lines = content.trim().split('\n');
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
if (!line)
|
|
176
|
+
continue;
|
|
177
|
+
try {
|
|
178
|
+
const ev = JSON.parse(line);
|
|
179
|
+
if (ev.aid && ev.ts) {
|
|
180
|
+
result.set(ev.aid, { ts: ev.ts, event: ev.event });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch { }
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
// ── Scan & cleanup ──
|
|
190
|
+
/**
|
|
191
|
+
* 扫描 instance/ 目录,返回所有实例记录(含死活状态)。
|
|
192
|
+
*
|
|
193
|
+
* 损坏的 JSON 文件直接删除。aid-*.jsonl 在 scan 时不解析,由调用方按需读取。
|
|
194
|
+
*/
|
|
195
|
+
export function scanInstances() {
|
|
196
|
+
const status = {
|
|
197
|
+
mains: [],
|
|
198
|
+
restartMonitors: [],
|
|
199
|
+
aidLastActivity: new Map(),
|
|
200
|
+
};
|
|
201
|
+
const dir = instanceDir();
|
|
202
|
+
if (!fs.existsSync(dir))
|
|
203
|
+
return status;
|
|
204
|
+
let files;
|
|
205
|
+
try {
|
|
206
|
+
files = fs.readdirSync(dir);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return status;
|
|
210
|
+
}
|
|
211
|
+
for (const file of files) {
|
|
212
|
+
const filePath = path.join(dir, file);
|
|
213
|
+
if (file.startsWith('main-') && file.endsWith('.json')) {
|
|
214
|
+
const record = safeParseJson(filePath);
|
|
215
|
+
if (record && record.pid && record.startedAt) {
|
|
216
|
+
const alive = isAlive(record.pid, record.startedAt);
|
|
217
|
+
status.mains.push({ record, alive });
|
|
218
|
+
if (alive) {
|
|
219
|
+
// 合并所有活 main 的 AID 活动记录(按 ts 取最新)
|
|
220
|
+
for (const [aid, info] of readAidLastActivity(record.pid)) {
|
|
221
|
+
const prev = status.aidLastActivity.get(aid);
|
|
222
|
+
if (!prev || info.ts > prev.ts) {
|
|
223
|
+
status.aidLastActivity.set(aid, info);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
try {
|
|
230
|
+
fs.unlinkSync(filePath);
|
|
231
|
+
}
|
|
232
|
+
catch { }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (file.startsWith('restart-monitor-') && file.endsWith('.json')) {
|
|
236
|
+
const record = safeParseJson(filePath);
|
|
237
|
+
if (record && record.pid && record.startedAt) {
|
|
238
|
+
const alive = isAlive(record.pid, record.startedAt);
|
|
239
|
+
status.restartMonitors.push({ record, alive });
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
try {
|
|
243
|
+
fs.unlinkSync(filePath);
|
|
244
|
+
}
|
|
245
|
+
catch { }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return status;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 清理所有非自己 PID 的残留文件。仍活着的旧实例进程会被 SIGKILL。
|
|
253
|
+
* 返回被杀掉的 PID 列表。
|
|
254
|
+
*/
|
|
255
|
+
export function cleanupInstances() {
|
|
256
|
+
const dir = instanceDir();
|
|
257
|
+
if (!fs.existsSync(dir))
|
|
258
|
+
return [];
|
|
259
|
+
let files;
|
|
260
|
+
try {
|
|
261
|
+
files = fs.readdirSync(dir);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const killed = [];
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
const filePath = path.join(dir, file);
|
|
269
|
+
if (file.startsWith('main-') && file.endsWith('.json')) {
|
|
270
|
+
const record = safeParseJson(filePath);
|
|
271
|
+
if (record?.pid) {
|
|
272
|
+
if (record.pid === process.pid)
|
|
273
|
+
continue;
|
|
274
|
+
if (isProcessRunning(record.pid)) {
|
|
275
|
+
const actual = getProcessStartTime(record.pid);
|
|
276
|
+
if (startTimeMatches(record.startedAt, actual)) {
|
|
277
|
+
killPid(record.pid);
|
|
278
|
+
killed.push(record.pid);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
fs.unlinkSync(filePath);
|
|
284
|
+
}
|
|
285
|
+
catch { }
|
|
286
|
+
}
|
|
287
|
+
else if (file.startsWith('restart-monitor-') && file.endsWith('.json')) {
|
|
288
|
+
const record = safeParseJson(filePath);
|
|
289
|
+
if (record?.pid) {
|
|
290
|
+
if (record.pid === process.pid)
|
|
291
|
+
continue;
|
|
292
|
+
if (isProcessRunning(record.pid)) {
|
|
293
|
+
const actual = getProcessStartTime(record.pid);
|
|
294
|
+
if (startTimeMatches(record.startedAt, actual)) {
|
|
295
|
+
killPid(record.pid);
|
|
296
|
+
killed.push(record.pid);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
fs.unlinkSync(filePath);
|
|
302
|
+
}
|
|
303
|
+
catch { }
|
|
304
|
+
}
|
|
305
|
+
else if (file.startsWith('aid-') && file.endsWith('.jsonl')) {
|
|
306
|
+
// aid-<pid>.jsonl:自己的不动,其他的清掉
|
|
307
|
+
const m = file.match(/^aid-(\d+)\.jsonl$/);
|
|
308
|
+
if (m && parseInt(m[1], 10) === process.pid)
|
|
309
|
+
continue;
|
|
310
|
+
try {
|
|
311
|
+
fs.unlinkSync(filePath);
|
|
312
|
+
}
|
|
313
|
+
catch { }
|
|
314
|
+
}
|
|
315
|
+
else if (file.endsWith('.tmp')) {
|
|
316
|
+
try {
|
|
317
|
+
fs.unlinkSync(filePath);
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return killed;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* 删除当前进程拥有的所有 instance 文件(正常关闭时调用)。
|
|
326
|
+
*/
|
|
327
|
+
export function removeAll(pid) {
|
|
328
|
+
const target = pid ?? process.pid;
|
|
329
|
+
removeMain(target);
|
|
330
|
+
removeAidLog(target);
|
|
331
|
+
}
|
|
332
|
+
// ── Internal ──
|
|
333
|
+
function killPid(pid) {
|
|
334
|
+
killProcess(pid, true);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* 扫所有 node 进程中跑 dist/index.js 的 PID,减去当前 HOME 已登记的 main PID。
|
|
338
|
+
*
|
|
339
|
+
* 用途:检测跨 HOME 残留的 evolclaw 主进程(例如测试套件 spawn 后未清理、
|
|
340
|
+
* 旧版本 pidfile 模式遗留等),由 cmdStart/cmdRestart 在启动前提示用户。
|
|
341
|
+
*
|
|
342
|
+
* Linux 下额外读取 /proc/<pid>/environ 提取 EVOLCLAW_HOME 用于展示。
|
|
343
|
+
* Windows / macOS 取不到环境变量时 evolclawHome 为 null。
|
|
344
|
+
*
|
|
345
|
+
* 不会主动 kill——清理由调用方决定(cmdRestart --kill-orphans 才执行)。
|
|
346
|
+
*/
|
|
347
|
+
export function findOrphanProcesses() {
|
|
348
|
+
// 1. 已登记 PID(自己 HOME 下的 main + 自己进程)
|
|
349
|
+
const known = new Set([process.pid]);
|
|
350
|
+
const status = scanInstances();
|
|
351
|
+
for (const m of status.mains)
|
|
352
|
+
known.add(m.record.pid);
|
|
353
|
+
for (const m of status.restartMonitors)
|
|
354
|
+
known.add(m.record.pid);
|
|
355
|
+
// 2. 系统中所有跑 dist/index.js 的 node 进程
|
|
356
|
+
const candidates = findProcesses('node.*dist/index.js');
|
|
357
|
+
const orphans = [];
|
|
358
|
+
for (const pid of candidates) {
|
|
359
|
+
if (known.has(pid))
|
|
360
|
+
continue;
|
|
361
|
+
if (!isProcessRunning(pid))
|
|
362
|
+
continue;
|
|
363
|
+
const cmdline = readCmdline(pid);
|
|
364
|
+
// 二次验证:确实是 evolclaw 的 dist/index.js
|
|
365
|
+
if (!/dist[\\/]index\.js/.test(cmdline))
|
|
366
|
+
continue;
|
|
367
|
+
orphans.push({
|
|
368
|
+
pid,
|
|
369
|
+
evolclawHome: readEvolclawHome(pid),
|
|
370
|
+
cmdline,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return orphans;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* SIGKILL 所有传入的孤儿 PID。返回成功杀掉的 PID 列表(即调用后已停的)。
|
|
377
|
+
*/
|
|
378
|
+
export function killOrphans(orphans) {
|
|
379
|
+
const killed = [];
|
|
380
|
+
for (const o of orphans) {
|
|
381
|
+
try {
|
|
382
|
+
killProcess(o.pid, true);
|
|
383
|
+
killed.push(o.pid);
|
|
384
|
+
}
|
|
385
|
+
catch { }
|
|
386
|
+
}
|
|
387
|
+
return killed;
|
|
388
|
+
}
|
|
389
|
+
function readCmdline(pid) {
|
|
390
|
+
if (isWindows) {
|
|
391
|
+
try {
|
|
392
|
+
const out = execFileSync('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine', '/value'], { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
393
|
+
const m = out.match(/CommandLine=([^\r\n]+)/);
|
|
394
|
+
return m ? m[1].trim() : '';
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return '';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8').replace(/\0/g, ' ').trim();
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// macOS / 权限不足
|
|
405
|
+
try {
|
|
406
|
+
return execFileSync('ps', ['-p', String(pid), '-o', 'args='], {
|
|
407
|
+
encoding: 'utf-8',
|
|
408
|
+
timeout: 3000,
|
|
409
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
410
|
+
}).trim();
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return '';
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function readEvolclawHome(pid) {
|
|
418
|
+
// Linux: /proc/<pid>/environ
|
|
419
|
+
if (!isWindows && process.platform !== 'darwin') {
|
|
420
|
+
try {
|
|
421
|
+
const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf-8');
|
|
422
|
+
for (const entry of env.split('\0')) {
|
|
423
|
+
if (entry.startsWith('EVOLCLAW_HOME='))
|
|
424
|
+
return entry.slice('EVOLCLAW_HOME='.length);
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogWriter — 统一的日志文件管理。
|
|
3
|
+
*
|
|
4
|
+
* 一个实例 = 一份"活跃日志 + 历史归档"。所有 EvolClaw 内部需要写日志的地方
|
|
5
|
+
* 都应通过它,避免每个模块各自实现切片/清理。
|
|
6
|
+
*
|
|
7
|
+
* 切片规则:
|
|
8
|
+
* - 'hourly' / 'daily':进入新时段时把当前活跃文件 rename 成
|
|
9
|
+
* `<base>-YYYYMMDD-HH.log` / `<base>-YYYYMMDD.log`,活跃文件名固定为
|
|
10
|
+
* `<base>.log`。这样 `tail -F <base>.log` 能跨切片续接(必须 -F,跟 path)。
|
|
11
|
+
* - 'size':活跃文件超过 maxSize 时 rename 成 `<base>.log.<ISO-15>`。
|
|
12
|
+
* - 'none':不切。
|
|
13
|
+
*
|
|
14
|
+
* 清理:进入新时段或启动时按 retention 删除过旧的归档文件。
|
|
15
|
+
*
|
|
16
|
+
* 设计取舍:LogWriter 不持有 stream,每次 write 时按需 open-append-close。
|
|
17
|
+
* 优点:跨切片简单(rename 后下次 write 自动开新文件);
|
|
18
|
+
* 代价:每行一次 open/close 在高频日志下不便宜——但 EvolClaw 的日志量
|
|
19
|
+
* 远低于 OS 缓存带宽,实测无瓶颈。
|
|
20
|
+
* 后续若有热点(如 messages.log)可改成持流模式 + 切片时 close。
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
25
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
26
|
+
export class LogWriter {
|
|
27
|
+
baseName;
|
|
28
|
+
logDir;
|
|
29
|
+
rotation;
|
|
30
|
+
maxSize;
|
|
31
|
+
retentionMs;
|
|
32
|
+
/** 当前活跃时段 tag(hourly: YYYYMMDD-HH,daily: YYYYMMDD),用于检测切片 */
|
|
33
|
+
currentTag;
|
|
34
|
+
/** 周期清理 timer(unref 不阻塞退出) */
|
|
35
|
+
cleanupTimer = null;
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
this.baseName = opts.baseName;
|
|
38
|
+
this.logDir = opts.logDir;
|
|
39
|
+
this.rotation = opts.rotation;
|
|
40
|
+
this.maxSize = opts.maxSize ?? 10 * 1024 * 1024;
|
|
41
|
+
this.retentionMs =
|
|
42
|
+
(opts.retention.hours ?? 0) * HOUR_MS +
|
|
43
|
+
(opts.retention.days ?? 0) * DAY_MS;
|
|
44
|
+
fs.mkdirSync(this.logDir, { recursive: true });
|
|
45
|
+
this.currentTag = this.tagOfNow();
|
|
46
|
+
// 启动时如果 active 文件已存在但归属上一时段,先归档掉
|
|
47
|
+
this.rotateIfNeeded(/* sync */ true);
|
|
48
|
+
this.cleanupOldArchives();
|
|
49
|
+
// 周期触发切片+清理。两段式定时器:先用 setTimeout 对齐到下一个整点
|
|
50
|
+
//(hourly: 下个整点;daily: 明日 00:00),然后切到每小时一次的 setInterval。
|
|
51
|
+
// 这样空闲期也能按时切片——而不是等到下一次有人 write 才补切。
|
|
52
|
+
if (this.rotation === 'hourly' || this.rotation === 'daily') {
|
|
53
|
+
const tick = () => {
|
|
54
|
+
this.rotateIfNeeded(false);
|
|
55
|
+
this.cleanupOldArchives();
|
|
56
|
+
};
|
|
57
|
+
const initialDelay = this.msUntilNextBoundary();
|
|
58
|
+
const initialTimer = setTimeout(() => {
|
|
59
|
+
tick();
|
|
60
|
+
this.cleanupTimer = setInterval(tick, HOUR_MS);
|
|
61
|
+
this.cleanupTimer.unref?.();
|
|
62
|
+
}, initialDelay);
|
|
63
|
+
initialTimer.unref?.();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** 写一行(自动加 \n) */
|
|
67
|
+
write(line) {
|
|
68
|
+
this.rotateIfNeeded(false);
|
|
69
|
+
try {
|
|
70
|
+
fs.appendFileSync(this.activePath(), line + '\n');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// 写入失败不抛——日志不能影响业务
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** 关闭(停掉清理 timer)。一般不需要调用,进程退出 timer 自动失效 */
|
|
77
|
+
close() {
|
|
78
|
+
if (this.cleanupTimer) {
|
|
79
|
+
clearInterval(this.cleanupTimer);
|
|
80
|
+
this.cleanupTimer = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** 当前活跃文件绝对路径 */
|
|
84
|
+
activePath() {
|
|
85
|
+
return path.join(this.logDir, `${this.baseName}.log`);
|
|
86
|
+
}
|
|
87
|
+
// ── Internal ──
|
|
88
|
+
/** 距离下一个整时段边界的毫秒数(hourly: 下个整点;daily: 明日 00:00) */
|
|
89
|
+
msUntilNextBoundary() {
|
|
90
|
+
const now = new Date();
|
|
91
|
+
const next = new Date(now);
|
|
92
|
+
if (this.rotation === 'daily') {
|
|
93
|
+
next.setDate(next.getDate() + 1);
|
|
94
|
+
next.setHours(0, 0, 0, 0);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
next.setHours(next.getHours() + 1, 0, 0, 0);
|
|
98
|
+
}
|
|
99
|
+
// 给 1 秒余量,确保 Date 时钟跨越整点后再触发,避免边界误差
|
|
100
|
+
return Math.max(1000, next.getTime() - now.getTime() + 1000);
|
|
101
|
+
}
|
|
102
|
+
tagOfNow() {
|
|
103
|
+
const d = new Date();
|
|
104
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
105
|
+
const day = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
|
106
|
+
if (this.rotation === 'daily')
|
|
107
|
+
return day;
|
|
108
|
+
return `${day}-${pad(d.getHours())}`;
|
|
109
|
+
}
|
|
110
|
+
archivePath(tag) {
|
|
111
|
+
return path.join(this.logDir, `${this.baseName}-${tag}.log`);
|
|
112
|
+
}
|
|
113
|
+
rotateIfNeeded(initial) {
|
|
114
|
+
const active = this.activePath();
|
|
115
|
+
if (this.rotation === 'none')
|
|
116
|
+
return;
|
|
117
|
+
if (this.rotation === 'size') {
|
|
118
|
+
let stat;
|
|
119
|
+
try {
|
|
120
|
+
stat = fs.statSync(active);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (stat.size <= this.maxSize)
|
|
126
|
+
return;
|
|
127
|
+
const tag = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
128
|
+
try {
|
|
129
|
+
fs.renameSync(active, `${active}.${tag}`);
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
this.cleanupOldArchives();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// hourly / daily
|
|
136
|
+
const nowTag = this.tagOfNow();
|
|
137
|
+
// 文件不存在 → 直接更新 currentTag,下次 write 会创建
|
|
138
|
+
let stat = null;
|
|
139
|
+
try {
|
|
140
|
+
stat = fs.statSync(active);
|
|
141
|
+
}
|
|
142
|
+
catch { /* not exist */ }
|
|
143
|
+
if (!stat) {
|
|
144
|
+
this.currentTag = nowTag;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// 启动时:用 mtime 判断 active 文件归属哪个 tag
|
|
148
|
+
const ownerTag = initial ? this.tagOfTime(stat.mtimeMs) : this.currentTag;
|
|
149
|
+
if (ownerTag === nowTag) {
|
|
150
|
+
this.currentTag = nowTag;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// 切片:把 active 重命名为 archive
|
|
154
|
+
const archive = this.archivePath(ownerTag);
|
|
155
|
+
try {
|
|
156
|
+
// 如果 archive 已存在(重名),先合并:append 老 active 内容到 archive 末尾
|
|
157
|
+
if (fs.existsSync(archive)) {
|
|
158
|
+
try {
|
|
159
|
+
const content = fs.readFileSync(active);
|
|
160
|
+
fs.appendFileSync(archive, content);
|
|
161
|
+
fs.unlinkSync(active);
|
|
162
|
+
}
|
|
163
|
+
catch { }
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
fs.renameSync(active, archive);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch { }
|
|
170
|
+
this.currentTag = nowTag;
|
|
171
|
+
}
|
|
172
|
+
tagOfTime(ms) {
|
|
173
|
+
const d = new Date(ms);
|
|
174
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
175
|
+
const day = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
|
176
|
+
if (this.rotation === 'daily')
|
|
177
|
+
return day;
|
|
178
|
+
return `${day}-${pad(d.getHours())}`;
|
|
179
|
+
}
|
|
180
|
+
cleanupOldArchives() {
|
|
181
|
+
LogWriter.cleanupArchivesIn(this.logDir, this.retentionMs);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 全局清理:扫整个 logDir,按 retention 删除所有匹配 LogWriter 归档命名约定的文件。
|
|
185
|
+
*
|
|
186
|
+
* 命名约定:`<baseName>-YYYYMMDD-HH.log`(hourly)或 `<baseName>-YYYYMMDD.log`(daily),
|
|
187
|
+
* 其中 baseName 由字母/数字/连字符组成。
|
|
188
|
+
*
|
|
189
|
+
* 这条规则跨 baseName 统一——只要文件按这个 pattern 命名就认为受 LogWriter 体系管辖。
|
|
190
|
+
* 这样 conditional 启用的 LogWriter(如 aun trace 关闭时)不会留下永久无人清的归档:
|
|
191
|
+
* 任意 LogWriter 实例化都会顺便清掉它们。
|
|
192
|
+
*/
|
|
193
|
+
static cleanupArchivesIn(logDir, retentionMs) {
|
|
194
|
+
if (retentionMs <= 0)
|
|
195
|
+
return;
|
|
196
|
+
const cutoff = Date.now() - retentionMs;
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = fs.readdirSync(logDir);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const generalPattern = /^[A-Za-z0-9][A-Za-z0-9_-]*-\d{8}(?:-\d{2})?\.log$/;
|
|
205
|
+
for (const name of entries) {
|
|
206
|
+
if (!generalPattern.test(name))
|
|
207
|
+
continue;
|
|
208
|
+
const full = path.join(logDir, name);
|
|
209
|
+
try {
|
|
210
|
+
if (fs.statSync(full).mtimeMs < cutoff)
|
|
211
|
+
fs.unlinkSync(full);
|
|
212
|
+
}
|
|
213
|
+
catch { }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|