pi-proactive 0.2.0-beta.3 → 0.2.0-beta.5
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 +27 -0
- package/README.md +46 -29
- package/build/cli.mjs +70 -6
- package/build/extensions/proactive.js +187 -2
- package/build/src/assistant.js +572 -0
- package/build/src/commands.js +13 -0
- package/build/src/config.js +20 -0
- package/build/src/content.js +2 -0
- package/build/src/daemon-host.js +2 -0
- package/build/src/daemon.js +2 -0
- package/build/src/dedupe.js +2 -0
- package/build/src/health.js +2 -0
- package/build/src/lineage.js +2 -0
- package/build/src/mailbox.js +2 -0
- package/build/src/notify.js +2 -0
- package/build/src/peer-spawn.js +2 -0
- package/build/src/peer.js +2 -0
- package/build/src/registry.js +2 -0
- package/build/src/socket.js +2 -0
- package/build/src/supervisor-state.js +355 -0
- package/build/src/supervisor-tui.js +85 -0
- package/build/src/supervisor-web.js +262 -0
- package/build/src/task-store.js +2 -0
- package/build/src/thread.js +2 -0
- package/package.json +7 -6
- package/skills/pi-proactive/SKILL.md +92 -27
- package/tools/cli.mjs +70 -6
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// 【S0 归属】L3 宿主集成(契约:docs/project/kernel-contract-s0.md §6.1)
|
|
2
|
+
// → 直接面对宿主/操作系统;内核面(机制/协议/记录/决策/目标/接口)不在本文件。
|
|
3
|
+
/**
|
|
4
|
+
* 会话内「常驻助理」控制面(`/proactive assistant on|off|status|restart`)。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么有这层(缺口的事实)
|
|
7
|
+
*
|
|
8
|
+
* 用户原话:「没有 /proactive daemon install」——
|
|
9
|
+
* 常驻助理的**控制逻辑**早就有了(CLI 侧 `pi-proactive daemon install|uninstall|status`,
|
|
10
|
+
* 见 `tools/cli.mjs` 与 `tools/daemon-install.sh`),但**会话里没有入口**:
|
|
11
|
+
* 用户想「打开常驻」时只能切到终端跑 CLI → 「用户只面对三件事(打开 / 它会做什么 / 怎么回应)」
|
|
12
|
+
* 里的【打开】在会话内**做不成**(见 `docs/product/ux-mvp-spec.md` §3)。
|
|
13
|
+
*
|
|
14
|
+
* ## 硬约束(本文件的设计前提)
|
|
15
|
+
*
|
|
16
|
+
* 1. **复用同一套 daemon 控制实现**:安装 / 卸载**只调用** `tools/daemon-install.sh` 与
|
|
17
|
+
* `tools/daemon-uninstall.sh` —— 与 CLI **同一份脚本**(本文件不复制任何安装逻辑)。
|
|
18
|
+
* 2. **安装不得阻塞会话**:安装要等 launchd/systemd 冷启动 + 心跳前进(实测数十秒),
|
|
19
|
+
* 在会话里同步等待 = 用户看着界面卡住 → 本文件**立即回执「正在安装…」**,
|
|
20
|
+
* 后台跑完再用**事件流 / 状态**报结果(`/proactive assistant status` 看得到)。
|
|
21
|
+
* 3. **失败必须可操作**:缺什么 / 怎么手动跑 / 怎么清理,三样都要说。
|
|
22
|
+
* 4. **状态口径单一**:宿主 / 进程 / 心跳 / 上次联系 全部读**文件事实**
|
|
23
|
+
* (`host.json` / `heartbeat.json` / `contacts.jsonl`)→ 会话内看到的与 CLI 看到的是同一份。
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { proactiveDir, resolveAgentDir } from "./config.js";
|
|
30
|
+
import { heartbeatPath, hostStatePath, readContactEntries } from "./daemon-config.js";
|
|
31
|
+
/**
|
|
32
|
+
* 心跳陈旧阈值(ms):常驻值班默认 30s 一跳 → 5 分钟 = 10 跳没来 → 判定「心跳已停」。
|
|
33
|
+
*
|
|
34
|
+
* 为什么要它:心跳文件在 ≠ 值班进程还活着(进程崩了文件还在)→ 只看文件会撒谎。
|
|
35
|
+
*/
|
|
36
|
+
export const HEARTBEAT_STALE_MS = 5 * 60_000;
|
|
37
|
+
/**
|
|
38
|
+
* 定位包根与两个常驻脚本。
|
|
39
|
+
*
|
|
40
|
+
* 两种落位都要认(与 `src/health.ts` 的 `pluginVersionFrom` 同一套办法):
|
|
41
|
+
* - 源码态 `pi-proactive/src/assistant.ts` → 包根在**上一级**;
|
|
42
|
+
* - 产物态 `<包>/build/src/assistant.js` → 包根在**上两级**。
|
|
43
|
+
*
|
|
44
|
+
* 环境变量 `PI_PROACTIVE_PKG_ROOT` 可显式指定包根(自测 / 特殊落位用)。
|
|
45
|
+
*/
|
|
46
|
+
export function resolveAssistantScripts(baseDir) {
|
|
47
|
+
const here = baseDir ?? path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const candidates = [];
|
|
49
|
+
const envRoot = process.env.PI_PROACTIVE_PKG_ROOT;
|
|
50
|
+
if (envRoot !== undefined && envRoot !== "")
|
|
51
|
+
candidates.push(path.resolve(envRoot));
|
|
52
|
+
// 顺序:源码态优先(`..`),产物态回落(`../..`)。
|
|
53
|
+
for (const up of ["..", "../.."])
|
|
54
|
+
candidates.push(path.resolve(here, up));
|
|
55
|
+
const tried = [];
|
|
56
|
+
for (const root of candidates) {
|
|
57
|
+
const install = path.join(root, "tools", "daemon-install.sh");
|
|
58
|
+
tried.push(install);
|
|
59
|
+
if (!fs.existsSync(install))
|
|
60
|
+
continue;
|
|
61
|
+
return {
|
|
62
|
+
pkgRoot: root,
|
|
63
|
+
install,
|
|
64
|
+
uninstall: path.join(root, "tools", "daemon-uninstall.sh"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
error: `找不到常驻安装脚本(试过 ${tried.join("、")})—— ` +
|
|
69
|
+
`重新安装 pi-proactive(npm i -g pi-proactive),或手动执行 npx pi-proactive daemon install;` +
|
|
70
|
+
`清理残留:rm -rf <agent-dir>/proactive/daemon && rm -f ~/Library/LaunchAgents/com.pi-proactive.daemon.plist`,
|
|
71
|
+
tried,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** 操作状态文件:`<agentDir>/proactive/assistant.json`(**不放** daemon 目录:卸载会删掉那个目录)。 */
|
|
75
|
+
export function assistantStatePath(agentDir) {
|
|
76
|
+
return path.join(proactiveDir(agentDir), "assistant.json");
|
|
77
|
+
}
|
|
78
|
+
/** 读操作状态(坏文件不当场崩,按「没有记录」处理)。 */
|
|
79
|
+
export function readAssistantAction(agentDir) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(fs.readFileSync(assistantStatePath(agentDir), "utf8"));
|
|
82
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
83
|
+
return undefined;
|
|
84
|
+
const rec = parsed;
|
|
85
|
+
if (typeof rec.action !== "string" || typeof rec.startedAt !== "number")
|
|
86
|
+
return undefined;
|
|
87
|
+
return rec;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** 写操作状态(原子写;坏目录不抛 —— 状态是**可观测性**,不是业务数据)。 */
|
|
94
|
+
export function writeAssistantAction(agentDir, state) {
|
|
95
|
+
const file = assistantStatePath(agentDir);
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
99
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
|
|
100
|
+
fs.renameSync(tmp, file);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// ignore: 写不进去也不能影响主流程
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** 进程是否还活着。 */
|
|
107
|
+
function pidAlive(pid) {
|
|
108
|
+
if (pid === undefined || pid <= 0)
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** 读常驻状态(只读;坏文件按「没有」处理,绝不抛)。 */
|
|
119
|
+
export function readAssistantStatus(agentDir, now = Date.now()) {
|
|
120
|
+
const status = { installed: false, present: false, pidAlive: false };
|
|
121
|
+
const host = readJsonSafe(hostStatePath(agentDir));
|
|
122
|
+
if (host !== undefined) {
|
|
123
|
+
status.present = true;
|
|
124
|
+
if (typeof host.hostKind === "string")
|
|
125
|
+
status.hostKind = host.hostKind;
|
|
126
|
+
if (typeof host.unitPath === "string" && host.unitPath !== "")
|
|
127
|
+
status.unitPath = host.unitPath;
|
|
128
|
+
if (typeof host.workspaceId === "string" && host.workspaceId !== "")
|
|
129
|
+
status.workspaceId = host.workspaceId;
|
|
130
|
+
}
|
|
131
|
+
const hb = readJsonSafe(heartbeatPath(agentDir));
|
|
132
|
+
if (hb !== undefined) {
|
|
133
|
+
status.present = true;
|
|
134
|
+
if (typeof hb.ts === "number") {
|
|
135
|
+
status.heartbeatTs = hb.ts;
|
|
136
|
+
status.heartbeatAgeMs = Math.max(0, now - hb.ts);
|
|
137
|
+
}
|
|
138
|
+
if (typeof hb.pid === "number") {
|
|
139
|
+
status.pid = hb.pid;
|
|
140
|
+
status.pidAlive = pidAlive(hb.pid);
|
|
141
|
+
}
|
|
142
|
+
if (status.hostKind === undefined && typeof hb.host === "string")
|
|
143
|
+
status.hostKind = hb.host;
|
|
144
|
+
}
|
|
145
|
+
// **真在跑吗**:心跳在 + (进程活着 | 心跳新鲜但拿不到 pid)才算常驻。
|
|
146
|
+
const fresh = status.heartbeatAgeMs !== undefined && status.heartbeatAgeMs <= HEARTBEAT_STALE_MS;
|
|
147
|
+
if (hb !== undefined) {
|
|
148
|
+
if (status.pid !== undefined) {
|
|
149
|
+
status.installed = status.pidAlive;
|
|
150
|
+
if (!status.installed)
|
|
151
|
+
status.notRunningReason = "process-dead";
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
status.installed = fresh;
|
|
155
|
+
if (!fresh)
|
|
156
|
+
status.notRunningReason = "heartbeat-stale";
|
|
157
|
+
}
|
|
158
|
+
if (status.installed && !fresh)
|
|
159
|
+
status.notRunningReason = "heartbeat-stale";
|
|
160
|
+
}
|
|
161
|
+
const contacts = readContactEntries(agentDir);
|
|
162
|
+
const last = contacts.length > 0 ? contacts[contacts.length - 1] : undefined;
|
|
163
|
+
if (last !== undefined) {
|
|
164
|
+
status.lastContactTs = last.ts;
|
|
165
|
+
if (last.targetAgentId !== undefined)
|
|
166
|
+
status.lastContactTarget = last.targetAgentId;
|
|
167
|
+
}
|
|
168
|
+
status.action = readAssistantAction(agentDir);
|
|
169
|
+
return status;
|
|
170
|
+
}
|
|
171
|
+
/** 读 JSON(坏文件 / 不存在 → undefined,绝不抛)。 */
|
|
172
|
+
function readJsonSafe(file) {
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
175
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
176
|
+
return undefined;
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** 「多久之前」(人话)。 */
|
|
184
|
+
export function formatAgo(ms) {
|
|
185
|
+
if (ms < 1000)
|
|
186
|
+
return "刚刚";
|
|
187
|
+
const s = Math.round(ms / 1000);
|
|
188
|
+
if (s < 60)
|
|
189
|
+
return `${s} 秒前`;
|
|
190
|
+
const m = Math.round(s / 60);
|
|
191
|
+
if (m < 60)
|
|
192
|
+
return `${m} 分钟前`;
|
|
193
|
+
const h = Math.round(m / 60);
|
|
194
|
+
if (h < 24)
|
|
195
|
+
return `${h} 小时前`;
|
|
196
|
+
return `${Math.round(h / 24)} 天前`;
|
|
197
|
+
}
|
|
198
|
+
/** 时刻(本地时区,秒级)。 */
|
|
199
|
+
function formatTime(ts) {
|
|
200
|
+
const d = new Date(ts);
|
|
201
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
202
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 常驻状态的人话视图(宿主 + 进程 + 心跳 + 上次联系 —— 四项缺一不可)。
|
|
206
|
+
*
|
|
207
|
+
* 口径说明:**只报文件里读到的**,读不到就说「无」→ 不给用户「它还在跑」的错觉。
|
|
208
|
+
*/
|
|
209
|
+
export function formatAssistantStatus(status) {
|
|
210
|
+
const lines = [];
|
|
211
|
+
if (status.installed) {
|
|
212
|
+
lines.push("常驻助理:已常驻");
|
|
213
|
+
}
|
|
214
|
+
else if (status.notRunningReason === "process-dead") {
|
|
215
|
+
lines.push("常驻助理:未常驻(心跳文件在,但值班进程已经不在)");
|
|
216
|
+
}
|
|
217
|
+
else if (status.notRunningReason === "heartbeat-stale") {
|
|
218
|
+
lines.push("常驻助理:未常驻(心跳已停)");
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
lines.push("常驻助理:未常驻");
|
|
222
|
+
}
|
|
223
|
+
if (status.hostKind !== undefined) {
|
|
224
|
+
const unit = status.unitPath === undefined ? "" : `(单元 ${status.unitPath})`;
|
|
225
|
+
const ws = status.workspaceId === undefined ? "" : `(workspace ${status.workspaceId})`;
|
|
226
|
+
lines.push(` · 宿主:${status.hostKind}${unit}${ws}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
lines.push(" · 宿主:无");
|
|
230
|
+
}
|
|
231
|
+
if (status.pid !== undefined) {
|
|
232
|
+
lines.push(` · 进程:pid=${status.pid} ${status.pidAlive ? "在跑" : "不在"}`);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
lines.push(" · 进程:无");
|
|
236
|
+
}
|
|
237
|
+
if (status.heartbeatTs !== undefined) {
|
|
238
|
+
lines.push(` · 心跳:${formatAgo(status.heartbeatAgeMs ?? 0)}(ts ${status.heartbeatTs})`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
lines.push(" · 心跳:无");
|
|
242
|
+
}
|
|
243
|
+
if (status.lastContactTs !== undefined) {
|
|
244
|
+
const target = status.lastContactTarget === undefined ? "" : `(对象 ${status.lastContactTarget})`;
|
|
245
|
+
lines.push(` · 上次联系:${formatAgo(Math.max(0, Date.now() - status.lastContactTs))}${target}`);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
lines.push(" · 上次联系:无");
|
|
249
|
+
}
|
|
250
|
+
if (status.action !== undefined) {
|
|
251
|
+
const act = status.action;
|
|
252
|
+
const done = act.finishedAt !== undefined;
|
|
253
|
+
const verdict = done ? (act.ok === true ? "成功" : `失败(退出码 ${String(act.exitCode ?? "?")})`) : "进行中";
|
|
254
|
+
lines.push(` · 最近操作:${act.action} · ${verdict}(${formatTime(act.startedAt)} 起)`);
|
|
255
|
+
if (done && act.ok === false) {
|
|
256
|
+
// 失败要**可操作**:光说「失败」没用 → 把失败输出与清理办法一并给出。
|
|
257
|
+
const tail = (act.output ?? "").trim();
|
|
258
|
+
if (tail !== "")
|
|
259
|
+
lines.push(` · 上次失败输出:${tail.split("\n").slice(-6).join(" | ")}`);
|
|
260
|
+
lines.push(" · 清理:/proactive assistant off(清单元 / 进程 / 状态目录)或 bash <包目录>/tools/daemon-uninstall.sh");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!status.installed) {
|
|
264
|
+
lines.push(" · 打开:/proactive assistant on");
|
|
265
|
+
}
|
|
266
|
+
return lines.join("\n");
|
|
267
|
+
}
|
|
268
|
+
/** 常驻目录(安装日志放这儿 → 卸载时一起清掉,不留残留)。 */
|
|
269
|
+
function daemonLogDir(agentDir) {
|
|
270
|
+
return path.join(proactiveDir(agentDir), "daemon");
|
|
271
|
+
}
|
|
272
|
+
/** 脚本参数(**只传脚本认识的参数** → 与 CLI 的参数契约同一套)。 */
|
|
273
|
+
function scriptArgs(opts, agentDir) {
|
|
274
|
+
const args = ["--agent-dir", agentDir];
|
|
275
|
+
const label = opts.label ?? process.env.PI_PROACTIVE_DAEMON_LABEL;
|
|
276
|
+
if (label !== undefined && label !== "")
|
|
277
|
+
args.push("--label", label);
|
|
278
|
+
const service = opts.serviceName ?? process.env.PI_PROACTIVE_DAEMON_SERVICE;
|
|
279
|
+
if (service !== undefined && service !== "")
|
|
280
|
+
args.push("--service-name", service);
|
|
281
|
+
if (opts.agentId !== undefined && opts.agentId !== "")
|
|
282
|
+
args.push("--agent-id", opts.agentId);
|
|
283
|
+
if (opts.host !== undefined && opts.host !== "")
|
|
284
|
+
args.push("--host", opts.host);
|
|
285
|
+
return args;
|
|
286
|
+
}
|
|
287
|
+
/** 取脚本落位(注入优先 → 解析)。 */
|
|
288
|
+
function scriptsOf(opts) {
|
|
289
|
+
if (opts.scripts !== undefined)
|
|
290
|
+
return opts.scripts;
|
|
291
|
+
return resolveAssistantScripts();
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* 打开常驻助理(**立即返回,不阻塞会话**)。
|
|
295
|
+
*
|
|
296
|
+
* 流程:起后台进程跑安装脚本 → 立刻回执「正在安装…」→ 装完由 `onDone` 报结果,
|
|
297
|
+
* 结果同时落盘(`assistant.json`)→ `/proactive assistant status` 随时可查。
|
|
298
|
+
*/
|
|
299
|
+
export async function requestAssistantInstall(opts = {}) {
|
|
300
|
+
const agentDir = opts.agentDir ?? resolveAgentDir();
|
|
301
|
+
const scripts = scriptsOf(opts);
|
|
302
|
+
if ("error" in scripts) {
|
|
303
|
+
return { text: `proactive: ${scripts.error}`, level: "error" };
|
|
304
|
+
}
|
|
305
|
+
const previous = readAssistantAction(agentDir);
|
|
306
|
+
if (previous !== undefined && previous.finishedAt === undefined && pidAlive(previous.pid)) {
|
|
307
|
+
return {
|
|
308
|
+
text: `常驻助理:正在安装…(上一次安装还在跑,pid=${String(previous.pid)})\n` +
|
|
309
|
+
` · 看结果:/proactive assistant status`,
|
|
310
|
+
level: "info",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const logDir = daemonLogDir(agentDir);
|
|
314
|
+
const logFile = path.join(logDir, "assistant-install.log");
|
|
315
|
+
try {
|
|
316
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
317
|
+
fs.writeFileSync(logFile, "");
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// ignore: 日志写不了也要能装
|
|
321
|
+
}
|
|
322
|
+
const out = fs.openSync(logFile, "a");
|
|
323
|
+
let spawned;
|
|
324
|
+
try {
|
|
325
|
+
spawned = spawn("bash", [scripts.install, ...scriptArgs(opts, agentDir)], {
|
|
326
|
+
detached: true,
|
|
327
|
+
stdio: ["ignore", out, out],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
fs.closeSync(out);
|
|
332
|
+
return {
|
|
333
|
+
text: `proactive: 常驻助理安装起不来:${err instanceof Error ? err.message : String(err)}\n` +
|
|
334
|
+
` · 手动等价命令:bash ${scripts.install} --agent-dir ${agentDir}\n` +
|
|
335
|
+
` · 清理命令:bash ${scripts.uninstall} --agent-dir ${agentDir}`,
|
|
336
|
+
level: "error",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
fs.closeSync(out);
|
|
340
|
+
const child = spawned;
|
|
341
|
+
const startedAt = Date.now();
|
|
342
|
+
const actionKind = opts.action === "restart" ? "restart" : "install";
|
|
343
|
+
writeAssistantAction(agentDir, {
|
|
344
|
+
action: actionKind,
|
|
345
|
+
startedAt,
|
|
346
|
+
pid: child.pid,
|
|
347
|
+
});
|
|
348
|
+
// 后台跑完 → 落状态 + 报结果(**不阻塞调用方**)。
|
|
349
|
+
child.on("close", (code) => {
|
|
350
|
+
const output = readTail(logFile);
|
|
351
|
+
const ok = code === 0 && /已常驻/.test(output);
|
|
352
|
+
void (async () => {
|
|
353
|
+
// 失败 → **自动回滚**(与 CLI `daemon install` 同一口径:装不成功就不给用户留单元 / 进程)。
|
|
354
|
+
// 不回滚的后果(实测):安装装到一半失败 → launchd 单元仍已加载 → 反复拉起失败进程。
|
|
355
|
+
const rollback = ok ? "" : await rollbackInstall(scripts, scriptArgs(opts, agentDir));
|
|
356
|
+
writeAssistantAction(agentDir, {
|
|
357
|
+
action: actionKind,
|
|
358
|
+
startedAt,
|
|
359
|
+
finishedAt: Date.now(),
|
|
360
|
+
ok,
|
|
361
|
+
exitCode: code ?? -1,
|
|
362
|
+
output: output.slice(-2000),
|
|
363
|
+
pid: child.pid,
|
|
364
|
+
});
|
|
365
|
+
const text = ok
|
|
366
|
+
? `已常驻\n${output.trim()}`
|
|
367
|
+
: installFailureText(code, output, scripts, agentDir, rollback);
|
|
368
|
+
opts.onDone?.(text);
|
|
369
|
+
})();
|
|
370
|
+
});
|
|
371
|
+
// spawn 失败(如 bash 不在 PATH)不会 throw,而是异步发 'error' —— 必须挂监听,
|
|
372
|
+
// 否则未处理的 error 事件会把整个会话带崩。
|
|
373
|
+
child.on("error", (err) => {
|
|
374
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
375
|
+
void (async () => {
|
|
376
|
+
const rollback = await rollbackInstall(scripts, scriptArgs(opts, agentDir));
|
|
377
|
+
writeAssistantAction(agentDir, {
|
|
378
|
+
action: actionKind,
|
|
379
|
+
startedAt,
|
|
380
|
+
finishedAt: Date.now(),
|
|
381
|
+
ok: false,
|
|
382
|
+
exitCode: 127,
|
|
383
|
+
output: message,
|
|
384
|
+
pid: child.pid,
|
|
385
|
+
});
|
|
386
|
+
opts.onDone?.(installFailureText(127, message, scripts, agentDir, rollback));
|
|
387
|
+
})();
|
|
388
|
+
});
|
|
389
|
+
child.unref();
|
|
390
|
+
return {
|
|
391
|
+
text: `常驻助理:正在安装…(不阻塞本会话)\n` +
|
|
392
|
+
` · 看结果:/proactive assistant status(装完会显示「已常驻」或失败原因)\n` +
|
|
393
|
+
` · 手动等价命令:bash ${scripts.install} --agent-dir ${agentDir}\n` +
|
|
394
|
+
` · 想关掉:/proactive assistant off`,
|
|
395
|
+
level: "info",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** 安装失败的回执(缺什么 / 怎么手动跑 / 怎么清理 —— 三样都要说)。 */
|
|
399
|
+
async function rollbackInstall(scripts, args) {
|
|
400
|
+
const result = await runScript(scripts.uninstall, args, 60_000);
|
|
401
|
+
const output = `${result.stdout}\n${result.stderr}`.trim();
|
|
402
|
+
return result.code === 0 ? output : `回滚未成功(退出码 ${String(result.code ?? "?")}):${output}`;
|
|
403
|
+
}
|
|
404
|
+
/** 安装失败的回执(缺什么 / 怎么手动跑 / 怎么清理 —— 三样都要说)。 */
|
|
405
|
+
function installFailureText(code, output, scripts, agentDir, rollback = "") {
|
|
406
|
+
const lines = [
|
|
407
|
+
`常驻助理:安装失败(退出码 ${String(code ?? "?")})`,
|
|
408
|
+
` · 输出:\n${indent(output.trim())}`,
|
|
409
|
+
];
|
|
410
|
+
if (rollback !== "") {
|
|
411
|
+
lines.push(` · 已自动回滚(不留已加载的单元):\n${indent(rollback)}`);
|
|
412
|
+
}
|
|
413
|
+
lines.push(` · 手动重试:bash ${scripts.install} --agent-dir ${agentDir}`);
|
|
414
|
+
lines.push(` · 清理残留:bash ${scripts.uninstall} --agent-dir ${agentDir}`);
|
|
415
|
+
return lines.join("\n");
|
|
416
|
+
}
|
|
417
|
+
/** 缩进(多行输出嵌进回执)。 */
|
|
418
|
+
function indent(text) {
|
|
419
|
+
if (text === "")
|
|
420
|
+
return " (无输出)";
|
|
421
|
+
return text
|
|
422
|
+
.split("\n")
|
|
423
|
+
.map((line) => ` ${line}`)
|
|
424
|
+
.join("\n");
|
|
425
|
+
}
|
|
426
|
+
/** 读文件尾部(最多 n 字符)。 */
|
|
427
|
+
function readTail(file, n = 4000) {
|
|
428
|
+
try {
|
|
429
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
430
|
+
return raw.length <= n ? raw : raw.slice(raw.length - n);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return "";
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* 关闭常驻助理(同步等卸载脚本跑完 → 回执含**残留终检**)。
|
|
438
|
+
*
|
|
439
|
+
* 为什么不后台跑:卸载的结论就是「有没有残留」,不等完就报等于没结论。
|
|
440
|
+
* 超时上限 60s(超了 → 如实说超时,并给出清理命令)。
|
|
441
|
+
*/
|
|
442
|
+
export async function requestAssistantUninstall(opts = {}) {
|
|
443
|
+
const agentDir = opts.agentDir ?? resolveAgentDir();
|
|
444
|
+
const scripts = scriptsOf(opts);
|
|
445
|
+
if ("error" in scripts) {
|
|
446
|
+
return { text: `proactive: ${scripts.error}`, level: "error" };
|
|
447
|
+
}
|
|
448
|
+
const startedAt = Date.now();
|
|
449
|
+
const before = readAssistantStatus(agentDir);
|
|
450
|
+
const unitPath = before.unitPath;
|
|
451
|
+
const args = scriptArgs(opts, agentDir);
|
|
452
|
+
const result = await runScript(scripts.uninstall, args, 60_000);
|
|
453
|
+
const output = `${result.stdout}\n${result.stderr}`.trim();
|
|
454
|
+
const timedOut = result.timedOut;
|
|
455
|
+
writeAssistantAction(agentDir, {
|
|
456
|
+
action: "uninstall",
|
|
457
|
+
startedAt,
|
|
458
|
+
finishedAt: Date.now(),
|
|
459
|
+
ok: !timedOut && result.code === 0,
|
|
460
|
+
exitCode: result.code ?? -1,
|
|
461
|
+
output: output.slice(-2000),
|
|
462
|
+
});
|
|
463
|
+
// **独立终检**(不只信脚本自己的结论):进程 / 单元文件 / 状态目录。
|
|
464
|
+
const residue = await residueCheck(agentDir, unitPath, before.pid);
|
|
465
|
+
const lines = [];
|
|
466
|
+
if (!before.present) {
|
|
467
|
+
// 从来没装过 → 不谎报「已关闭」(那会让用户以为关掉了什么)。
|
|
468
|
+
lines.push("常驻助理:未常驻(没有可关闭的东西)");
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
lines.push(`常驻助理:${!timedOut && result.code === 0 ? "已关闭" : "关闭失败"}`);
|
|
472
|
+
}
|
|
473
|
+
lines.push(` · 残留终检:${residue.residue === 0 ? "无残留" : `有残留 ${String(residue.residue)} 项`}`);
|
|
474
|
+
lines.push(` · 任务 ${String(residue.tasks)} / 进程 ${String(residue.processes)} / 单元 ${String(residue.units)}`);
|
|
475
|
+
if (residue.detail.length > 0)
|
|
476
|
+
lines.push(` · 明细:${residue.detail.join(";")}`);
|
|
477
|
+
if (timedOut) {
|
|
478
|
+
lines.push(" · 说明:卸载脚本超时(60s)→ 状态未知,请手动再跑一次并看残留");
|
|
479
|
+
}
|
|
480
|
+
if (output !== "")
|
|
481
|
+
lines.push(indent(output));
|
|
482
|
+
lines.push(` · 手动等价命令:bash ${scripts.uninstall} --agent-dir ${agentDir}`);
|
|
483
|
+
const level = !timedOut && result.code === 0 && residue.residue === 0 ? "info" : "error";
|
|
484
|
+
return { text: lines.join("\n"), level };
|
|
485
|
+
}
|
|
486
|
+
/** 卸载残留终检:进程 / 单元文件 / 状态目录 / 任务表。 */
|
|
487
|
+
async function residueCheck(agentDir, unitPath, pid) {
|
|
488
|
+
const detail = [];
|
|
489
|
+
let processes = 0;
|
|
490
|
+
let units = 0;
|
|
491
|
+
if (pidAlive(pid)) {
|
|
492
|
+
processes += 1;
|
|
493
|
+
detail.push(`值班进程还在(pid=${String(pid)})`);
|
|
494
|
+
}
|
|
495
|
+
if (unitPath !== undefined && fs.existsSync(unitPath)) {
|
|
496
|
+
units += 1;
|
|
497
|
+
detail.push(`单元文件还在(${unitPath})`);
|
|
498
|
+
}
|
|
499
|
+
if (fs.existsSync(daemonLogDir(agentDir))) {
|
|
500
|
+
units += 1;
|
|
501
|
+
detail.push(`状态/日志目录还在(${daemonLogDir(agentDir)})`);
|
|
502
|
+
}
|
|
503
|
+
// 任务表:卸载**默认保留用户数据**(任务 / 事件流 / 邮箱)→ 这里只报数量,不当残留。
|
|
504
|
+
const tasksFile = path.join(proactiveDir(agentDir), "tasks.json");
|
|
505
|
+
let tasks = 0;
|
|
506
|
+
try {
|
|
507
|
+
const parsed = JSON.parse(fs.readFileSync(tasksFile, "utf8"));
|
|
508
|
+
if (Array.isArray(parsed))
|
|
509
|
+
tasks = parsed.length;
|
|
510
|
+
else if (typeof parsed === "object" && parsed !== null) {
|
|
511
|
+
const list = parsed.tasks;
|
|
512
|
+
if (Array.isArray(list))
|
|
513
|
+
tasks = list.length;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
tasks = 0;
|
|
518
|
+
}
|
|
519
|
+
return { residue: processes + units, tasks, processes, units, detail };
|
|
520
|
+
}
|
|
521
|
+
/** 跑一个脚本(等它结束;带超时)。 */
|
|
522
|
+
function runScript(file, args, timeoutMs) {
|
|
523
|
+
return new Promise((resolve) => {
|
|
524
|
+
const child = spawn("bash", [file, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
525
|
+
let stdout = "";
|
|
526
|
+
let stderr = "";
|
|
527
|
+
let timedOut = false;
|
|
528
|
+
const timer = setTimeout(() => {
|
|
529
|
+
timedOut = true;
|
|
530
|
+
child.kill("SIGKILL");
|
|
531
|
+
}, timeoutMs);
|
|
532
|
+
timer.unref?.();
|
|
533
|
+
child.stdout?.on("data", (chunk) => {
|
|
534
|
+
stdout += chunk.toString();
|
|
535
|
+
});
|
|
536
|
+
child.stderr?.on("data", (chunk) => {
|
|
537
|
+
stderr += chunk.toString();
|
|
538
|
+
});
|
|
539
|
+
child.on("error", (err) => {
|
|
540
|
+
clearTimeout(timer);
|
|
541
|
+
resolve({ code: 127, stdout, stderr: `${stderr}\n${err instanceof Error ? err.message : String(err)}`, timedOut });
|
|
542
|
+
});
|
|
543
|
+
child.on("close", (code) => {
|
|
544
|
+
clearTimeout(timer);
|
|
545
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* 会话内 `assistant` 子命令的**统一入口**(命令面与模型工具面共用这一段 → 逐字一致)。
|
|
551
|
+
*
|
|
552
|
+
* @param action `on` / `off` / `status` / `restart`
|
|
553
|
+
*/
|
|
554
|
+
export async function handleAssistantCommand(action, opts = {}) {
|
|
555
|
+
switch (action) {
|
|
556
|
+
case "on":
|
|
557
|
+
return await requestAssistantInstall(opts);
|
|
558
|
+
case "restart":
|
|
559
|
+
return await requestAssistantInstall({ ...opts, action: "restart" });
|
|
560
|
+
case "off":
|
|
561
|
+
return await requestAssistantUninstall(opts);
|
|
562
|
+
case "status": {
|
|
563
|
+
const agentDir = opts.agentDir ?? resolveAgentDir();
|
|
564
|
+
return { text: formatAssistantStatus(readAssistantStatus(agentDir)), level: "info" };
|
|
565
|
+
}
|
|
566
|
+
default:
|
|
567
|
+
return {
|
|
568
|
+
text: "proactive: 用法:/proactive assistant on | off | status | restart",
|
|
569
|
+
level: "error",
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}
|
package/build/src/commands.js
CHANGED
|
@@ -6,6 +6,7 @@ import { formatHealthReport, healthToJson } from "./health.js";
|
|
|
6
6
|
import { formatLineageHistory } from "./lineage.js";
|
|
7
7
|
import { formatPeerDirectory, formatPeerList } from "./peer.js";
|
|
8
8
|
import { readProgressEta } from "./progress-eta.js";
|
|
9
|
+
import { handleAssistantCommand } from "./assistant.js";
|
|
9
10
|
import { Registry } from "./registry.js";
|
|
10
11
|
const DETECT_LABEL = {
|
|
11
12
|
terminal: "终端输入",
|
|
@@ -279,6 +280,7 @@ export const SUBCOMMANDS = "status | on | off | test | tasks | cancel <id> | idl
|
|
|
279
280
|
" | history [peer|me] [条数] | doctor [--json]" +
|
|
280
281
|
" | progress" +
|
|
281
282
|
" | tell <peer> <内容> [--kind ask|task|notify]" +
|
|
283
|
+
" | assistant on|off|status|restart" +
|
|
282
284
|
" | config [undo] [--json]";
|
|
283
285
|
/** `/proactive status` 末尾的指路行(MANUAL)。 */
|
|
284
286
|
export const HELP_POINTER = "指路:/proactive help 看命令表与最常见故障;详细文档见 <安装目录>/README.md(随包分发)。";
|
|
@@ -453,6 +455,17 @@ export async function dispatchProactiveCommand(args, ctx, opts = {}) {
|
|
|
453
455
|
}
|
|
454
456
|
return { text, level: "info" };
|
|
455
457
|
}
|
|
458
|
+
case "assistant": {
|
|
459
|
+
// 会话内常驻助理(on / off / status / restart)。
|
|
460
|
+
// **与模型工具面共用同一段实现**(`handleAssistantCommand`)→ 输出逐字一致。
|
|
461
|
+
// 安装**不阻塞**:立即回执「正在安装…」,结果由 `ctx.assistantDone` 异步回传 + 落盘可查。
|
|
462
|
+
const action = (parts[1] ?? "status").toLowerCase();
|
|
463
|
+
const outcome = await handleAssistantCommand(action, {
|
|
464
|
+
agentDir: ctx.agentDir ?? resolveAgentDir(),
|
|
465
|
+
...(ctx.assistantDone === undefined ? {} : { onDone: ctx.assistantDone }),
|
|
466
|
+
});
|
|
467
|
+
return { text: outcome.text, level: outcome.level };
|
|
468
|
+
}
|
|
456
469
|
case "spawn": {
|
|
457
470
|
const result = await ctx.spawnPeerSession(parts[1]);
|
|
458
471
|
if (!result.ok) {
|
package/build/src/config.js
CHANGED
|
@@ -410,6 +410,22 @@ function applyContentRefs(cfg, raw, dir) {
|
|
|
410
410
|
cfg.externalContent = resolved;
|
|
411
411
|
}
|
|
412
412
|
/** 全部配置项(顺序 = 清单展示顺序)。 */
|
|
413
|
+
/**
|
|
414
|
+
* daemon 配置面【专属】的顶层键:由 src/daemon-config.ts 解析与校验。
|
|
415
|
+
* 主配置不认识它们,但也不该把它们判成"未知项"(同一份 config.json,两个解析器,键集必须一致)。
|
|
416
|
+
*/
|
|
417
|
+
export const DAEMON_OWNED_KEYS = [
|
|
418
|
+
"version",
|
|
419
|
+
"enabled",
|
|
420
|
+
"agentId",
|
|
421
|
+
"dryRun",
|
|
422
|
+
"budget",
|
|
423
|
+
"permissions",
|
|
424
|
+
"tickIntervalMs",
|
|
425
|
+
"handoverHour",
|
|
426
|
+
"staleSocketMs",
|
|
427
|
+
"staleExecutionMs",
|
|
428
|
+
];
|
|
413
429
|
export const CONFIG_KEY_SPECS = [
|
|
414
430
|
{
|
|
415
431
|
path: "enabled",
|
|
@@ -754,6 +770,10 @@ export function validateConfig(raw, file) {
|
|
|
754
770
|
walkContentRefs(value, p, file, issues);
|
|
755
771
|
continue;
|
|
756
772
|
}
|
|
773
|
+
if (!p.includes(".") && DAEMON_OWNED_KEYS.includes(p)) {
|
|
774
|
+
// daemon 配置面自有键 → 主配置不校验、不报错(由 daemon-config.ts 负责)
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
757
777
|
const spec = byPath.get(p);
|
|
758
778
|
if (spec !== undefined) {
|
|
759
779
|
if (!matchesSpec(spec, value)) {
|
package/build/src/content.js
CHANGED
package/build/src/daemon-host.js
CHANGED
package/build/src/daemon.js
CHANGED
package/build/src/dedupe.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// 【S0 归属】L1 内核 · 宿主无关(契约:docs/project/kernel-contract-s0.md §6.1)
|
|
2
|
+
// → 不得 import 宿主包、不得出现宿主类型/字段;对外只经宿主无关接口(HostPort 一族)。
|
|
1
3
|
import fs from "node:fs";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { isKnownOnDiskVersion, onDiskVersionOf, proactiveDir, reportForeignVersion, resolveAgentDir, writeAtomicFile, } from "./config.js";
|
package/build/src/health.js
CHANGED