pi-proactive 0.2.0-beta.4 → 0.2.0-beta.6
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/CHANGELOG.md +77 -0
- package/README.md +32 -14
- package/build/extensions/proactive.js +2 -1
- package/daemon/run-daemon.mjs +268 -0
- package/package.json +12 -3
- package/skills/pi-proactive/SKILL.md +67 -9
- package/tools/daemon-common.mjs +289 -0
- package/tools/daemon-install.mjs +488 -0
- package/tools/daemon-uninstall.mjs +302 -0
- package/tools/enumerate-residue.mjs +487 -0
- package/tools/verify-windows.mjs +358 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pi-proactive 常驻助理:**跨平台一键安装**(用户只跑这一条命令 → 看到「已常驻」)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么有 .mjs 版:旧入口 `tools/daemon-install.sh` 是 **bash** → **Windows 用户装不了常驻助理**
|
|
6
|
+
* (见 `docs/project/windows-porting.md` §三①)。本文件是**同一条安装动作**的跨平台实现:
|
|
7
|
+
*
|
|
8
|
+
* · macOS → launchd(**复用 `daemon/launchd.plist` 模板**,模板本身不改)
|
|
9
|
+
* · Linux → systemd --user(**复用 `daemon/pi-proactive-daemon.service` 模板**)
|
|
10
|
+
* · Windows → **计划任务**(`schtasks /Create /SC MINUTE /MO n ... /F`)
|
|
11
|
+
*
|
|
12
|
+
* 三条平台硬约束(Windows 尤其):
|
|
13
|
+
* ① **短路径**:端点(unix socket)路径上限 100B → agent 根与工作目录必须短;
|
|
14
|
+
* ② **引号化**:Windows 路径常含空格(`C:\Program Files\nodejs\node.exe`)→ 逐个加引号;
|
|
15
|
+
* ③ **幂等**:重复安装**更新**同名任务,**不产生第二份任务/进程**(`/Query` 先探测 + `/F` 覆盖)。
|
|
16
|
+
*
|
|
17
|
+
* 验证三证据(**缺一即失败,不谎报成功**):
|
|
18
|
+
* ① 进程在 —— 心跳文件里的 pid 活着
|
|
19
|
+
* ② 心跳在更新 —— 两次读取的 ts 前进
|
|
20
|
+
* ③ 宿主已加载 —— launchctl print / systemctl is-active / schtasks /Query
|
|
21
|
+
*
|
|
22
|
+
* 成本硬约束:装上**不等于花钱** —— 配置默认 `enabled=false` + 干跑。
|
|
23
|
+
*
|
|
24
|
+
* 用法:
|
|
25
|
+
* node pi-proactive/tools/daemon-install.mjs
|
|
26
|
+
* node pi-proactive/tools/daemon-install.mjs --agent-dir <dir> # 指定 agent 根目录
|
|
27
|
+
* node pi-proactive/tools/daemon-install.mjs --agent-id <id> # 指定稳定身份
|
|
28
|
+
* node pi-proactive/tools/daemon-install.mjs --label <label> # launchd 单元标签
|
|
29
|
+
* node pi-proactive/tools/daemon-install.mjs --service-name <n> # systemd 单元名
|
|
30
|
+
* node pi-proactive/tools/daemon-install.mjs --task-name <n> # Windows 计划任务名
|
|
31
|
+
* node pi-proactive/tools/daemon-install.mjs --no-start # 只写配置,不启动
|
|
32
|
+
* node pi-proactive/tools/daemon-install.mjs --print-plan # 只打印计划(JSON),不落任何副作用
|
|
33
|
+
*
|
|
34
|
+
* 退出码:0=已常驻(验证通过);1=失败;2=环境不支持 / 用法错误。
|
|
35
|
+
*/
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import {
|
|
40
|
+
MAX_SOCKET_PATH_BYTES,
|
|
41
|
+
buildWindowsTaskCommand,
|
|
42
|
+
chooseShortAgentDir,
|
|
43
|
+
defaultAgentDir,
|
|
44
|
+
defaultAgentId,
|
|
45
|
+
detectPlatform,
|
|
46
|
+
isAlive,
|
|
47
|
+
pkgRootFrom,
|
|
48
|
+
quoteSystemd,
|
|
49
|
+
readJsonSafe,
|
|
50
|
+
renderLaunchdPlist,
|
|
51
|
+
renderSystemdUnit,
|
|
52
|
+
resolveDaemonEntry,
|
|
53
|
+
resolveSocketPath,
|
|
54
|
+
run,
|
|
55
|
+
} from "./daemon-common.mjs";
|
|
56
|
+
|
|
57
|
+
const PKG_ROOT = pkgRootFrom(import.meta.url);
|
|
58
|
+
|
|
59
|
+
const USAGE = `pi-proactive 常驻助理 · 跨平台安装器(macOS / Linux / Windows)
|
|
60
|
+
|
|
61
|
+
node pi-proactive/tools/daemon-install.mjs [选项]
|
|
62
|
+
|
|
63
|
+
--agent-dir <dir> agent 根目录(默认 ~/.pi/agent)
|
|
64
|
+
--agent-id <id> 稳定身份(默认 employee#<主机名>#pi-proactive-daemon)
|
|
65
|
+
--label <label> launchd 单元标签(默认 com.pi-proactive.daemon)
|
|
66
|
+
--service-name <n> systemd 单元名(默认 pi-proactive-daemon.service)
|
|
67
|
+
--task-name <n> Windows 计划任务名(默认 PiProactiveDaemon)
|
|
68
|
+
--minutes <n> Windows 计划任务周期(分钟,默认 1)
|
|
69
|
+
--no-start 只写配置,不启动
|
|
70
|
+
--print-plan 只打印安装计划(JSON),不产生任何副作用
|
|
71
|
+
-h, --help 本帮助
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
/** 解析命令行。 */
|
|
75
|
+
function parseArgs(argv) {
|
|
76
|
+
const o = {
|
|
77
|
+
agentDir: defaultAgentDir(),
|
|
78
|
+
agentId: "",
|
|
79
|
+
label: process.env.PI_PROACTIVE_DAEMON_LABEL || "com.pi-proactive.daemon",
|
|
80
|
+
serviceName: process.env.PI_PROACTIVE_DAEMON_SERVICE || "pi-proactive-daemon.service",
|
|
81
|
+
taskName: process.env.PI_PROACTIVE_DAEMON_TASK || "PiProactiveDaemon",
|
|
82
|
+
minutes: 1,
|
|
83
|
+
noStart: false,
|
|
84
|
+
printPlan: false,
|
|
85
|
+
platform: "",
|
|
86
|
+
};
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const a = argv[i];
|
|
89
|
+
const next = () => {
|
|
90
|
+
const v = argv[++i];
|
|
91
|
+
if (v === undefined) {
|
|
92
|
+
console.error(`参数 ${a} 缺值`);
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
return v;
|
|
96
|
+
};
|
|
97
|
+
if (a === "--agent-dir") o.agentDir = path.resolve(next());
|
|
98
|
+
else if (a === "--agent-id") o.agentId = next();
|
|
99
|
+
else if (a === "--label") o.label = next();
|
|
100
|
+
else if (a === "--service-name") o.serviceName = next();
|
|
101
|
+
else if (a === "--task-name") o.taskName = next();
|
|
102
|
+
else if (a === "--minutes") o.minutes = Math.max(1, Number(next()) || 1);
|
|
103
|
+
else if (a === "--no-start") o.noStart = true;
|
|
104
|
+
else if (a === "--print-plan") o.printPlan = true;
|
|
105
|
+
else if (a === "--platform") o.platform = next();
|
|
106
|
+
else if (a === "-h" || a === "--help") {
|
|
107
|
+
console.log(USAGE);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
} else {
|
|
110
|
+
console.error(`未知参数:${a}`);
|
|
111
|
+
process.exit(2);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return o;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 值班配置(**不存在才写**;不覆盖用户已有配置)。 */
|
|
118
|
+
function daemonConfigJson(agentId) {
|
|
119
|
+
return `${JSON.stringify(
|
|
120
|
+
{
|
|
121
|
+
version: "v1",
|
|
122
|
+
enabled: false, // 装上不等于花钱
|
|
123
|
+
agentId,
|
|
124
|
+
dryRun: true,
|
|
125
|
+
budget: {
|
|
126
|
+
maxActionsPerHour: 6,
|
|
127
|
+
maxActionsPerDay: 40,
|
|
128
|
+
maxTokenUnitsPerDay: 200000,
|
|
129
|
+
maxLimitedActionsPerDay: 20,
|
|
130
|
+
degradeToObserveWhenExhausted: true,
|
|
131
|
+
},
|
|
132
|
+
permissions: { defaultTier: "limited" },
|
|
133
|
+
tickIntervalMs: 30000,
|
|
134
|
+
handoverHour: 8,
|
|
135
|
+
staleSocketMs: 3600000,
|
|
136
|
+
staleExecutionMs: 3600000,
|
|
137
|
+
contact: {
|
|
138
|
+
enabled: true,
|
|
139
|
+
target: "auto",
|
|
140
|
+
minIntervalMs: 3600000,
|
|
141
|
+
maxPerDay: 8,
|
|
142
|
+
deviceNotify: "fallback",
|
|
143
|
+
},
|
|
144
|
+
host: { hostKind: "auto" },
|
|
145
|
+
},
|
|
146
|
+
null,
|
|
147
|
+
2,
|
|
148
|
+
)}\n`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 生成安装计划(**纯计算**:不落任何副作用 → `--print-plan` 与真实安装同源)。
|
|
153
|
+
*/
|
|
154
|
+
function buildPlan(opts) {
|
|
155
|
+
const platform = detectPlatform(opts.platform);
|
|
156
|
+
const node = process.execPath;
|
|
157
|
+
const runner = path.join(PKG_ROOT, "daemon", "run-daemon.mjs");
|
|
158
|
+
const daemon = resolveDaemonEntry(PKG_ROOT);
|
|
159
|
+
if (!daemon) {
|
|
160
|
+
throw new Error("找不到常驻入口(试过 src/daemon.ts、build/src/daemon.js)");
|
|
161
|
+
}
|
|
162
|
+
const agentId = opts.agentId || defaultAgentId();
|
|
163
|
+
|
|
164
|
+
// ---- agent 根:Windows 必须**短**(端点路径 < 100B)----
|
|
165
|
+
let agentDir = opts.agentDir;
|
|
166
|
+
let socketInfo = resolveSocketPath(PKG_ROOT, agentId, agentDir);
|
|
167
|
+
if (platform === "win32") {
|
|
168
|
+
// 候选根(按优先级):用户指定 → 可覆盖的回退根 → %LOCALAPPDATA%\pp → C:\pp-agent
|
|
169
|
+
// (Windows 上 agent 根必须**短**:端点路径上限 100B;C:\ 根最短,但可能没有写权限)
|
|
170
|
+
const fallback =
|
|
171
|
+
process.env.PI_PROACTIVE_WINDOWS_FALLBACK_ROOT ||
|
|
172
|
+
path.join(os.homedir(), "AppData", "Local", "pp");
|
|
173
|
+
const candidates = [opts.agentDir, fallback, "C:\\pp-agent"];
|
|
174
|
+
const picked = chooseShortAgentDir(candidates, (d) => {
|
|
175
|
+
const s = resolveSocketPath(PKG_ROOT, agentId, d);
|
|
176
|
+
return s ? s.bytes : null;
|
|
177
|
+
});
|
|
178
|
+
if (!picked) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`找不到端点路径 < ${MAX_SOCKET_PATH_BYTES}B 的 agent 根(试过 ${candidates.join("、")})` +
|
|
181
|
+
"→ 请用 --agent-dir 指定一个更短的目录",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
agentDir = picked.dir;
|
|
185
|
+
socketInfo = resolveSocketPath(PKG_ROOT, agentId, agentDir);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const logDir = path.join(agentDir, "proactive", "daemon");
|
|
189
|
+
const daemonDir = path.join(agentDir, "proactive", "daemon");
|
|
190
|
+
const configFile = path.join(agentDir, "proactive", "daemon.json");
|
|
191
|
+
const hostStateFile = path.join(daemonDir, "host.json");
|
|
192
|
+
// Windows 的工作目录用**短** agent 根;unix 侧用包根(与旧行为一致)。
|
|
193
|
+
const workDir = platform === "win32" ? agentDir : PKG_ROOT;
|
|
194
|
+
|
|
195
|
+
const runnerArgs = [
|
|
196
|
+
"--agent-dir",
|
|
197
|
+
agentDir,
|
|
198
|
+
"--agent-id",
|
|
199
|
+
agentId,
|
|
200
|
+
"--entry",
|
|
201
|
+
daemon.entry,
|
|
202
|
+
"--log-dir",
|
|
203
|
+
logDir,
|
|
204
|
+
"--node",
|
|
205
|
+
node,
|
|
206
|
+
"--work-dir",
|
|
207
|
+
workDir,
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
const hostKind = platform === "win32" ? "schtasks" : platform === "darwin" ? "launchd" : "systemd";
|
|
211
|
+
const steps = [
|
|
212
|
+
{ kind: "mkdir", path: agentDir },
|
|
213
|
+
{ kind: "mkdir", path: daemonDir },
|
|
214
|
+
{ kind: "writeIfAbsent", path: configFile, content: daemonConfigJson(agentId) },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
let unitPath = "";
|
|
218
|
+
if (platform === "darwin") {
|
|
219
|
+
unitPath = path.join(os.homedir(), "Library", "LaunchAgents", `${opts.label}.plist`);
|
|
220
|
+
const template = fs.readFileSync(path.join(PKG_ROOT, "daemon", "launchd.plist"), "utf8");
|
|
221
|
+
const content = renderLaunchdPlist(template, {
|
|
222
|
+
label: opts.label,
|
|
223
|
+
pkgRoot: PKG_ROOT,
|
|
224
|
+
agentId,
|
|
225
|
+
logDir,
|
|
226
|
+
runner,
|
|
227
|
+
programArgs: [node, runner, ...runnerArgs],
|
|
228
|
+
});
|
|
229
|
+
steps.push({ kind: "write", path: unitPath, content });
|
|
230
|
+
if (!opts.noStart) {
|
|
231
|
+
steps.push({
|
|
232
|
+
kind: "exec",
|
|
233
|
+
cmd: "launchctl",
|
|
234
|
+
args: ["bootout", `gui/${process.getuid?.() ?? 0}/${opts.label}`],
|
|
235
|
+
ignoreFail: true,
|
|
236
|
+
});
|
|
237
|
+
// bootout 是**异步**的:不等它真正从域里消失就 bootstrap,会出现
|
|
238
|
+
// 「bootstrap 返回 0,但随后被挂起的 bootout 摘掉」→ 单元根本没加载(重启即失效)。
|
|
239
|
+
steps.push({
|
|
240
|
+
kind: "waitGone",
|
|
241
|
+
cmd: "launchctl",
|
|
242
|
+
args: ["print", `gui/${process.getuid?.() ?? 0}/${opts.label}`],
|
|
243
|
+
timeoutMs: 10_000,
|
|
244
|
+
});
|
|
245
|
+
steps.push({
|
|
246
|
+
kind: "execFallback",
|
|
247
|
+
attempts: [
|
|
248
|
+
{ cmd: "launchctl", args: ["bootstrap", `gui/${process.getuid?.() ?? 0}`, unitPath] },
|
|
249
|
+
{ cmd: "launchctl", args: ["load", "-w", unitPath] },
|
|
250
|
+
{ cmd: "launchctl", args: ["bootstrap", `gui/${process.getuid?.() ?? 0}`, unitPath] },
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
} else if (platform === "linux") {
|
|
255
|
+
unitPath = path.join(os.homedir(), ".config", "systemd", "user", opts.serviceName);
|
|
256
|
+
const template = fs.readFileSync(path.join(PKG_ROOT, "daemon", "pi-proactive-daemon.service"), "utf8");
|
|
257
|
+
const execStart = [node, runner, ...runnerArgs].map(quoteSystemd).join(" ");
|
|
258
|
+
const content = renderSystemdUnit(template, {
|
|
259
|
+
pkgRoot: PKG_ROOT,
|
|
260
|
+
agentId,
|
|
261
|
+
workDir,
|
|
262
|
+
execStart,
|
|
263
|
+
});
|
|
264
|
+
steps.push({ kind: "write", path: unitPath, content });
|
|
265
|
+
if (!opts.noStart) {
|
|
266
|
+
steps.push({ kind: "exec", cmd: "systemctl", args: ["--user", "daemon-reload"] });
|
|
267
|
+
steps.push({ kind: "exec", cmd: "systemctl", args: ["--user", "enable", "--now", opts.serviceName] });
|
|
268
|
+
}
|
|
269
|
+
} else if (platform === "win32") {
|
|
270
|
+
unitPath = `计划任务:${opts.taskName}`;
|
|
271
|
+
const tr = buildWindowsTaskCommand({ node, runner, extraArgs: runnerArgs });
|
|
272
|
+
// 幂等:先探测同名任务是否已存在 → 存在就是**更新**(/F 覆盖),绝不建第二个。
|
|
273
|
+
steps.push({ kind: "exec", cmd: "schtasks", args: ["/Query", "/TN", opts.taskName], ignoreFail: true, probe: "exists" });
|
|
274
|
+
steps.push({
|
|
275
|
+
kind: "exec",
|
|
276
|
+
cmd: "schtasks",
|
|
277
|
+
args: [
|
|
278
|
+
"/Create",
|
|
279
|
+
"/TN",
|
|
280
|
+
opts.taskName,
|
|
281
|
+
"/TR",
|
|
282
|
+
tr,
|
|
283
|
+
"/SC",
|
|
284
|
+
"MINUTE",
|
|
285
|
+
"/MO",
|
|
286
|
+
String(opts.minutes),
|
|
287
|
+
"/F",
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
if (!opts.noStart) {
|
|
291
|
+
steps.push({ kind: "exec", cmd: "schtasks", args: ["/Run", "/TN", opts.taskName], ignoreFail: true });
|
|
292
|
+
}
|
|
293
|
+
steps.push({ kind: "exec", cmd: "schtasks", args: ["/Query", "/TN", opts.taskName] });
|
|
294
|
+
} else {
|
|
295
|
+
throw new Error(`不支持的操作系统:${process.platform}(本安装器支持 macOS / Linux / Windows)`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
steps.push({
|
|
299
|
+
kind: "write",
|
|
300
|
+
path: hostStateFile,
|
|
301
|
+
content: `${JSON.stringify(
|
|
302
|
+
{
|
|
303
|
+
hostKind,
|
|
304
|
+
label: opts.taskName,
|
|
305
|
+
workspaceId: "",
|
|
306
|
+
paneId: "",
|
|
307
|
+
unitPath,
|
|
308
|
+
agentId,
|
|
309
|
+
installedAt: Date.now(),
|
|
310
|
+
},
|
|
311
|
+
null,
|
|
312
|
+
2,
|
|
313
|
+
)}\n`,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
platform,
|
|
318
|
+
agentDir,
|
|
319
|
+
workDir,
|
|
320
|
+
pkgRoot: PKG_ROOT,
|
|
321
|
+
node,
|
|
322
|
+
runner,
|
|
323
|
+
daemonEntry: daemon.entry,
|
|
324
|
+
agentId,
|
|
325
|
+
label: opts.label,
|
|
326
|
+
serviceName: opts.serviceName,
|
|
327
|
+
taskName: opts.taskName,
|
|
328
|
+
minutes: opts.minutes,
|
|
329
|
+
hostKind,
|
|
330
|
+
unitPath,
|
|
331
|
+
logDir,
|
|
332
|
+
configFile,
|
|
333
|
+
hostStateFile,
|
|
334
|
+
socketPath: socketInfo?.path ?? "",
|
|
335
|
+
socketBytes: socketInfo?.bytes ?? -1,
|
|
336
|
+
noStart: opts.noStart,
|
|
337
|
+
steps,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 执行计划(每步都可判定失败 → 不谎报成功)。 */
|
|
342
|
+
async function executePlan(plan) {
|
|
343
|
+
for (const step of plan.steps) {
|
|
344
|
+
switch (step.kind) {
|
|
345
|
+
case "mkdir":
|
|
346
|
+
fs.mkdirSync(step.path, { recursive: true });
|
|
347
|
+
break;
|
|
348
|
+
case "write": {
|
|
349
|
+
fs.mkdirSync(path.dirname(step.path), { recursive: true });
|
|
350
|
+
fs.writeFileSync(step.path, step.content);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case "writeIfAbsent":
|
|
354
|
+
if (!fs.existsSync(step.path)) {
|
|
355
|
+
fs.mkdirSync(path.dirname(step.path), { recursive: true });
|
|
356
|
+
fs.writeFileSync(step.path, step.content);
|
|
357
|
+
console.log(`已写值班配置(enabled=false · 默认干跑):${step.path}`);
|
|
358
|
+
} else {
|
|
359
|
+
console.log(`值班配置已存在,保持不动:${step.path}`);
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
case "exec": {
|
|
363
|
+
const r = run(step.cmd, step.args, { cwd: plan.workDir });
|
|
364
|
+
if (!r.ok && !step.ignoreFail) {
|
|
365
|
+
throw new Error(`${step.cmd} ${step.args.join(" ")} 失败:${r.stderr || r.stdout || r.error}`);
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
case "waitGone": {
|
|
370
|
+
// 等到目标从宿主里消失(最多 timeoutMs)
|
|
371
|
+
const deadline = Date.now() + (step.timeoutMs ?? 10_000);
|
|
372
|
+
while (Date.now() < deadline) {
|
|
373
|
+
const r = run(step.cmd, step.args, { cwd: plan.workDir });
|
|
374
|
+
if (!r.ok) break;
|
|
375
|
+
await sleep(200);
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case "execFallback": {
|
|
380
|
+
const last = step.attempts[step.attempts.length - 1];
|
|
381
|
+
for (const attempt of step.attempts) {
|
|
382
|
+
const r = run(attempt.cmd, attempt.args, { cwd: plan.workDir });
|
|
383
|
+
if (r.ok) break;
|
|
384
|
+
if (attempt === last) {
|
|
385
|
+
throw new Error(`${last.cmd} ${last.args.join(" ")} 失败:${last.stderr || last.stdout || last.error}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
default:
|
|
391
|
+
throw new Error(`未知步骤:${step.kind}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 睡一会儿。 */
|
|
397
|
+
function sleep(ms) {
|
|
398
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 验证三证据(进程在 + 心跳在更新 + 宿主已加载)。 */
|
|
402
|
+
async function verify(plan) {
|
|
403
|
+
const hbFile = path.join(plan.agentDir, "proactive", "daemon", "heartbeat.json");
|
|
404
|
+
let ts1 = 0;
|
|
405
|
+
let pid = 0;
|
|
406
|
+
// 等一个**活着的**值班进程写下的心跳(旧心跳可能来自刚被 bootout 杀掉的进程 → 不能算数)
|
|
407
|
+
for (let i = 0; i < 240; i++) {
|
|
408
|
+
const hb = readJsonSafe(hbFile);
|
|
409
|
+
if (hb && typeof hb.ts === "number" && isAlive(hb.pid)) {
|
|
410
|
+
ts1 = hb.ts;
|
|
411
|
+
pid = Number(hb.pid) || 0;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
await sleep(500);
|
|
415
|
+
}
|
|
416
|
+
if (!ts1) {
|
|
417
|
+
const tail = (f) => {
|
|
418
|
+
try {
|
|
419
|
+
return fs.readFileSync(f, "utf8").split("\n").slice(-20).join("\n");
|
|
420
|
+
} catch {
|
|
421
|
+
return "";
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
throw new Error(
|
|
425
|
+
`验证失败:没有看到心跳文件(${hbFile})→ 助理没起来\n` +
|
|
426
|
+
`---- daemon.err.log(尾部)----\n${tail(path.join(plan.logDir, "daemon.err.log"))}\n` +
|
|
427
|
+
`---- daemon.out.log(尾部)----\n${tail(path.join(plan.logDir, "daemon.out.log"))}`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (!isAlive(pid)) throw new Error(`验证失败:心跳里的进程不在(pid=${pid || "?"})→ 助理没起来`);
|
|
431
|
+
|
|
432
|
+
let ts2 = ts1;
|
|
433
|
+
for (let i = 0; i < 60 && ts2 === ts1; i++) {
|
|
434
|
+
await sleep(500);
|
|
435
|
+
ts2 = Number(readJsonSafe(hbFile)?.ts ?? ts1);
|
|
436
|
+
}
|
|
437
|
+
if (ts2 === ts1) throw new Error(`验证失败:心跳没有更新(ts 一直是 ${ts1})→ 助理没在跑`);
|
|
438
|
+
|
|
439
|
+
// 宿主已加载
|
|
440
|
+
if (plan.platform === "darwin") {
|
|
441
|
+
const r = run("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${plan.label}`]);
|
|
442
|
+
if (!r.ok) throw new Error(`验证失败:LaunchAgent 没加载(${plan.unitPath})→ 助理没在跑`);
|
|
443
|
+
} else if (plan.platform === "linux") {
|
|
444
|
+
const r = run("systemctl", ["--user", "is-active", plan.serviceName]);
|
|
445
|
+
if (!r.ok) throw new Error(`验证失败:systemd 单元没在跑(${plan.unitPath})→ 助理没在跑`);
|
|
446
|
+
} else {
|
|
447
|
+
const r = run("schtasks", ["/Query", "/TN", plan.taskName]);
|
|
448
|
+
if (!r.ok) throw new Error(`验证失败:计划任务不存在(${plan.taskName})→ 助理没在跑`);
|
|
449
|
+
}
|
|
450
|
+
return { pid, ts1, ts2 };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ------------------------------------------------------------------ 入口 ----
|
|
454
|
+
|
|
455
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
456
|
+
let plan;
|
|
457
|
+
try {
|
|
458
|
+
plan = buildPlan(opts);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
console.error(String(e instanceof Error ? e.message : e));
|
|
461
|
+
process.exit(2);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (opts.printPlan) {
|
|
465
|
+
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
466
|
+
process.exit(0);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
await executePlan(plan);
|
|
471
|
+
if (opts.noStart) {
|
|
472
|
+
console.log(`已写配置(--no-start · 未启动):${plan.unitPath}`);
|
|
473
|
+
process.exit(0);
|
|
474
|
+
}
|
|
475
|
+
const v = await verify(plan);
|
|
476
|
+
console.log("已常驻");
|
|
477
|
+
console.log(` · 宿主:${plan.hostKind}`);
|
|
478
|
+
console.log(` · 单元:${plan.unitPath}`);
|
|
479
|
+
console.log(` · 进程:pid=${v.pid} 在跑`);
|
|
480
|
+
console.log(` · 心跳:${path.join(plan.agentDir, "proactive", "daemon", "heartbeat.json")}(ts ${v.ts1} → ${v.ts2} 在前进)`);
|
|
481
|
+
console.log(` · 端点路径:${plan.socketPath}(${plan.socketBytes}B ≤ ${MAX_SOCKET_PATH_BYTES}B)`);
|
|
482
|
+
console.log(" · 想关掉:node pi-proactive/tools/daemon-uninstall.mjs");
|
|
483
|
+
console.log("提醒:默认 enabled=false + 干跑 → 装上不会花钱;要真跑请开开关与预算上限。");
|
|
484
|
+
process.exit(0);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
console.error(String(e instanceof Error ? e.message : e));
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|