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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-proactive 常驻助理(**跨平台用户侧入口**)的共享底座。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要这个文件:`tools/daemon-install.mjs`、`tools/daemon-uninstall.mjs`、
|
|
5
|
+
* `tools/verify-windows.mjs` 三者共用「平台判定 / 路径解析 / 模板渲染 / 命令执行」——
|
|
6
|
+
* **同一口径只写一份**(避免安装器与卸载器对「端点路径」「单元位置」的理解漂移)。
|
|
7
|
+
*
|
|
8
|
+
* 硬约束:
|
|
9
|
+
* · 零第三方依赖(只用 Node 内置模块);
|
|
10
|
+
* · 纯 ESM(`.mjs`)—— **不是 TypeScript**:`.mjs` 里不写类型注解(类型信息走 JSDoc),
|
|
11
|
+
* 否则 Node 直接语法报错;包内零 .ts 同样是发布纪律 W6;
|
|
12
|
+
* · Windows / macOS / Linux 同一份代码。
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
/** unix socket 端点路径上限(与 `src/socket.ts` 的 MAX_SOCKET_PATH_BYTES 逐字一致)。 */
|
|
21
|
+
export const MAX_SOCKET_PATH_BYTES = 100;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 平台判定。
|
|
25
|
+
*
|
|
26
|
+
* @param {string} [override] 显式平台(`win32` / `darwin` / `linux`)—— 供计划打印与测试用;
|
|
27
|
+
* 缺省读环境变量 `PI_PROACTIVE_PLATFORM`,再缺省用真实平台。
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function detectPlatform(override = "") {
|
|
31
|
+
const raw = String(override || process.env.PI_PROACTIVE_PLATFORM || "").toLowerCase();
|
|
32
|
+
if (raw === "win32" || raw === "windows") return "win32";
|
|
33
|
+
if (raw === "darwin" || raw === "macos") return "darwin";
|
|
34
|
+
if (raw === "linux") return "linux";
|
|
35
|
+
if (process.platform === "win32") return "win32";
|
|
36
|
+
if (process.platform === "darwin") return "darwin";
|
|
37
|
+
if (process.platform === "linux") return "linux";
|
|
38
|
+
return process.platform;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 包根目录(tools/ 的上一级)。
|
|
43
|
+
* @param {string} importMetaUrl
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function pkgRootFrom(importMetaUrl) {
|
|
47
|
+
return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* agent 根目录(默认 `~/.pi/agent`;可用 `PI_CODING_AGENT_DIR` 覆盖)。
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function defaultAgentDir() {
|
|
55
|
+
return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 稳定身份(跨重启连续;缺省按主机名派生)。
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function defaultAgentId() {
|
|
63
|
+
return `employee#${os.hostname()}#pi-proactive-daemon`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 常驻入口(值班进程本体)。
|
|
68
|
+
*
|
|
69
|
+
* 开发态(仓库):`src/daemon.ts` 是真源 → 优先;
|
|
70
|
+
* 产物态(npm 包):包里**没有** src/ → 落到 `build/src/daemon.js`。
|
|
71
|
+
* 候选清单与错误信息同源 → 报错时说的就是真的试过的路径。
|
|
72
|
+
*
|
|
73
|
+
* @param {string} pkgRoot
|
|
74
|
+
* @returns {{ entry: string; needsStripTypes: boolean } | null}
|
|
75
|
+
*/
|
|
76
|
+
export function resolveDaemonEntry(pkgRoot) {
|
|
77
|
+
for (const c of ["src/daemon.ts", "build/src/daemon.js"]) {
|
|
78
|
+
const p = path.join(pkgRoot, c);
|
|
79
|
+
if (fs.existsSync(p)) return { entry: p, needsStripTypes: p.endsWith(".ts") };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 端点(socket)路径 —— **复用 `src/socket.ts` 的实现**(不复制口径)。
|
|
86
|
+
*
|
|
87
|
+
* 用子进程 import 源码模块:`.ts` 走 Node 的类型擦除,`.js` 直接跑;
|
|
88
|
+
* 产物态与开发态都指向同一份逻辑 → 不会算错。
|
|
89
|
+
*
|
|
90
|
+
* @param {string} pkgRoot
|
|
91
|
+
* @param {string} agentId
|
|
92
|
+
* @param {string} agentDir
|
|
93
|
+
* @returns {{ path: string; bytes: number } | null}
|
|
94
|
+
*/
|
|
95
|
+
export function resolveSocketPath(pkgRoot, agentId, agentDir) {
|
|
96
|
+
const mod = ["src/socket.ts", "build/src/socket.js"]
|
|
97
|
+
.map((c) => path.join(pkgRoot, c))
|
|
98
|
+
.find((p) => fs.existsSync(p));
|
|
99
|
+
if (!mod) return null;
|
|
100
|
+
const code =
|
|
101
|
+
`import(${JSON.stringify(pathToFileURL(mod).href)})` +
|
|
102
|
+
`.then((m)=>process.stdout.write(m.socketPathFor(process.argv[1], process.argv[2])))` +
|
|
103
|
+
`.catch(()=>process.exit(3))`;
|
|
104
|
+
const res = spawnSync(process.execPath, ["--experimental-strip-types", "-e", code, agentId, agentDir], {
|
|
105
|
+
encoding: "utf8",
|
|
106
|
+
timeout: 20_000,
|
|
107
|
+
});
|
|
108
|
+
if (res.status !== 0) return null;
|
|
109
|
+
const p = (res.stdout ?? "").trim();
|
|
110
|
+
if (!p) return null;
|
|
111
|
+
return { path: p, bytes: Buffer.byteLength(p, "utf8") };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 同步跑一条命令(不继承 stdin;带超时守卫)。
|
|
116
|
+
*
|
|
117
|
+
* @param {string} cmd
|
|
118
|
+
* @param {string[]} args
|
|
119
|
+
* @param {{ cwd?: string; timeout?: number }} [opts]
|
|
120
|
+
*/
|
|
121
|
+
export function run(cmd, args, opts = {}) {
|
|
122
|
+
const res = spawnSync(cmd, args, {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
timeout: opts.timeout ?? 60_000,
|
|
125
|
+
cwd: opts.cwd,
|
|
126
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
code: res.status ?? (res.error ? 1 : 0),
|
|
130
|
+
ok: res.status === 0 && !res.error,
|
|
131
|
+
stdout: res.stdout ?? "",
|
|
132
|
+
stderr: res.stderr ?? "",
|
|
133
|
+
error: res.error ? String(res.error.message) : "",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 进程是否还活着(pid 无效 → false)。
|
|
139
|
+
* @param {number | undefined | null} pid
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
export function isAlive(pid) {
|
|
143
|
+
if (!pid || pid <= 0) return false;
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 读 JSON(坏数据不抛 → 返回 null;巡检脚本绝不能被脏输入搞崩)。
|
|
154
|
+
* @param {string} file
|
|
155
|
+
*/
|
|
156
|
+
export function readJsonSafe(file) {
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Windows 命令行参数引号化(含空格/引号 → 必须加引号)。
|
|
166
|
+
* @param {string} value
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function quoteWindows(value) {
|
|
170
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Windows 计划任务的可执行命令串(`schtasks /TR` 的值)。
|
|
175
|
+
*
|
|
176
|
+
* 关键:**路径含空格必须引号化**(`C:\Program Files\nodejs\node.exe`)。
|
|
177
|
+
*
|
|
178
|
+
* @param {{ node: string; runner: string; extraArgs?: string[] }} o
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function buildWindowsTaskCommand(o) {
|
|
182
|
+
const parts = [quoteWindows(o.node), quoteWindows(o.runner), ...(o.extraArgs ?? []).map(quoteWindows)];
|
|
183
|
+
return parts.join(" ");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* systemd 的参数引号化(路径含空格 → 加引号)。
|
|
188
|
+
* @param {string} value
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
export function quoteSystemd(value) {
|
|
192
|
+
const s = String(value);
|
|
193
|
+
return /[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* XML 文本转义(plist 里路径可能带 & < >)。
|
|
198
|
+
* @param {string} value
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
export function xmlEscape(value) {
|
|
202
|
+
return String(value)
|
|
203
|
+
.replace(/&/g, "&")
|
|
204
|
+
.replace(/</g, "<")
|
|
205
|
+
.replace(/>/g, ">");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 渲染 launchd 单元(**复用 `daemon/launchd.plist` 模板,不改模板本身**)。
|
|
210
|
+
*
|
|
211
|
+
* 只做两件事:
|
|
212
|
+
* ① 占位符替换(label / pkgRoot / agentId / logDir / runner);
|
|
213
|
+
* ② 把 `ProgramArguments` 换成**直接调用 node**(模板里是 `/bin/bash` →
|
|
214
|
+
* 包内不得依赖 bash;unix 侧 bash 仍在,但统一走 node 才能三平台同源)。
|
|
215
|
+
*
|
|
216
|
+
* @param {string} template
|
|
217
|
+
* @param {{ label: string; pkgRoot: string; agentId: string; logDir: string; runner: string; programArgs: string[] }} o
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
export function renderLaunchdPlist(template, o) {
|
|
221
|
+
let out = template
|
|
222
|
+
.replace(/__LABEL__/g, o.label)
|
|
223
|
+
.replace(/__PKG_ROOT__/g, o.pkgRoot)
|
|
224
|
+
.replace(/__AGENT_ID__/g, o.agentId)
|
|
225
|
+
.replace(/__LOG_DIR__/g, o.logDir)
|
|
226
|
+
.replace(/__RUNNER__/g, o.runner);
|
|
227
|
+
const entries = o.programArgs.map((a) => `\t\t<string>${xmlEscape(a)}</string>`).join("\n");
|
|
228
|
+
out = out.replace(
|
|
229
|
+
/(<key>ProgramArguments<\/key>\s*<array>)[\s\S]*?(<\/array>)/,
|
|
230
|
+
(_m, head, tail) => `${head}\n${entries}\n\t${tail}`,
|
|
231
|
+
);
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 渲染 systemd --user 单元(**复用 `daemon/pi-proactive-daemon.service` 模板**)。
|
|
237
|
+
*
|
|
238
|
+
* 与 plist 同理:`ExecStart` 换成直接调用 node(不再经 bash)。
|
|
239
|
+
*
|
|
240
|
+
* @param {string} template
|
|
241
|
+
* @param {{ pkgRoot: string; agentId: string; workDir: string; execStart: string }} o
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
export function renderSystemdUnit(template, o) {
|
|
245
|
+
return template
|
|
246
|
+
.split("\n")
|
|
247
|
+
.map((line) => {
|
|
248
|
+
if (line.startsWith("ExecStart=")) return `ExecStart=${o.execStart}`;
|
|
249
|
+
if (line.startsWith("WorkingDirectory=")) return `WorkingDirectory=${o.workDir || o.pkgRoot}`;
|
|
250
|
+
if (line.startsWith("Environment=PI_PROACTIVE_AGENT_ID=")) {
|
|
251
|
+
return `Environment=PI_PROACTIVE_AGENT_ID=${o.agentId}`;
|
|
252
|
+
}
|
|
253
|
+
return line;
|
|
254
|
+
})
|
|
255
|
+
.join("\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 选一个**够短**的 agent 根(socket 端点路径 < 100B 的硬约束)。
|
|
260
|
+
*
|
|
261
|
+
* @param {string[]} candidates 候选根(按优先级)
|
|
262
|
+
* @param {(dir: string) => number | null} measure 量一条候选的端点路径字节数(null = 量不出来)
|
|
263
|
+
* @returns {{ dir: string; bytes: number } | null}
|
|
264
|
+
*/
|
|
265
|
+
export function chooseShortAgentDir(candidates, measure) {
|
|
266
|
+
for (const dir of candidates) {
|
|
267
|
+
const bytes = measure(dir);
|
|
268
|
+
if (bytes !== null && bytes <= MAX_SOCKET_PATH_BYTES) return { dir, bytes };
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 心跳文件路径(与 `src/daemon-config.ts` 的 heartbeatPath 同口径)。
|
|
275
|
+
* @param {string} agentDir
|
|
276
|
+
* @returns {string}
|
|
277
|
+
*/
|
|
278
|
+
export function heartbeatPathFor(agentDir) {
|
|
279
|
+
return path.join(agentDir, "proactive", "daemon", "heartbeat.json");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 值班锁目录(与 `daemon/run-daemon.mjs` 同口径)。
|
|
284
|
+
* @param {string} agentDir
|
|
285
|
+
* @returns {string}
|
|
286
|
+
*/
|
|
287
|
+
export function lockPathFor(agentDir) {
|
|
288
|
+
return path.join(agentDir, "proactive", "daemon", "daemon.lock");
|
|
289
|
+
}
|