pi-proactive 0.2.0-beta.6 → 0.2.0-beta.7

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/app/app.mjs ADDED
@@ -0,0 +1,1378 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pi-proactive App —— **pi-agent 的 shim / wrapper**(最小可用外壳 · M1a)。
4
+ *
5
+ * 定位(docs/product/app-shell/APP-SPEC.md §0):App **不实现**任何 agent / 调度 / 主动性逻辑;
6
+ * 它只做三件事 —— 启动(shim)· 注入(wrapper)· 呈现(通知 / 只读面板)。
7
+ * 底下跑的就是**真 pi-agent 本体**(`pi --mode rpc --no-session -e <pi-proactive 包>`)。
8
+ *
9
+ * 六个域(与 APP-SPEC §1 一一对应):
10
+ * D1 注入 启动参数 = `pi --mode rpc --no-session -e <包路径>`;**可证明** = 进程内扩展清单含 pi-proactive。
11
+ * D2 承载 专属短路径 agent 根 `/tmp/pp-app-<pid>`(unix socket 路径上限 100B → 端点必须建得出来)。
12
+ * D3 驱动 JSONL 命令/响应**按 id 关联**(不是按顺序)。
13
+ * D4 呈现 系统通知(macOS osascript,通道可插拔、不可用时**明确降级**)+ 只读监督面板(复用 supervisor-web)。
14
+ * D5 生命周期 单实例锁 · 二次启动回执「已在运行」· 退出**零残留**(进程/socket/临时根/锁/日志)。
15
+ * D6 引导 ≤3 步、一次只问一件事、任何一步可「算了」且零残留 + **三问话术**(它是谁/在干什么/怎么让它做事)。
16
+ *
17
+ * 硬纪律:
18
+ * - 零第三方依赖(只用 Node 内置);纯 .mjs(npm 包内不得有 .ts)。
19
+ * - **状态等待代替固定等待**:等扩展真的注册了才叫就绪;超时 ⇒ 报【未就绪】+ 诊断(禁止 sleep 后假装成功)。
20
+ * - **单次注入**:只用 `-e`,不写用户 settings.json(不污染用户环境)。
21
+ * - **不写凭据**:沿用宿主 pi 的认证(共享模式)或环境里的 API key;App 自己不存任何凭据。
22
+ * - **不碰用户既有项目**:只读监督面;写操作留 pi-agent。
23
+ *
24
+ * 用法:`node app/app.mjs <start|stop|status|describe|guide|ask> [选项]`
25
+ */
26
+ import { spawn, spawnSync } from "node:child_process";
27
+ import { createHash } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
+
33
+ const here = path.dirname(fileURLToPath(import.meta.url));
34
+
35
+ /** 包根:`<pkg>/app` → `<pkg>`(npm 安装后 = 已安装的 pi-proactive 包)。 */
36
+ export const DEFAULT_PKG_ROOT = path.resolve(here, "..");
37
+
38
+ /** unix socket 路径上限(字节)—— 与 pi-proactive/src/socket.ts 的 MAX_SOCKET_PATH_BYTES 同口径。 */
39
+ export const MAX_SOCKET_PATH_BYTES = 100;
40
+
41
+ /** App 实例的默认 agentId(短 → 端点路径短且可预测)。 */
42
+ export const APP_AGENT_ID = "app";
43
+
44
+ /** 端点落在 `<agentRoot>/proactive/run/`(与 src/socket.ts 的布局一致)。 */
45
+ const RUN_SUBDIR = path.join("proactive", "run");
46
+
47
+ /** 通知记录(证据)落在 `<agentRoot>/proactive/app/`。 */
48
+ const APP_SUBDIR = path.join("proactive", "app");
49
+
50
+ const MISSING_PI_MESSAGE =
51
+ "未找到 pi 可执行文件(--pi-bin 未给、PI_BIN 未设、PATH 里也没有 pi)。\n" +
52
+ "安装指引:npm i -g @earendil-works/pi-coding-agent\n" +
53
+ "或用 --pi-bin /绝对路径/pi(或环境变量 PI_BIN=/绝对路径/pi)指定已安装的 pi。";
54
+
55
+ // ------------------------------------------------------------------ 小工具
56
+
57
+ function errText(err) {
58
+ return err instanceof Error ? err.message : String(err);
59
+ }
60
+
61
+ /**
62
+ * 进程是否存活。
63
+ * EPERM = 活着但不属于当前用户;僵尸(Z)= 已退出、只是等父进程收尸 → 不算活着。
64
+ */
65
+ function isAlive(pid) {
66
+ if (!pid || pid <= 0) return false;
67
+ try {
68
+ process.kill(pid, 0);
69
+ } catch (err) {
70
+ return Boolean(err && err.code === "EPERM");
71
+ }
72
+ try {
73
+ const out = spawnSync("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf8" });
74
+ if ((out.stdout || "").trim().startsWith("Z")) return false;
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /** 可执行文件判定(存在 + 是文件 + 有执行位)。 */
82
+ function isExecutableFile(p) {
83
+ try {
84
+ const st = fs.statSync(p);
85
+ if (!st.isFile()) return false;
86
+ fs.accessSync(p, fs.constants.X_OK);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /** 短路径临时根:优先 /tmp(macOS 的 os.tmpdir() 很长,会吃掉 socket 路径预算)。 */
94
+ function defaultTmpDir() {
95
+ try {
96
+ if (fs.statSync("/tmp").isDirectory()) return "/tmp";
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ return os.tmpdir();
101
+ }
102
+
103
+ function uid() {
104
+ return typeof process.getuid === "function" ? process.getuid() : 0;
105
+ }
106
+
107
+ /** agentId → 文件名安全化(与扩展 sanitizeSocketFileName 同口径:`/` `\` `#` 空白 → `-`)。 */
108
+ function sanitizeName(name) {
109
+ return String(name).replace(/[/\\#\s]/g, "-") || "unknown";
110
+ }
111
+
112
+ /**
113
+ * 端点路径(与 src/socket.ts 的 socketPathFor **同规则**):
114
+ * 常规名 `<sanitized(agentId)>.sock`;完整路径超 100B → 降级为 `h-<sha256 前 16 位>.sock`。
115
+ * 判定只依赖 (agentId, agentDir) → 与扩展服务端推出同一个路径。
116
+ */
117
+ function socketPathFor(agentId, agentRoot) {
118
+ const runDir = path.join(agentRoot, RUN_SUBDIR);
119
+ const natural = path.join(runDir, `${sanitizeName(agentId)}.sock`);
120
+ if (Buffer.byteLength(natural, "utf8") <= MAX_SOCKET_PATH_BYTES) return natural;
121
+ const hashed = `h-${createHash("sha256").update(String(agentId), "utf8").digest("hex").slice(0, 16)}.sock`;
122
+ return path.join(runDir, hashed);
123
+ }
124
+
125
+ // ------------------------------------------------------------------ D2 承载
126
+
127
+ /**
128
+ * 算出本次实例的「承载面」:agent 根 / 端点 / 日志 / 锁。
129
+ * - dedicated(默认):`/tmp/pp-app-<pid>`(**专属短路径**)→ 我们拥有它 → 退出可整体删除;
130
+ * - shared:沿用宿主 pi 的 agent 根(PI_CODING_AGENT_DIR 或 ~/.pi/agent)→ **我们不拥有** → 只删自己的产物;
131
+ * - 显式 agentDir:覆盖根目录,同样**不拥有**。
132
+ */
133
+ export function resolveInstance(opts = {}) {
134
+ const env = opts.env || process.env;
135
+ const pid = opts.pid || process.pid;
136
+ const agentRootKind = opts.agentRootKind === "shared" ? "shared" : "dedicated";
137
+ const explicit = opts.agentDir ? String(opts.agentDir) : "";
138
+ const tmpDir = opts.tmpDir || defaultTmpDir();
139
+ const dedicatedRoot = path.join(tmpDir, `pp-app-${pid}`);
140
+ const sharedRoot = env.PI_CODING_AGENT_DIR ? env.PI_CODING_AGENT_DIR : path.join(os.homedir(), ".pi", "agent");
141
+ const agentRoot = explicit || (agentRootKind === "shared" ? sharedRoot : dedicatedRoot);
142
+ return composeInstance({
143
+ agentRootKind,
144
+ agentRoot,
145
+ ownsAgentRoot: agentRootKind !== "shared" && explicit === "",
146
+ agentId: env.PI_PROACTIVE_AGENT_ID ? env.PI_PROACTIVE_AGENT_ID : APP_AGENT_ID,
147
+ logPath: path.join(tmpDir, `pp-app-${pid}.log`),
148
+ // 锁与实例无关(跨实例才能检出「已在运行」)→ 按 uid 隔离,避免跨用户互相挡。
149
+ lockPath: path.join(tmpDir, `pp-app-${uid()}.lock`),
150
+ pkgRoot: opts.pkgRoot || env.PI_PROACTIVE_PKG_ROOT || DEFAULT_PKG_ROOT,
151
+ });
152
+ }
153
+
154
+ /** 由原始字段算出完整实例描述(端点路径与长度预算统一在这里算)。 */
155
+ function composeInstance(raw) {
156
+ const socketPath = socketPathFor(raw.agentId, raw.agentRoot);
157
+ const socketPathBytes = Buffer.byteLength(socketPath, "utf8");
158
+ return {
159
+ agentRootKind: raw.agentRootKind,
160
+ dedicated: raw.agentRootKind === "dedicated",
161
+ ownsAgentRoot: Boolean(raw.ownsAgentRoot),
162
+ agentRoot: raw.agentRoot,
163
+ agentId: raw.agentId,
164
+ socketPath,
165
+ socketPathBytes,
166
+ // 如实标记:超长 = 端点建不出来 → 后续检查必须判「未触发」,不得判「不残留」。
167
+ socketOverBudget: socketPathBytes > MAX_SOCKET_PATH_BYTES,
168
+ logPath: raw.logPath,
169
+ lockPath: raw.lockPath,
170
+ pkgRoot: raw.pkgRoot,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * 锁文件是「现在跑着的是哪个实例」的**唯一真源**:
176
+ * stop / status 必须按锁里记的路径清理与汇报 —— 否则会拿自己的 pid 去清别人的实例(清不到 = 残留)。
177
+ */
178
+ function instanceFromLock(lock, fallback) {
179
+ if (!lock) return fallback;
180
+ return composeInstance({
181
+ agentRootKind: lock.agentRootKind || fallback.agentRootKind,
182
+ agentRoot: lock.agentRoot || fallback.agentRoot,
183
+ ownsAgentRoot: lock.ownsAgentRoot === undefined ? fallback.ownsAgentRoot : Boolean(lock.ownsAgentRoot),
184
+ agentId: lock.agentId || fallback.agentId,
185
+ logPath: lock.logPath || fallback.logPath,
186
+ lockPath: fallback.lockPath,
187
+ pkgRoot: lock.pkgRoot || fallback.pkgRoot,
188
+ });
189
+ }
190
+
191
+ /** 找真实存在的端点(run/*.sock);没有则返回 null(= **未触发**,不是「不残留」)。 */
192
+ export function findEndpoint(agentRoot) {
193
+ const dir = path.join(agentRoot, RUN_SUBDIR);
194
+ let names;
195
+ try {
196
+ names = fs.readdirSync(dir);
197
+ } catch {
198
+ return null;
199
+ }
200
+ const sock = names.filter((n) => n.endsWith(".sock")).sort()[0];
201
+ if (!sock) return null;
202
+ const p = path.join(dir, sock);
203
+ return { path: p, bytes: Buffer.byteLength(p, "utf8") };
204
+ }
205
+
206
+ /** 本机是否有我们留下的 launchd 单元(本 App 不装单元 —— 检查即证明)。 */
207
+ function listLaunchdUnits() {
208
+ const dir = path.join(os.homedir(), "Library", "LaunchAgents");
209
+ try {
210
+ return fs
211
+ .readdirSync(dir)
212
+ .filter((n) => n.startsWith("pp-app") && n.endsWith(".plist"))
213
+ .sort();
214
+ } catch {
215
+ return [];
216
+ }
217
+ }
218
+
219
+ /** 残留终检(进程 / 端点 / 目录 / 锁 / 日志 / launchd)。 */
220
+ /** 残留终检(进程 / 端点 / 目录 / 锁 / 日志 / launchd)。 */
221
+ export function residueReport(instance, lock) {
222
+ const endpoint = findEndpoint(instance.agentRoot);
223
+ return {
224
+ // 「残留」= 停完之后**还在外面跑着**的进程;本进程自己不算残留。
225
+ appProcessAlive: isAlive(lock && lock.pid) && (!lock || lock.pid !== process.pid),
226
+ piProcessAlive: isAlive(lock && lock.piPid),
227
+ socketExists: fs.existsSync(instance.socketPath),
228
+ endpointExists: endpoint !== null,
229
+ agentRootExists: fs.existsSync(instance.agentRoot),
230
+ lockExists: fs.existsSync(instance.lockPath),
231
+ logExists: fs.existsSync(instance.logPath),
232
+ launchdUnits: listLaunchdUnits(),
233
+ };
234
+ }
235
+
236
+ // ------------------------------------------------------------------ D1 注入
237
+
238
+ /**
239
+ * 定位 pi 可执行文件:--pi-bin > PI_BIN > PATH。
240
+ * 找不到 → 抛错并给安装指引(**不静默降级、不假装在跑**)。
241
+ */
242
+ export function resolvePiBin(opts = {}) {
243
+ const env = opts.env || process.env;
244
+ const explicit = opts.piBin ? String(opts.piBin) : "";
245
+ const fromEnv = env.PI_BIN ? String(env.PI_BIN) : "";
246
+ for (const cand of [
247
+ { bin: explicit, source: "arg" },
248
+ { bin: fromEnv, source: "env" },
249
+ ]) {
250
+ if (!cand.bin) continue;
251
+ if (!isExecutableFile(cand.bin)) {
252
+ throw new Error(
253
+ `指定的 pi 可执行文件不可用:${cand.bin}(来源:${cand.source === "arg" ? "--pi-bin" : "PI_BIN"})—— ` +
254
+ "请给出真实存在且可执行的绝对路径",
255
+ );
256
+ }
257
+ return { bin: cand.bin, source: cand.source };
258
+ }
259
+ for (const dir of (env.PATH || "").split(path.delimiter)) {
260
+ if (!dir) continue;
261
+ const cand = path.join(dir, "pi");
262
+ if (isExecutableFile(cand)) return { bin: cand, source: "path" };
263
+ }
264
+ throw new Error(MISSING_PI_MESSAGE);
265
+ }
266
+
267
+ /**
268
+ * 启动参数:`pi --mode rpc --no-session -e <pi-proactive 包路径>`。
269
+ * - `--mode rpc`:JSONL 命令/响应/事件;
270
+ * - `--no-session`:不落会话文件;
271
+ * - `-e/--extension`:pi 官方的「单次调用试一个包」→ 天然幂等、**不写用户 settings.json**。
272
+ */
273
+ export function buildLaunchArgs(opts = {}) {
274
+ const pkgRoot = opts.pkgRoot || DEFAULT_PKG_ROOT;
275
+ return ["--mode", "rpc", "--no-session", "-e", pkgRoot];
276
+ }
277
+
278
+ /** 子进程环境:把 agent 根与身份显式交给扩展(**不写任何凭据文件**)。 */
279
+ function buildChildEnv(opts = {}) {
280
+ const env = opts.env || process.env;
281
+ const instance = opts.instance;
282
+ return {
283
+ ...env,
284
+ PI_CODING_AGENT_DIR: instance.agentRoot,
285
+ PI_PROACTIVE_AGENT_ID: instance.agentId,
286
+ // App 下的会话以【协调者】角色运行(DESIGN §9 G-4)。
287
+ PI_PROACTIVE_ROLE: "coordinator",
288
+ };
289
+ }
290
+
291
+ /**
292
+ * 凭据可用性(**如实**判定,不静默):
293
+ * 共享模式沿用宿主 pi 的 auth.json;专属模式下只能靠环境里的 API key。
294
+ * 都没有 → 明说「能看不能做」。
295
+ */
296
+ /**
297
+ * 平台支持判定(**macOS 优先**;Windows 分支留给后续,但**不得**写成「应该能行」)。
298
+ * 未实现的平台 ⇒ 明确报错,不假装能跑。
299
+ */
300
+ export function platformSupport(platform = process.platform) {
301
+ if (platform === "win32") {
302
+ return {
303
+ supported: false,
304
+ reason:
305
+ "本 App 目前只实现了 macOS / Linux(unix domain socket + 系统通知通道);" +
306
+ "Windows 分支**尚未实现**(端点与通道的落位方式都不同)→ 明确报错,不假装能跑。",
307
+ };
308
+ }
309
+ return { supported: true, reason: "" };
310
+ }
311
+
312
+ /**
313
+ * 凭据可用性(**如实**判定,不静默):
314
+ * 共享模式沿用宿主 pi 的 auth.json;专属模式下只能靠环境里的 API key。
315
+ * 都没有 → 明说「能看不能做」。
316
+ */
317
+ export function detectCredential(agentRoot, env = process.env) {
318
+ const authFile = path.join(agentRoot, "auth.json");
319
+ try {
320
+ const raw = JSON.parse(fs.readFileSync(authFile, "utf8"));
321
+ const providers = Object.entries(raw || {}).filter(
322
+ ([, v]) => v && typeof v === "object" && Object.keys(v).length > 0,
323
+ );
324
+ if (providers.length > 0) {
325
+ return {
326
+ ok: true,
327
+ source: "auth.json",
328
+ detail: `已找到模型凭据(${authFile}:${providers.map(([k]) => k).join("、")})`,
329
+ };
330
+ }
331
+ // pi 会在**新建**的 agent 根写一个空的 `{}` → 那不是凭据(不得据此说「能做事」)。
332
+ } catch {
333
+ /* 没有或读不了 → 继续看环境变量 */
334
+ }
335
+ for (const [key, value] of Object.entries(env || {})) {
336
+ if (key.endsWith("_API_KEY") && typeof value === "string" && value.trim() !== "") {
337
+ return { ok: true, source: "env", detail: `已找到模型凭据(环境变量 ${key})` };
338
+ }
339
+ }
340
+ return {
341
+ ok: false,
342
+ source: "none",
343
+ detail: "这个环境里没有模型凭据(既没有登录文件,也没有 API key 环境变量)→ 我现在只能看、不能做事",
344
+ };
345
+ }
346
+
347
+ // ------------------------------------------------------------------ D3 驱动(JSONL)
348
+
349
+ /**
350
+ * pi 子进程的 JSONL 会话:命令按 **id** 关联响应(不是按顺序)。
351
+ * 事件(extension_ui_request 等)走监听器。
352
+ */
353
+ export function createRpcSession(child) {
354
+ const pending = new Map();
355
+ const listeners = [];
356
+ let buf = "";
357
+ let seq = 0;
358
+
359
+ const onData = (chunk) => {
360
+ buf += String(chunk);
361
+ let idx;
362
+ while ((idx = buf.indexOf("\n")) >= 0) {
363
+ const line = buf.slice(0, idx).replace(/\r$/, "");
364
+ buf = buf.slice(idx + 1);
365
+ if (!line.trim()) continue;
366
+ let rec;
367
+ try {
368
+ rec = JSON.parse(line);
369
+ } catch {
370
+ continue;
371
+ }
372
+ if (rec && rec.type === "response" && rec.id !== undefined && pending.has(rec.id)) {
373
+ const resolve = pending.get(rec.id);
374
+ pending.delete(rec.id);
375
+ resolve(rec);
376
+ continue;
377
+ }
378
+ for (const cb of listeners) {
379
+ try {
380
+ cb(rec);
381
+ } catch {
382
+ /* 监听器出错不得影响协议 */
383
+ }
384
+ }
385
+ }
386
+ };
387
+ child.stdout.on("data", onData);
388
+
389
+ return {
390
+ /** 发一条命令并等它的响应(超时 ⇒ reject,绝不无限等)。 */
391
+ request(command, timeoutMs = 10_000) {
392
+ const id = command.id || `pp-${Date.now()}-${++seq}`;
393
+ const full = { ...command, id };
394
+ return new Promise((resolve, reject) => {
395
+ const timer = setTimeout(() => {
396
+ pending.delete(id);
397
+ reject(new Error(`RPC ${command.type} 超时(${timeoutMs}ms)`));
398
+ }, timeoutMs);
399
+ pending.set(id, (rec) => {
400
+ clearTimeout(timer);
401
+ resolve(rec);
402
+ });
403
+ child.stdin.write(`${JSON.stringify(full)}\n`, (err) => {
404
+ if (err) {
405
+ clearTimeout(timer);
406
+ pending.delete(id);
407
+ reject(err);
408
+ }
409
+ });
410
+ });
411
+ },
412
+ /** 订阅非响应记录(事件 / extension_ui_request)。 */
413
+ onRecord(cb) {
414
+ listeners.push(cb);
415
+ return () => {
416
+ const i = listeners.indexOf(cb);
417
+ if (i >= 0) listeners.splice(i, 1);
418
+ };
419
+ },
420
+ /** 发送一条命令(不等响应)。 */
421
+ send(command) {
422
+ const full = { ...command, id: command.id || `pp-${Date.now()}-${++seq}` };
423
+ child.stdin.write(`${JSON.stringify(full)}\n`);
424
+ return full.id;
425
+ },
426
+ };
427
+ }
428
+
429
+ // ------------------------------------------------------------------ 单实例锁
430
+
431
+ function readLock(lockPath) {
432
+ try {
433
+ const obj = JSON.parse(fs.readFileSync(lockPath, "utf8"));
434
+ return obj && typeof obj === "object" ? obj : null;
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /** 独占创建锁文件(EEXIST = 已经有实例占位)。 */
441
+ function createLock(lockPath, payload) {
442
+ fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2), { flag: "wx", mode: 0o600 });
443
+ }
444
+
445
+ /** 覆盖锁内容(我们已持有它)→ 临时文件 + rename(原子替换)。 */
446
+ function updateLock(lockPath, payload) {
447
+ const tmp = `${lockPath}.${process.pid}.tmp`;
448
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
449
+ fs.renameSync(tmp, lockPath);
450
+ }
451
+
452
+ function removeLock(lockPath) {
453
+ try {
454
+ fs.unlinkSync(lockPath);
455
+ } catch {
456
+ /* ignore */
457
+ }
458
+ }
459
+
460
+ // ------------------------------------------------------------------ 清理 / 残留
461
+
462
+ /** 删掉本次实例自己的产物(共享模式下**不**删用户的 agent 根)。 */
463
+ function cleanupArtifacts(instance, lock) {
464
+ const agentId = (lock && lock.agentId) || instance.agentId;
465
+ try {
466
+ fs.unlinkSync(path.join(instance.agentRoot, RUN_SUBDIR, `${sanitizeName(agentId)}.sock`));
467
+ } catch {
468
+ /* ignore */
469
+ }
470
+ try {
471
+ fs.unlinkSync(path.join(instance.agentRoot, "proactive", "agents", `${sanitizeName(agentId)}.json`));
472
+ } catch {
473
+ /* ignore */
474
+ }
475
+ if (instance.ownsAgentRoot) {
476
+ try {
477
+ fs.rmSync(instance.agentRoot, { recursive: true, force: true });
478
+ } catch {
479
+ /* ignore */
480
+ }
481
+ }
482
+ try {
483
+ fs.unlinkSync(instance.logPath);
484
+ } catch {
485
+ /* ignore */
486
+ }
487
+ }
488
+
489
+ // ------------------------------------------------------------------ D4 呈现(复用既有资产)
490
+
491
+ /**
492
+ * 加载包内模块(**产物态 build/src/*.js 优先,源码态 src/*.ts 兜底**)。
493
+ * 与 tools/cli.mjs 的 loadSupervisorModule 同一套路:动态 import 的 specifier 是绝对 file:// URL
494
+ * (.mjs 不能写 .ts 后缀的相对导入 —— 门禁 G3 也是这个口径)。
495
+ */
496
+ export async function loadPiModule(base, pkgRoot = DEFAULT_PKG_ROOT) {
497
+ const candidates = [path.join(pkgRoot, "build", "src", `${base}.js`), path.join(pkgRoot, "src", `${base}.ts`)];
498
+ for (const file of candidates) {
499
+ if (!fs.existsSync(file)) continue;
500
+ return await import(pathToFileURL(file).href);
501
+ }
502
+ throw new Error(`包内缺少模块 ${base}(期望 build/src/${base}.js 或 src/${base}.ts)—— 请重新安装 pi-proactive`);
503
+ }
504
+
505
+ /**
506
+ * 设备级通知通道(**可插拔**):复用 src/notify.ts 的 macOS osascript 通道与四段载荷。
507
+ * 不可用时**明确降级**(不静默):返回 available=false + 原因。
508
+ */
509
+ export async function createNotifier(opts = {}) {
510
+ const notify = await loadPiModule("notify", opts.pkgRoot);
511
+ const channel = notify.createMacChannel({
512
+ ...(opts.exec ? { exec: opts.exec } : {}),
513
+ ...(opts.available ? { available: opts.available } : {}),
514
+ });
515
+ return {
516
+ channel: channel.id,
517
+ available: (() => {
518
+ try {
519
+ return channel.isAvailable();
520
+ } catch {
521
+ return false;
522
+ }
523
+ })(),
524
+ /** 发一条通知(绝不抛错:通知失败不得让 App 崩)。 */
525
+ async send(text, meta = {}) {
526
+ const payload = notify.buildContactPayload({
527
+ why: meta.why ?? "pi 助理有事要告诉你",
528
+ ask: String(text),
529
+ consequence: meta.consequence ?? "先不用你做什么也可以",
530
+ howToDisable: meta.howToDisable ?? "在 Pi 会话里说「别烦我」",
531
+ ...(meta.title ? { title: meta.title } : {}),
532
+ });
533
+ try {
534
+ const res = await channel.send(payload);
535
+ return { ok: Boolean(res.ok), detail: res.detail, payload };
536
+ } catch (err) {
537
+ return { ok: false, detail: errText(err), payload };
538
+ }
539
+ },
540
+ };
541
+ }
542
+
543
+ /** 只读监督面板(复用 src/supervisor-web.ts:只监听 127.0.0.1、只读、单一数据源)。 */
544
+ export async function startPanel(opts = {}) {
545
+ const web = await loadPiModule("supervisor-web", opts.pkgRoot);
546
+ const handle = await web.startSupervisorWeb({
547
+ agentDir: opts.agentDir,
548
+ port: opts.port ?? 0,
549
+ ...(opts.agentId ? { agentId: opts.agentId } : {}),
550
+ });
551
+ return { url: handle.url, port: handle.port, close: () => handle.close() };
552
+ }
553
+
554
+ // ------------------------------------------------------------------ D6 引导与可发现性
555
+
556
+ /**
557
+ * 三问话术(**D2 判据**:App 启动后能一句话回答「它是谁 / 在干什么 / 怎么让它做事」)。
558
+ * 约束:零必读概念(不出现 agent-dir / 预算 / 宿主 / 端口 …),人话,一句一答。
559
+ * 文本**逐字写在产物里** → 第三方可直接读到(不依赖运行时的隐藏上下文)。
560
+ */
561
+ export function describeApp() {
562
+ return {
563
+ who: "我是 pi 助理:一个住在你这台机器上的帮手。你不用一直守着它——有事它会主动来找你。",
564
+ what: "它在做三件事:记住你交给它的任务、盯着该发生的变化、到点或者达成时告诉你。",
565
+ how: "直接对它说人话就行,比如「盯着 CI 变绿」「有人找我时告诉我」「别烦我」。要它真的替你做事,这个环境里得有模型凭据;没有的话它会明确告诉你。",
566
+ };
567
+ }
568
+
569
+ /**
570
+ * 首启引导(≤3 步 · 一次只问一件事 · 每步都能「算了」· 零必读概念)。
571
+ * 「算了」= 收工退出,且不留任何残留。
572
+ */
573
+ export function guideSteps() {
574
+ return [
575
+ { id: "stay", question: "要我以后常驻吗?", options: ["要", "算了"], cancel: "算了" },
576
+ { id: "watch", question: "要我盯着什么吗?说一句话就行。", options: ["说一句话", "算了"], cancel: "算了" },
577
+ { id: "ping", question: "有事我这样找你行吗?(系统通知)", options: ["行", "算了"], cancel: "算了" },
578
+ ];
579
+ }
580
+
581
+ // ------------------------------------------------------------------ 回执
582
+
583
+ function baseReceipt(instance) {
584
+ return {
585
+ status: "not-running",
586
+ agentRootKind: instance.agentRootKind,
587
+ agentRoot: instance.agentRoot,
588
+ agentId: instance.agentId,
589
+ socketPath: instance.socketPath,
590
+ socketPathBytes: instance.socketPathBytes,
591
+ socketOverBudget: instance.socketOverBudget,
592
+ lockPath: instance.lockPath,
593
+ logPath: instance.logPath,
594
+ pkgRoot: instance.pkgRoot,
595
+ };
596
+ }
597
+
598
+ // ------------------------------------------------------------------ 运行中的会话(进程内)
599
+
600
+ /** 本进程内已启动的会话(按锁路径索引)→ `ask` 等命令可以复用同一份 JSONL 连接。 */
601
+ const sessions = new Map();
602
+
603
+ /** 通知去重集合(按事件 id)→ 同一事件不弹两次。 */
604
+ const notifiedIds = new Set();
605
+
606
+ // ------------------------------------------------------------------ 启动 / 停止 / 状态
607
+
608
+ /**
609
+ * 启动 App:拉起 pi → **等就绪信号**(不是 sleep)→ 拉起只读面板 → 注册通知通道 → 回执。
610
+ *
611
+ * 就绪判定(两道,都要真证据):
612
+ * ① AC1:RPC `get_commands` 的**进程内**扩展清单里有来自本包的扩展入口;
613
+ * ② AC2:`<agentRoot>/proactive/run/*.sock` 真实出现。
614
+ * 任一不满足 → **明确失败**(报【未就绪】+ 诊断),不静默降级、不假装在跑。
615
+ */
616
+ export async function startApp(opts = {}) {
617
+ const instance = resolveInstance(opts);
618
+ const base = baseReceipt(instance);
619
+ const timeoutMs = opts.timeoutMs || 30_000;
620
+
621
+ // --- 平台:macOS / Linux 优先(unix socket + osascript/notify-send);Windows 分支**未实现**
622
+ // → 明确报错,不写「应该能行」。
623
+ const platform = platformSupport();
624
+ if (!platform.supported) {
625
+ return { ...base, status: "error", error: platform.reason };
626
+ }
627
+ // --- 单实例锁:已有活实例 → 幂等回执「已在运行」(不产生第二份实例/第二份通知注册)
628
+ const existing = readLock(instance.lockPath);
629
+ if (existing) {
630
+ if (isAlive(existing.pid)) {
631
+ const running = instanceFromLock(existing, instance);
632
+ return {
633
+ ...baseReceipt(running),
634
+ status: "already-running",
635
+ pid: existing.pid,
636
+ piPid: existing.piPid,
637
+ piBin: existing.piBin,
638
+ piBinSource: existing.piBinSource,
639
+ launchArgs: existing.launchArgs,
640
+ panelUrl: existing.panelUrl,
641
+ evidence: existing.evidence,
642
+ residue: residueReport(running, existing),
643
+ };
644
+ }
645
+ // 陈旧锁(进程已死)→ 清掉它和它的残留,再来一次
646
+ cleanupArtifacts(instanceFromLock(existing, instance), existing);
647
+ removeLock(instance.lockPath);
648
+ }
649
+
650
+ // --- 定位 pi(找不到 → 明确报错 + 安装指引)
651
+ let pi;
652
+ try {
653
+ pi = resolvePiBin({ piBin: opts.piBin, env: opts.env });
654
+ } catch (err) {
655
+ return { ...base, status: "error", error: errText(err) };
656
+ }
657
+
658
+ // --- 占锁(独占):抢不到 = 别的实例刚起来 → 幂等回执
659
+ try {
660
+ createLock(instance.lockPath, {
661
+ pid: process.pid,
662
+ piPid: 0,
663
+ agentRootKind: instance.agentRootKind,
664
+ agentRoot: instance.agentRoot,
665
+ ownsAgentRoot: instance.ownsAgentRoot,
666
+ agentId: instance.agentId,
667
+ logPath: instance.logPath,
668
+ pkgRoot: instance.pkgRoot,
669
+ piBin: pi.bin,
670
+ piBinSource: pi.source,
671
+ launchArgs: buildLaunchArgs({ pkgRoot: instance.pkgRoot }),
672
+ startedAt: Date.now(),
673
+ });
674
+ } catch (err) {
675
+ if (err && err.code === "EEXIST") {
676
+ const lock = readLock(instance.lockPath);
677
+ if (lock && isAlive(lock.pid)) {
678
+ const running = instanceFromLock(lock, instance);
679
+ return {
680
+ ...baseReceipt(running),
681
+ status: "already-running",
682
+ pid: lock.pid,
683
+ piPid: lock.piPid,
684
+ panelUrl: lock.panelUrl,
685
+ evidence: lock.evidence,
686
+ residue: residueReport(running, lock),
687
+ };
688
+ }
689
+ return { ...base, status: "error", error: `锁文件存在但无法判定归属:${instance.lockPath}` };
690
+ }
691
+ return { ...base, status: "error", error: `写锁失败:${errText(err)}` };
692
+ }
693
+
694
+ // --- 拉起 pi
695
+ let child = null;
696
+ let stderrTail = "";
697
+ try {
698
+ fs.mkdirSync(instance.agentRoot, { recursive: true, mode: 0o700 });
699
+ const logFd = fs.openSync(instance.logPath, "a", 0o600);
700
+ try {
701
+ child = spawn(pi.bin, buildLaunchArgs({ pkgRoot: instance.pkgRoot }), {
702
+ env: buildChildEnv({ instance, env: opts.env }),
703
+ stdio: ["pipe", "pipe", "pipe"],
704
+ });
705
+ } finally {
706
+ fs.closeSync(logFd);
707
+ }
708
+ } catch (err) {
709
+ removeLock(instance.lockPath);
710
+ return { ...base, status: "error", error: `启动 pi 失败:${errText(err)}` };
711
+ }
712
+ child.stderr.on("data", (c) => {
713
+ stderrTail = (stderrTail + String(c)).slice(-2000);
714
+ try {
715
+ fs.appendFileSync(instance.logPath, String(c));
716
+ } catch {
717
+ /* ignore */
718
+ }
719
+ });
720
+
721
+ const session = createRpcSession(child);
722
+ const exited = new Promise((resolve) => child.on("exit", (code) => resolve(code)));
723
+ let exitInfo = null;
724
+ void exited.then((code) => {
725
+ exitInfo = { code };
726
+ });
727
+
728
+ const lockPayload = {
729
+ ...(readLock(instance.lockPath) || {}),
730
+ piPid: child.pid,
731
+ piBin: pi.bin,
732
+ piBinSource: pi.source,
733
+ launchArgs: buildLaunchArgs({ pkgRoot: instance.pkgRoot }),
734
+ };
735
+ try {
736
+ updateLock(instance.lockPath, lockPayload);
737
+ } catch {
738
+ /* ignore */
739
+ }
740
+
741
+ // --- 就绪判定(状态等待,超时 ⇒ 报【未就绪】+ 诊断)
742
+ const ready = await waitReady(session, instance, timeoutMs, () => exitInfo, () => stderrTail);
743
+ if (!ready.ok) {
744
+ killChild(child);
745
+ cleanupArtifacts(instance, { agentId: instance.agentId });
746
+ removeLock(instance.lockPath);
747
+ return { ...base, status: "error", error: `未就绪:${ready.reason}`, evidence: ready.evidence };
748
+ }
749
+
750
+ // --- 通知通道(可插拔;不可用 ⇒ 明确降级提示,面板仍在)
751
+ let notifier = null;
752
+ let notifyDegraded = "";
753
+ try {
754
+ notifier = await createNotifier({
755
+ pkgRoot: instance.pkgRoot,
756
+ ...(opts.notifyExec ? { exec: opts.notifyExec } : {}),
757
+ ...(opts.notifyAvailable ? { available: opts.notifyAvailable } : {}),
758
+ });
759
+ if (!notifier.available) {
760
+ notifyDegraded = `系统通知不可用(通道 ${notifier.channel} 在这台机器上不可用)→ 面板仍在,通知改在会话里给你`;
761
+ }
762
+ } catch (err) {
763
+ notifyDegraded = `系统通知通道初始化失败:${errText(err)} → 面板仍在`;
764
+ }
765
+
766
+ // --- 通知记录(证据)+ 去重
767
+ const notificationRecordPath = path.join(instance.agentRoot, APP_SUBDIR, "notifications.jsonl");
768
+ const notificationState = { count: 0, last: null };
769
+ const unsubscribe = session.onRecord((rec) => {
770
+ if (!rec || rec.type !== "extension_ui_request" || rec.method !== "notify") return;
771
+ const eventId = String(rec.id || rec.message || "");
772
+ if (eventId && notifiedIds.has(eventId)) return; // 同一事件不弹两次
773
+ if (eventId) notifiedIds.add(eventId);
774
+ const message = String(rec.message ?? "");
775
+ const done = notifier
776
+ ? notifier.send(message, { title: rec.notifyType === "error" ? "助理出错了" : "助理找你有事" })
777
+ : Promise.resolve({ ok: false, detail: "无通知通道" });
778
+ void done.then((res) => {
779
+ notificationState.count += 1;
780
+ notificationState.last = { eventId, message, ok: Boolean(res.ok), detail: res.detail };
781
+ try {
782
+ fs.mkdirSync(path.dirname(notificationRecordPath), { recursive: true });
783
+ fs.appendFileSync(
784
+ notificationRecordPath,
785
+ `${JSON.stringify({ ts: Date.now(), eventId, message, ok: Boolean(res.ok), detail: res.detail })}\n`,
786
+ );
787
+ } catch {
788
+ /* 记录失败不得影响主流程 */
789
+ }
790
+ if (opts.onNotify) opts.onNotify(notificationState.last);
791
+ });
792
+ });
793
+
794
+ // --- 只读监督面板(可失败降级,不阻塞主流程)
795
+ let panel = null;
796
+ let panelDegraded = "";
797
+ try {
798
+ panel = await startPanel({
799
+ pkgRoot: instance.pkgRoot,
800
+ agentDir: instance.agentRoot,
801
+ port: opts.panelPort ?? 0,
802
+ agentId: instance.agentId,
803
+ });
804
+ } catch (err) {
805
+ panelDegraded = `监督面板起不来:${errText(err)} → 其余功能不受影响`;
806
+ }
807
+
808
+ // --- 凭据如实判定(不静默)
809
+ const credential = detectCredential(instance.agentRoot, opts.env || process.env);
810
+
811
+ // --- 写锁(供退出清理与幂等判定)
812
+ try {
813
+ updateLock(instance.lockPath, {
814
+ ...lockPayload,
815
+ evidence: ready.evidence,
816
+ panelUrl: panel ? panel.url : undefined,
817
+ notifications: notificationState.count,
818
+ });
819
+ } catch {
820
+ /* ignore */
821
+ }
822
+
823
+ sessions.set(instance.lockPath, { child, session, panel, unsubscribe, instance });
824
+
825
+ return {
826
+ ...base,
827
+ status: "started",
828
+ pid: process.pid,
829
+ piPid: child.pid,
830
+ piBin: pi.bin,
831
+ piBinSource: pi.source,
832
+ launchArgs: lockPayload.launchArgs,
833
+ panelUrl: panel ? panel.url : undefined,
834
+ panelPort: panel ? panel.port : undefined,
835
+ notify: {
836
+ channel: notifier ? notifier.channel : "none",
837
+ available: Boolean(notifier && notifier.available),
838
+ ...(notifyDegraded ? { degraded: notifyDegraded } : {}),
839
+ },
840
+ ...(panelDegraded ? { panelDegraded } : {}),
841
+ credential,
842
+ evidence: ready.evidence,
843
+ supervisor: await collectSupervisorSnapshot(instance),
844
+ notificationRecordPath,
845
+ notifications: notificationState,
846
+ guidance: { steps: guideSteps(), nextStep: guideSteps()[0] },
847
+ residue: residueReport(instance, { pid: process.pid, piPid: child.pid }),
848
+ session: { child, session, panel, unsubscribe, instance },
849
+ };
850
+ }
851
+
852
+ /** 等就绪信号(AC1 扩展注册 + AC2 端点出现)。返回 {ok, reason, evidence}。 */
853
+ async function waitReady(session, instance, timeoutMs, exitInfo, stderrTail) {
854
+ const evidence = {
855
+ extensionListSource: "rpc:get_commands",
856
+ endpointExists: false,
857
+ registryFile: path.join(instance.agentRoot, "proactive", "agents", `${sanitizeName(instance.agentId)}.json`),
858
+ };
859
+ const diagnose = (reason) => {
860
+ const pkgExt = path.join(instance.pkgRoot, "build", "extensions", "proactive.js");
861
+ const built = fs.existsSync(pkgExt);
862
+ return (
863
+ `${reason}\n` +
864
+ `诊断:包路径=${instance.pkgRoot}|扩展入口${built ? "存在" : "**不存在**(请先构建:bash pi-proactive/tools/build-release.sh)"}|` +
865
+ `端点目录=${path.join(instance.agentRoot, RUN_SUBDIR)}|端点预算=${instance.socketPathBytes}B/${MAX_SOCKET_PATH_BYTES}B` +
866
+ (instance.socketOverBudget ? "(**超预算** → 端点建不出来)" : "") +
867
+ `\n子进程退出=${exitInfo() ? JSON.stringify(exitInfo()) : "仍在运行"}|stderr 末尾=${(stderrTail() || "").slice(-400) || "(空)"}`
868
+ );
869
+ };
870
+
871
+ // ① 进程内扩展清单必须含 pi-proactive(AC1 的**取证**)
872
+ let commands = [];
873
+ try {
874
+ const res = await session.request({ type: "get_commands" }, Math.min(timeoutMs, 15_000));
875
+ commands = (res && res.data && res.data.commands) || [];
876
+ } catch (err) {
877
+ return {
878
+ ok: false,
879
+ reason: diagnose(`读不到 pi 进程内的扩展清单(${errText(err)})`),
880
+ evidence,
881
+ };
882
+ }
883
+ const hit = commands.find(
884
+ (c) =>
885
+ c &&
886
+ c.source === "extension" &&
887
+ typeof (c.sourceInfo && c.sourceInfo.path) === "string" &&
888
+ c.sourceInfo.path.startsWith(instance.pkgRoot),
889
+ );
890
+ evidence.extensionPath = hit ? hit.sourceInfo.path : undefined;
891
+ evidence.extensionCommand = hit ? hit.name : undefined;
892
+ evidence.extensionCount = commands.length;
893
+ if (!hit) {
894
+ return {
895
+ ok: false,
896
+ reason: diagnose(
897
+ `注入未生效:pi 进程内的扩展清单里没有来自 ${instance.pkgRoot} 的扩展(source=extension)`,
898
+ ),
899
+ evidence,
900
+ };
901
+ }
902
+
903
+ // ② 端点必须**真的出现**(AC2)
904
+ const endpoint = await waitForEndpoint(instance, timeoutMs);
905
+ evidence.endpointExists = endpoint !== null;
906
+ evidence.endpointPath = endpoint ? endpoint.path : undefined;
907
+ evidence.endpointBytes = endpoint ? endpoint.bytes : undefined;
908
+ if (!endpoint) {
909
+ return {
910
+ ok: false,
911
+ reason: diagnose(
912
+ `端点未出现(**未触发**,不是「不残留」):${path.join(instance.agentRoot, RUN_SUBDIR)} 下没有 *.sock`,
913
+ ),
914
+ evidence,
915
+ };
916
+ }
917
+ return { ok: true, evidence };
918
+ }
919
+
920
+ /** 等端点出现(返回端点信息;超时 = null = 未触发)。 */
921
+ async function waitForEndpoint(instance, timeoutMs) {
922
+ const deadline = Date.now() + timeoutMs;
923
+ for (;;) {
924
+ const ep = findEndpoint(instance.agentRoot);
925
+ if (ep) return ep;
926
+ if (Date.now() >= deadline) return null;
927
+ await new Promise((r) => setTimeout(r, 100));
928
+ }
929
+ }
930
+
931
+ /** 只读监督快照(命令面口径 —— 与面板同一份聚合,数字必然逐字一致)。 */
932
+ async function collectSupervisorSnapshot(instance) {
933
+ try {
934
+ const state = await loadPiModule("supervisor-state", instance.pkgRoot);
935
+ return state.collectSupervisorState({ agentDir: instance.agentRoot, agentId: instance.agentId });
936
+ } catch {
937
+ return undefined;
938
+ }
939
+ }
940
+
941
+ /** 终止 pi 子进程(先 TERM 后 KILL)。 */
942
+ function killChild(child) {
943
+ if (!child || child.exitCode !== null) return;
944
+ try {
945
+ child.kill("SIGTERM");
946
+ } catch {
947
+ /* ignore */
948
+ }
949
+ const timer = setTimeout(() => {
950
+ try {
951
+ child.kill("SIGKILL");
952
+ } catch {
953
+ /* ignore */
954
+ }
955
+ }, 3_000);
956
+ timer.unref?.();
957
+ }
958
+
959
+ /** 等一个 pid 消失(最多 ms)。 */
960
+ async function waitGone(pid, ms) {
961
+ const deadline = Date.now() + ms;
962
+ while (Date.now() < deadline) {
963
+ if (!isAlive(pid)) return true;
964
+ await new Promise((r) => setTimeout(r, 100));
965
+ }
966
+ return !isAlive(pid);
967
+ }
968
+
969
+ /**
970
+ * 停止 App:停 App 进程(它会带走 pi)→ 兜底杀 pi → 清残留 → 回执残留终检。
971
+ * 没在跑 → 幂等回执「未在运行」。
972
+ */
973
+ export async function stopApp(opts = {}) {
974
+ const instance = resolveInstance(opts);
975
+ const lock = readLock(instance.lockPath);
976
+ if (!lock) {
977
+ return { ...baseReceipt(instance), status: "not-running", residue: residueReport(instance, null) };
978
+ }
979
+ // 清理目标 = 锁里记的那个实例(不是本进程 pid 派生的路径 —— 否则清的是自己,不是它)。
980
+ const target = instanceFromLock(lock, instance);
981
+ const live = sessions.get(instance.lockPath);
982
+ if (live) {
983
+ if (live.unsubscribe) live.unsubscribe();
984
+ if (live.panel) await live.panel.close().catch(() => {});
985
+ killChild(live.child);
986
+ sessions.delete(instance.lockPath);
987
+ }
988
+ // App 进程(不是自己就杀)
989
+ if (lock.pid && lock.pid !== process.pid && isAlive(lock.pid)) {
990
+ try {
991
+ process.kill(lock.pid, "SIGTERM");
992
+ } catch {
993
+ /* ignore */
994
+ }
995
+ if (!(await waitGone(lock.pid, 5_000))) {
996
+ try {
997
+ process.kill(lock.pid, "SIGKILL");
998
+ } catch {
999
+ /* ignore */
1000
+ }
1001
+ await waitGone(lock.pid, 2_000);
1002
+ }
1003
+ }
1004
+ // pi 子进程
1005
+ if (lock.piPid && isAlive(lock.piPid)) {
1006
+ try {
1007
+ process.kill(lock.piPid, "SIGTERM");
1008
+ } catch {
1009
+ /* ignore */
1010
+ }
1011
+ if (!(await waitGone(lock.piPid, 5_000))) {
1012
+ try {
1013
+ process.kill(lock.piPid, "SIGKILL");
1014
+ } catch {
1015
+ /* ignore */
1016
+ }
1017
+ await waitGone(lock.piPid, 2_000);
1018
+ }
1019
+ }
1020
+ cleanupArtifacts(target, lock);
1021
+ removeLock(instance.lockPath);
1022
+ return {
1023
+ ...baseReceipt(target),
1024
+ status: "stopped",
1025
+ pid: lock.pid,
1026
+ piPid: lock.piPid,
1027
+ residue: residueReport(target, lock),
1028
+ };
1029
+ }
1030
+
1031
+ /** 状态查询:如实反映在跑 / 没在跑(附与面板同源的只读快照)。 */
1032
+ export async function statusApp(opts = {}) {
1033
+ const instance = resolveInstance(opts);
1034
+ const lock = readLock(instance.lockPath);
1035
+ if (!lock) {
1036
+ return {
1037
+ ...baseReceipt(instance),
1038
+ status: "not-running",
1039
+ residue: residueReport(instance, null),
1040
+ supervisor: await collectSupervisorSnapshot(instance),
1041
+ };
1042
+ }
1043
+ const target = instanceFromLock(lock, instance);
1044
+ const running = isAlive(lock.pid);
1045
+ return {
1046
+ ...baseReceipt(target),
1047
+ status: running ? "running" : "not-running",
1048
+ pid: lock.pid,
1049
+ piPid: lock.piPid,
1050
+ piBin: lock.piBin,
1051
+ piBinSource: lock.piBinSource,
1052
+ launchArgs: lock.launchArgs,
1053
+ panelUrl: lock.panelUrl,
1054
+ evidence: lock.evidence,
1055
+ residue: residueReport(target, lock),
1056
+ supervisor: await collectSupervisorSnapshot(target),
1057
+ };
1058
+ }
1059
+
1060
+ /**
1061
+ * 下发一条 prompt 并等 **id 关联**的响应(AC3 / D3 驱动)。
1062
+ * 进程内已有实例 → 复用同一份 JSONL 连接;否则起一个一次性实例(起 → 问 → 停)。
1063
+ */
1064
+ export async function askApp(opts = {}) {
1065
+ const instance = resolveInstance(opts);
1066
+ const live = sessions.get(instance.lockPath);
1067
+ if (live) {
1068
+ const request = { type: "prompt", message: String(opts.message ?? "") };
1069
+ try {
1070
+ const response = await live.session.request(request, opts.timeoutMs || 30_000);
1071
+ return { ok: true, request: { ...request, id: response.id }, response, reused: true };
1072
+ } catch (err) {
1073
+ return { ok: false, error: errText(err), request };
1074
+ }
1075
+ }
1076
+ const started = await startApp(opts);
1077
+ if (started.status !== "started") {
1078
+ return { ok: false, error: String(started.error ?? `启动失败(${started.status})`) };
1079
+ }
1080
+ const liveSession = started.session;
1081
+ const request = { type: "prompt", message: String(opts.message ?? "") };
1082
+ try {
1083
+ const response = await liveSession.session.request(request, opts.timeoutMs || 30_000);
1084
+ await stopApp(opts);
1085
+ return { ok: true, request: { ...request, id: response.id }, response, reused: false };
1086
+ } catch (err) {
1087
+ await stopApp(opts);
1088
+ return { ok: false, error: errText(err), request };
1089
+ }
1090
+ }
1091
+
1092
+ // ------------------------------------------------------------------ CLI
1093
+
1094
+ const USAGE = `pi-proactive App —— pi-agent 的 shim / wrapper(最小可用外壳)
1095
+
1096
+ 用法:
1097
+ app.mjs start [--shared] [--agent-dir DIR] [--pi-bin PATH] [--pkg-root DIR] [--tmp-dir DIR] [--json]
1098
+ app.mjs stop [--json]
1099
+ app.mjs status [--json]
1100
+ app.mjs describe [--json] 三问:它是谁 / 在干什么 / 怎么让它做事
1101
+ app.mjs guide [--json] 首启引导(≤3 步)
1102
+ app.mjs ask "<要它做的事>" [--json]
1103
+
1104
+ 说明:
1105
+ start 拉起 pi(--mode rpc --no-session -e <pi-proactive 包>)并常驻;等**扩展真的注册**才算就绪;
1106
+ 二次启动回执「已在运行」(不产生第二份实例)。
1107
+ stop 停止并做残留终检(进程 / socket / 临时根 / 锁 / 日志 / launchd)。
1108
+ status 如实汇报当前状态(含只读监督快照)。
1109
+ describe 三问话术(零必读概念)。
1110
+ guide 首启引导的步骤。
1111
+ ask 经 JSONL 下发一条 prompt 并回带 id 关联的响应。
1112
+
1113
+ 选项:
1114
+ --shared 用宿主 pi 的 agent 根(默认:专属短路径 /tmp/pp-app-<pid>)
1115
+ --agent-dir DIR 显式指定 agent 根(不会被 App 删除)
1116
+ --pi-bin PATH 指定 pi 可执行文件(默认:PI_BIN > PATH)
1117
+ --pkg-root DIR 指定 pi-proactive 包路径(默认:本包自身)
1118
+ --tmp-dir DIR 临时根的基目录(默认 /tmp)
1119
+ --json 机器可读回执(每行一个 JSON)`;
1120
+
1121
+ /** 控制词:「算了」= 收工退出(零残留);「退出」= 直接退出。 */
1122
+ const CANCEL_WORDS = ["算了", "cancel"];
1123
+ const QUIT_WORDS = ["退出", "quit", "exit"];
1124
+
1125
+ function parseArgs(argv) {
1126
+ const flags = { json: false };
1127
+ const rest = [];
1128
+ for (let i = 0; i < argv.length; i++) {
1129
+ const a = argv[i];
1130
+ if (a === "--json") flags.json = true;
1131
+ else if (a === "--shared") flags.shared = true;
1132
+ else if (a === "--agent-dir") flags.agentDir = argv[++i];
1133
+ else if (a === "--pi-bin") flags.piBin = argv[++i];
1134
+ else if (a === "--pkg-root") flags.pkgRoot = argv[++i];
1135
+ else if (a === "--tmp-dir") flags.tmpDir = argv[++i];
1136
+ else if (a === "--help" || a === "-h") flags.help = true;
1137
+ else if (a.startsWith("--")) throw new Error(`未知参数:${a}`);
1138
+ else rest.push(a);
1139
+ }
1140
+ return { flags, rest };
1141
+ }
1142
+
1143
+ function printReceipt(receipt, json) {
1144
+ if (json) {
1145
+ const { session, ...rest } = receipt;
1146
+ process.stdout.write(`${JSON.stringify(rest)}\n`);
1147
+ return;
1148
+ }
1149
+ const lines = [
1150
+ `状态:${receipt.status}`,
1151
+ `agent 根:${receipt.agentRoot}(${receipt.agentRootKind})`,
1152
+ `端点:${receipt.socketPath}(${receipt.socketPathBytes}B / 上限 ${MAX_SOCKET_PATH_BYTES}B${receipt.socketOverBudget ? " · 超长!" : ""})`,
1153
+ ];
1154
+ if (receipt.piPid) lines.push(`pi 进程:${receipt.piPid}(${receipt.piBin || "?"})`);
1155
+ if (receipt.panelUrl) lines.push(`监督面板(只读):${receipt.panelUrl}`);
1156
+ if (receipt.notify) {
1157
+ lines.push(
1158
+ `系统通知:${receipt.notify.available ? `可用(${receipt.notify.channel})` : `**不可用** · ${receipt.notify.degraded || "已降级"}`}`,
1159
+ );
1160
+ }
1161
+ if (receipt.credential) lines.push(`凭据:${receipt.credential.detail}`);
1162
+ if (receipt.evidence) {
1163
+ lines.push(`注入证据:${receipt.evidence.extensionListSource} → ${receipt.evidence.extensionPath || "(无)"}`);
1164
+ lines.push(`端点已出现:${receipt.evidence.endpointExists ? "是" : "否"}`);
1165
+ }
1166
+ if (receipt.residue) {
1167
+ const r = receipt.residue;
1168
+ lines.push(
1169
+ `残留终检:进程 ${r.appProcessAlive || r.piProcessAlive ? "有" : "无"} · 端点 ${r.endpointExists ? "有" : "无"} · ` +
1170
+ `agent 根 ${r.agentRootExists ? "有" : "无"} · 锁 ${r.lockExists ? "有" : "无"} · launchd ${r.launchdUnits.length}`,
1171
+ );
1172
+ }
1173
+ if (receipt.error) lines.push(`错误:${receipt.error}`);
1174
+ process.stdout.write(`${lines.join("\n")}\n`);
1175
+ }
1176
+
1177
+ /** 引导问句(一行一步,零必读概念)。 */
1178
+ function printGuidance(step) {
1179
+ process.stdout.write(`下一步:${step.question}(${step.options.join(" / ")})\n`);
1180
+ }
1181
+
1182
+ async function main(argv) {
1183
+ const cmd = argv[0];
1184
+ let parsed;
1185
+ try {
1186
+ parsed = parseArgs(argv.slice(1));
1187
+ } catch (err) {
1188
+ process.stderr.write(`${errText(err)}\n${USAGE}\n`);
1189
+ return 2;
1190
+ }
1191
+ const { flags, rest } = parsed;
1192
+ if (!cmd || flags.help || cmd === "help") {
1193
+ process.stdout.write(`${USAGE}\n`);
1194
+ return cmd ? 0 : 2;
1195
+ }
1196
+ const opts = {
1197
+ agentRootKind: flags.shared ? "shared" : "dedicated",
1198
+ agentDir: flags.agentDir,
1199
+ piBin: flags.piBin,
1200
+ pkgRoot: flags.pkgRoot,
1201
+ tmpDir: flags.tmpDir,
1202
+ };
1203
+
1204
+ if (cmd === "describe") {
1205
+ const d = describeApp();
1206
+ if (flags.json) {
1207
+ process.stdout.write(`${JSON.stringify(d)}\n`);
1208
+ return 0;
1209
+ }
1210
+ process.stdout.write(`它是谁:${d.who}\n在干什么:${d.what}\n怎么让它做事:${d.how}\n`);
1211
+ return 0;
1212
+ }
1213
+ if (cmd === "guide") {
1214
+ const steps = guideSteps();
1215
+ if (flags.json) {
1216
+ process.stdout.write(`${JSON.stringify({ steps })}\n`);
1217
+ return 0;
1218
+ }
1219
+ process.stdout.write(`${steps.map((s, i) => `${i + 1}. ${s.question}(${s.options.join(" / ")})`).join("\n")}\n`);
1220
+ return 0;
1221
+ }
1222
+ if (cmd === "status") {
1223
+ const receipt = await statusApp(opts);
1224
+ printReceipt(receipt, flags.json);
1225
+ return 0;
1226
+ }
1227
+ if (cmd === "stop") {
1228
+ const receipt = await stopApp(opts);
1229
+ printReceipt(receipt, flags.json);
1230
+ return 0;
1231
+ }
1232
+ if (cmd === "ask") {
1233
+ const message = rest.join(" ").trim();
1234
+ if (!message) {
1235
+ process.stderr.write(`ask 需要一句话:app.mjs ask "<要它做的事>"\n`);
1236
+ return 2;
1237
+ }
1238
+ const result = await askApp({ ...opts, message });
1239
+ if (flags.json) {
1240
+ process.stdout.write(`${JSON.stringify(result)}\n`);
1241
+ } else if (result.ok) {
1242
+ process.stdout.write(`${JSON.stringify(result.response)}\n`);
1243
+ } else {
1244
+ process.stderr.write(`ask 失败:${String(result.error)}\n`);
1245
+ }
1246
+ return result.ok ? 0 : 1;
1247
+ }
1248
+ if (cmd !== "start") {
1249
+ process.stderr.write(`未知子命令:${cmd}\n${USAGE}\n`);
1250
+ return 2;
1251
+ }
1252
+
1253
+ // --- start:拉起并常驻(App 就是 pi 的守护者:它持有 pi 的 stdin/stdout)
1254
+ const receipt = await startApp(opts);
1255
+ printReceipt(receipt, flags.json);
1256
+ if (receipt.status === "error") return 2;
1257
+ if (receipt.status !== "started") {
1258
+ // already-running → 幂等回执(不常驻第二份实例)
1259
+ process.stdout.write("已在运行(没有起第二份)\n");
1260
+ return 0;
1261
+ }
1262
+ const live = receipt.session;
1263
+ const steps = guideSteps();
1264
+ let stepIndex = 0;
1265
+ printGuidance(steps[0]);
1266
+
1267
+ let stopping = false;
1268
+ const shutdown = async (reason) => {
1269
+ if (stopping) return;
1270
+ stopping = true;
1271
+ if (live) {
1272
+ if (live.unsubscribe) live.unsubscribe();
1273
+ if (live.panel) await live.panel.close().catch(() => {});
1274
+ try {
1275
+ live.child.stdin.end();
1276
+ } catch {
1277
+ /* ignore */
1278
+ }
1279
+ const deadline = Date.now() + 3_000;
1280
+ while (live.child.exitCode === null && Date.now() < deadline) {
1281
+ await new Promise((r) => setTimeout(r, 50));
1282
+ }
1283
+ killChild(live.child);
1284
+ }
1285
+ const instance = resolveInstance(opts);
1286
+ cleanupArtifacts(instance, { agentId: instance.agentId });
1287
+ removeLock(instance.lockPath);
1288
+ sessions.delete(instance.lockPath);
1289
+ process.stdout.write(`${reason}\n`);
1290
+ process.exit(0);
1291
+ };
1292
+
1293
+ process.on("SIGINT", () => void shutdown("收到中断,收工(不留残留)"));
1294
+ process.on("SIGTERM", () => void shutdown("收到结束信号,收工(不留残留)"));
1295
+ if (live) {
1296
+ live.child.on("exit", () => {
1297
+ if (!stopping) void shutdown("助理进程退出了,收工(不留残留)");
1298
+ });
1299
+ }
1300
+
1301
+ // 会话驱动:stdin 的一行 = 一条 prompt(按 id 关联响应)
1302
+ const promptOf = async (text) => {
1303
+ try {
1304
+ const res = await live.session.request({ type: "prompt", message: text }, 120_000);
1305
+ process.stdout.write(`${JSON.stringify(res)}\n`);
1306
+ } catch (err) {
1307
+ process.stdout.write(`下发失败:${errText(err)}\n`);
1308
+ }
1309
+ };
1310
+
1311
+ if (process.stdin.isTTY) {
1312
+ // 交互式:一问一答推进引导;任何一步都能「算了」→ 零残留
1313
+ process.stdin.setEncoding("utf8");
1314
+ let buffer = "";
1315
+ process.stdin.on("data", (chunk) => {
1316
+ buffer += String(chunk);
1317
+ let i;
1318
+ while ((i = buffer.indexOf("\n")) >= 0) {
1319
+ const line = buffer.slice(0, i).trim();
1320
+ buffer = buffer.slice(i + 1);
1321
+ if (!line) continue;
1322
+ if (CANCEL_WORDS.includes(line)) {
1323
+ void shutdown("好,收工(不留残留)");
1324
+ return;
1325
+ }
1326
+ if (QUIT_WORDS.includes(line)) {
1327
+ void shutdown("退出(不留残留)");
1328
+ return;
1329
+ }
1330
+ if (stepIndex < steps.length && (line === steps[stepIndex].options[0] || line === "要" || line === "是")) {
1331
+ stepIndex += 1;
1332
+ if (stepIndex < steps.length) {
1333
+ printGuidance(steps[stepIndex]);
1334
+ } else {
1335
+ process.stdout.write("好,都记下了。接下来直接跟我说要做什么就行。\n");
1336
+ }
1337
+ continue;
1338
+ }
1339
+ // 其余一律当作「要它做的事」
1340
+ stepIndex = steps.length;
1341
+ void promptOf(line);
1342
+ }
1343
+ });
1344
+ } else {
1345
+ process.stdin.setEncoding("utf8");
1346
+ let buffer = "";
1347
+ process.stdin.on("data", (chunk) => {
1348
+ buffer += String(chunk);
1349
+ let i;
1350
+ while ((i = buffer.indexOf("\n")) >= 0) {
1351
+ const line = buffer.slice(0, i).trim();
1352
+ buffer = buffer.slice(i + 1);
1353
+ if (!line) continue;
1354
+ if (CANCEL_WORDS.includes(line) || QUIT_WORDS.includes(line)) {
1355
+ void shutdown("好,收工(不留残留)");
1356
+ return;
1357
+ }
1358
+ void promptOf(line);
1359
+ }
1360
+ });
1361
+ }
1362
+
1363
+ // 常驻:等信号
1364
+ await new Promise(() => {});
1365
+ return 0;
1366
+ }
1367
+
1368
+ const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
1369
+ if (isMain) {
1370
+ main(process.argv.slice(2))
1371
+ .then((code) => {
1372
+ process.exitCode = code;
1373
+ })
1374
+ .catch((err) => {
1375
+ process.stderr.write(`[pi-proactive app] 未预期错误:${errText(err)}\n`);
1376
+ process.exitCode = 1;
1377
+ });
1378
+ }