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

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.
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `app/evidence.mjs` —— **M1a 验收取证脚本**(AC1–AC6 + D1–D4 的可复跑证据)。
4
+ *
5
+ * 用法:`node pi-proactive/app/evidence.mjs`
6
+ *
7
+ * App 本身的入口(同一目录的 app.mjs):
8
+ * node pi-proactive/app/app.mjs start 起 pi 并注入 pi-proactive(等扩展真的注册才算就绪)
9
+ * node pi-proactive/app/app.mjs stop 停止 + 残留终检
10
+ * node pi-proactive/app/app.mjs status 状态(含与面板同源的只读快照)
11
+ * node pi-proactive/app/app.mjs describe 三问:它是谁 / 在干什么 / 怎么让它做事
12
+ * node pi-proactive/app/app.mjs guide 首启引导(≤3 步)
13
+ * node pi-proactive/app/app.mjs ask "…" 经 JSONL 下发一条 prompt(id 关联响应)
14
+ *
15
+ * 为什么有它:测试给的是「断言通过」,取证给的是**逐条的实际输出**(谁在跑、端点在哪、
16
+ * 通知发了没有、残留是不是零)。本脚本用**真 pi**(不碰模型:隔离 agent 根没有凭据 →
17
+ * prompt 被本地拒绝)跑一遍 AC1–AC6 + D1–D4 的外部可观察面。
18
+ *
19
+ * 纪律:
20
+ * - 隔离:agent 根 = /tmp 下的 pp-evidence-<随机> 目录里的 pp-app-<pid>(**不写** live 目录 ~/.pi/agent/proactive);
21
+ * - 零真实模型调用:隔离根无凭据 → pi 在本地拒绝 prompt(不发网络请求);
22
+ * - 会真弹一条 macOS 通知(AC4 取证需要「系统侧可见」);不可用时明确报降级;
23
+ * - 结束清零:临时根、锁、日志、pi 子进程全部清掉。
24
+ */
25
+ import { spawnSync } from "node:child_process";
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+
30
+ const here = path.dirname(fileURLToPath(import.meta.url));
31
+ const PKG_ROOT = path.resolve(here, "..");
32
+ const app = await import(pathToFileURL(path.join(PKG_ROOT, "app", "app.mjs")).href);
33
+
34
+ const base = fs.mkdtempSync("/tmp/pp-evidence-");
35
+ const opts = { tmpDir: base, pid: process.pid, timeoutMs: 25_000, env: { ...process.env } };
36
+ const line = (t) => console.log(`\n=== ${t} ===`);
37
+ /** 本机在跑的 pi 进程数(按进程名计数 → 用来证明「没有第二份实例」)。 */
38
+ const countPi = () =>
39
+ spawnSync("sh", ["-c", "ps ax -o comm= | grep -cx 'pi'"], { encoding: "utf8" }).stdout.trim();
40
+
41
+ try {
42
+ line("D2 可发现性:三问(node app/app.mjs describe --json)");
43
+ const desc = app.describeApp();
44
+ for (const k of ["who", "what", "how"]) console.log(`${k}: ${desc[k]}`);
45
+ const src = fs.readFileSync(path.join(PKG_ROOT, "app", "app.mjs"), "utf8");
46
+ console.log("三问答案逐字存在于产物 app/app.mjs:", ["who", "what", "how"].every((k) => src.includes(desc[k])));
47
+
48
+ line("D1 引导:≤3 步、一次只问一件事、每步可「算了」");
49
+ const steps = app.guideSteps();
50
+ for (const s of steps) console.log(`- ${s.question}(${s.options.join(" / ")})`);
51
+ console.log("步数(≤3):", steps.length);
52
+
53
+ line("启动(真 pi · 单次注入)");
54
+ const before = countPi();
55
+ const started = await app.startApp(opts);
56
+ console.log("status :", started.status);
57
+ console.log("pi 进程 :", started.piPid, started.piBin);
58
+ console.log("启动参数 :", (started.launchArgs || []).join(" "));
59
+ console.log("agent 根 :", started.agentRoot);
60
+ console.log("凭据 :", started.credential.detail);
61
+ console.log("通知通道 :", JSON.stringify(started.notify));
62
+ console.log("面板 :", started.panelUrl);
63
+ console.log("本机 pi 进程数 :", before, "→", countPi());
64
+
65
+ line("AC1 注入可证明(进程内扩展清单 = RPC get_commands 的真实返回)");
66
+ console.log("取证来源 :", started.evidence.extensionListSource);
67
+ console.log("扩展入口 :", started.evidence.extensionPath);
68
+ console.log("入口文件真实存在 :", fs.existsSync(String(started.evidence.extensionPath)));
69
+ console.log("命令名 :", started.evidence.extensionCommand);
70
+
71
+ line("AC2 承载(端点真的出现 + 短路径 + <100B)");
72
+ const ep = String(started.evidence.endpointPath);
73
+ console.log("端点路径 :", ep);
74
+ console.log("字节数 :", Buffer.byteLength(ep, "utf8"), "/ 上限 100");
75
+ console.log("落在 agent 根下 :", ep.startsWith(String(started.agentRoot)));
76
+ console.log("真实存在 :", fs.existsSync(ep));
77
+ console.log("是 unix socket :", fs.existsSync(ep) ? fs.statSync(ep).isSocket() : false);
78
+ console.log("信箱目录 :", path.join(String(started.agentRoot), "proactive", "mailbox", String(started.agentId)));
79
+
80
+ line("D3 面板数字与命令面同源逐字一致");
81
+ const panel = await (await fetch(`${started.panelUrl}/api/state`)).json();
82
+ const cliPath = fs.existsSync(path.join(PKG_ROOT, "build", "cli.mjs"))
83
+ ? path.join(PKG_ROOT, "build", "cli.mjs")
84
+ : path.join(PKG_ROOT, "tools", "cli.mjs");
85
+ const cmdState = JSON.parse(
86
+ spawnSync(process.execPath, [cliPath, "web", "--agent-dir", String(started.agentRoot), "--json"], {
87
+ encoding: "utf8",
88
+ }).stdout,
89
+ );
90
+ const strip = (o) => {
91
+ const c = { ...o };
92
+ delete c.generatedAt;
93
+ return c;
94
+ };
95
+ console.log(
96
+ "面板 /api/state ≡ 命令面 `pi-proactive web --json`:",
97
+ JSON.stringify(strip(panel)) === JSON.stringify(strip(cmdState)),
98
+ );
99
+ console.log(
100
+ "面板 /api/state ≡ App status 回执的 supervisor:",
101
+ JSON.stringify(strip(panel)) === JSON.stringify(strip(started.supervisor)),
102
+ );
103
+
104
+ line("AC3 驱动(JSONL 下发 prompt → id 关联响应)");
105
+ const asked = await app.askApp({ ...opts, message: "记住:CI 变绿时告诉我" });
106
+ console.log("请求 :", JSON.stringify(asked.request));
107
+ console.log("响应 :", JSON.stringify(asked.response));
108
+ console.log("id 关联 :", asked.response.id === asked.request.id);
109
+ console.log("说明 : 隔离 agent 根无凭据 → pi 在**本地**拒绝(不发网络请求 → 零真实模型调用)");
110
+
111
+ line("AC5 幂等:二次启动(不产生第二份实例 / 第二份通知注册)");
112
+ const again = await app.startApp(opts);
113
+ console.log("二次启动 status :", again.status, "|pi 进程:", again.piPid, "|App 进程:", again.pid);
114
+ console.log("本机 pi 进程数 :", countPi(), "(与首次启动相同 ⇒ 没有第二份实例)");
115
+
116
+ line("AC4 呈现:真实系统通知(macOS osascript → Notification Center 可见)");
117
+ const notifier = await app.createNotifier({ pkgRoot: PKG_ROOT });
118
+ console.log("通道可用 :", notifier.available, notifier.channel);
119
+ const sent = await notifier.send("M1a 取证:这是一条来自 pi-proactive App 的真实通知", {
120
+ title: "助理找你有事",
121
+ });
122
+ console.log("发送结果 :", JSON.stringify({ ok: sent.ok, detail: sent.detail }));
123
+ console.log("通知正文(四段):\n" + sent.payload.body);
124
+
125
+ line("AC5/D4 停止 → 零残留终检");
126
+ const stopped = await app.stopApp(opts);
127
+ console.log("status :", stopped.status);
128
+ console.log("残留终检 :", JSON.stringify(stopped.residue));
129
+ console.log(
130
+ "七处零残留 :",
131
+ !stopped.residue.appProcessAlive &&
132
+ !stopped.residue.piProcessAlive &&
133
+ !stopped.residue.endpointExists &&
134
+ !stopped.residue.socketExists &&
135
+ !stopped.residue.agentRootExists &&
136
+ !stopped.residue.lockExists &&
137
+ !stopped.residue.logExists,
138
+ );
139
+ console.log("本机 pi 进程数 :", countPi(), "(回到启动前 ⇒ 无孤儿 pi)");
140
+ console.log("基目录残留 :", fs.readdirSync(base).filter((n) => n.startsWith("pp-app") || n.endsWith(".lock")));
141
+ } finally {
142
+ fs.rmSync(base, { recursive: true, force: true });
143
+ console.log("\n取证脚本结束(临时根已清)。");
144
+ }
@@ -8,6 +8,7 @@ import { formatPeerDirectory, formatPeerList } from "./peer.js";
8
8
  import { readProgressEta } from "./progress-eta.js";
9
9
  import { handleAssistantCommand } from "./assistant.js";
10
10
  import { Registry } from "./registry.js";
11
+ import { buildPanel, ensureSupervisorWeb, stopSupervisorWeb, DEFAULT_SUPERVISOR_WEB_PORT } from "./supervisor-panel.js";
11
12
  const DETECT_LABEL = {
12
13
  terminal: "终端输入",
13
14
  "input-only": "输入事件(保守模式)",
@@ -455,6 +456,53 @@ export async function dispatchProactiveCommand(args, ctx, opts = {}) {
455
456
  }
456
457
  return { text, level: "info" };
457
458
  }
459
+ case "panel": {
460
+ // 交互面板(三侧面):**同源状态块** —— 数字与 status / peers / history / Web 看板一致。
461
+ //
462
+ // 为什么在命令面就地取数:① 监督聚合 = `collectSupervisorState`(同一份);
463
+ // ② 对端目录 = **与 `/proactive peers --all` 同一个查询**(`queryPeerDirectory`);
464
+ // ③ 主动联系流水 = `contacts.jsonl`(既有读法)→ 三块不各算一套。
465
+ const agentDir = ctx.agentDir ?? resolveAgentDir();
466
+ const registry = ctx.registry ?? new Registry({ agentDir });
467
+ const { directory } = queryPeerDirectory({
468
+ registry,
469
+ selfAgentId: ctx.agentId,
470
+ inFlight: ctx.inFlightForPeer,
471
+ query: { includeOffline: true },
472
+ });
473
+ const panel = buildPanel({ agentDir, agentId: ctx.agentId, peers: directory });
474
+ return { text: panel.text, level: "info" };
475
+ }
476
+ case "web": {
477
+ // 只读 Web 看板(**不阻塞**):后台起服务后立刻回执可点 URL;重复调用**不重起**。
478
+ const flags = parts.slice(1);
479
+ const portAt = flags.indexOf("--port");
480
+ let port = DEFAULT_SUPERVISOR_WEB_PORT;
481
+ if (portAt >= 0) {
482
+ const raw = flags[portAt + 1] ?? "";
483
+ port = Number(raw);
484
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
485
+ return { text: `proactive: --port 需要 0-65535 的整数(实际:${raw})`, level: "error" };
486
+ }
487
+ }
488
+ if (flags.includes("stop")) {
489
+ const stopped = await stopSupervisorWeb();
490
+ return { text: stopped ? "监督面已停止(端口已释放)" : "监督面本来就没在跑", level: "info" };
491
+ }
492
+ const outcome = await ensureSupervisorWeb({
493
+ port,
494
+ agentDir: ctx.agentDir ?? resolveAgentDir(),
495
+ agentId: ctx.agentId,
496
+ });
497
+ if (!outcome.ok) {
498
+ return { text: `proactive: ${outcome.error ?? "监督面启动失败"}`, level: "error" };
499
+ }
500
+ const head = outcome.alreadyRunning === true ? `已在运行 ${outcome.url}` : `监督面已启动 ${outcome.url}`;
501
+ return {
502
+ text: `${head}\n只读(改配置请回 Pi 会话)· 只监听 127.0.0.1 · 停止:/proactive web stop`,
503
+ level: "info",
504
+ };
505
+ }
458
506
  case "assistant": {
459
507
  // 会话内常驻助理(on / off / status / restart)。
460
508
  // **与模型工具面共用同一段实现**(`handleAssistantCommand`)→ 输出逐字一致。
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 交互入口的**接线层**:TUI 面板编排 + 只读 Web 服务的生命周期。
3
+ *
4
+ * 为什么单独一个模块:交互入口卡要接**两个**入口(TUI 面板 / Web 看板),
5
+ * 而这两件事都必须**同源**(同一份聚合、逐字一致)⇒ 编排与生命周期放在一处,
6
+ * 渲染留在 `src/supervisor-tui.ts`(纯文本,可被 Node 测试直接断言)。
7
+ *
8
+ * 纪律:
9
+ * - **只读**:本模块只读既有事实面(心跳 / 值班状态 / 事件流 / 主动联系清单 / 对端注册表);
10
+ * - **不重算指标**:监督数字来自 `src/supervisor-state.ts` 的**同一份聚合**,
11
+ * 对端名单来自 `/proactive peers` 的**同一次目录查询**(渲染逐字复用);
12
+ * - **幂等**:Web 服务重复调用**不重起**(端口数不增加);失败必须**明确报错 + 可执行建议**。
13
+ */
14
+ import { readContactEntries } from "./daemon-config.js";
15
+ import { resolveAgentDir } from "./config.js";
16
+ import { collectSupervisorState } from "./supervisor-state.js";
17
+ import { formatInteractivePanel } from "./supervisor-tui.js";
18
+ import { startSupervisorWeb } from "./supervisor-web.js";
19
+ /** Web 监督面的缺省端口(与 CLI `pi-proactive web` 同一个)。 */
20
+ export const DEFAULT_SUPERVISOR_WEB_PORT = 7799;
21
+ /**
22
+ * 采集并渲染交互面板(**只读**:不写任何文件)。
23
+ *
24
+ * @param input 见 {@link PanelInput}。
25
+ */
26
+ export function buildPanel(input = {}) {
27
+ const agentDir = input.agentDir ?? resolveAgentDir();
28
+ const now = input.now ?? Date.now();
29
+ const state = collectSupervisorState({
30
+ agentDir,
31
+ now,
32
+ ...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
33
+ });
34
+ const contacts = readContactEntries(agentDir);
35
+ const text = formatInteractivePanel({
36
+ state,
37
+ contacts,
38
+ ...(input.peers !== undefined ? { peers: input.peers } : {}),
39
+ }).join("\n");
40
+ return { state, contacts, text };
41
+ }
42
+ /** 当前进程内已在跑的看板句柄(**幂等的唯一真源**;进程退出即释放端口)。 */
43
+ let running;
44
+ /** 探一下端口上是不是**已经有一份监督面**(端口被别人占 ≠ 监督面在跑)。 */
45
+ async function probeSupervisorWeb(port) {
46
+ try {
47
+ const res = await fetch(`http://127.0.0.1:${port}/api/state`, { signal: AbortSignal.timeout(1000) });
48
+ if (!res.ok)
49
+ return undefined;
50
+ const body = (await res.json());
51
+ if (typeof body?.status !== "object" || body.status === null)
52
+ return undefined;
53
+ return `http://127.0.0.1:${port}`;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /**
60
+ * 起只读 Web 监督面(**幂等**)。
61
+ *
62
+ * - 已在跑 → 直接回「已在运行 <url>」,**不重起**(端口数不增加);
63
+ * - 端口被占 → 若占着的正是监督面 → 如实说「已在运行」;否则**明确报错 + 可执行建议**。
64
+ */
65
+ export async function ensureSupervisorWeb(input = {}) {
66
+ if (running !== undefined) {
67
+ return { ok: true, url: running.url, port: running.port, alreadyRunning: true };
68
+ }
69
+ const port = input.port ?? DEFAULT_SUPERVISOR_WEB_PORT;
70
+ try {
71
+ const handle = await startSupervisorWeb({
72
+ port,
73
+ ...(input.agentDir !== undefined ? { agentDir: input.agentDir } : {}),
74
+ ...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
75
+ });
76
+ running = handle;
77
+ return { ok: true, url: handle.url, port: handle.port, alreadyRunning: false };
78
+ }
79
+ catch (err) {
80
+ const code = err?.code;
81
+ if (code === "EADDRINUSE") {
82
+ const existing = await probeSupervisorWeb(port);
83
+ if (existing !== undefined)
84
+ return { ok: true, url: existing, port, alreadyRunning: true };
85
+ return {
86
+ ok: false,
87
+ error: `端口 ${port} 已被占用(EADDRINUSE)—— 监督面没起来。\n` +
88
+ `可执行建议:① 换一个端口:/proactive web --port ${port + 1};` +
89
+ `② 或先停掉占用它的进程:lsof -nP -iTCP:${port} -sTCP:LISTEN`,
90
+ };
91
+ }
92
+ const detail = err instanceof Error ? err.message : String(err);
93
+ return { ok: false, error: `监督面启动失败(端口 ${port}):${detail}\n可执行建议:/proactive web --port 0(随机端口)` };
94
+ }
95
+ }
96
+ /**
97
+ * 停掉本进程内的看板(**释放端口**;幂等:本来没跑 → 返回 false)。
98
+ */
99
+ export async function stopSupervisorWeb() {
100
+ if (running === undefined)
101
+ return false;
102
+ const handle = running;
103
+ running = undefined;
104
+ await handle.close();
105
+ return true;
106
+ }
@@ -9,8 +9,9 @@
9
9
  * 着色由扩展层用宿主的 theme 加,宽度裁剪由扩展层做 → 本模块可被纯 Node 测试直接断言。
10
10
  */
11
11
  import { formatBudgetLine, formatConservationLine, formatEntrustmentConservationLine, formatStatusLine, formatSupervisorSummary, } from "./supervisor-state.js";
12
+ import { formatPeerDirectory } from "./peer.js";
12
13
  /** 毫秒 → `MM-DD HH:mm:ss`(本地时区)。 */
13
- function fmtTime(ts) {
14
+ export function formatSupervisorTime(ts) {
14
15
  const d = new Date(ts);
15
16
  const p = (n) => String(n).padStart(2, "0");
16
17
  return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
@@ -39,7 +40,7 @@ export function formatSupervisorPanel(state, opts = {}) {
39
40
  const len = t.bodyLen === undefined ? "" : ` · 正文 ${t.bodyLen} 字`;
40
41
  const digest = t.bodyDigest === undefined ? "" : ` · 摘要 ${t.bodyDigest}`;
41
42
  const peer = t.peer === undefined ? "" : ` · 对端 ${t.peer}`;
42
- lines.push(` ${fmtTime(t.ts)} ${t.summary}${len}${digest}${peer}`);
43
+ lines.push(` ${formatSupervisorTime(t.ts)} ${t.summary}${len}${digest}${peer}`);
43
44
  }
44
45
  }
45
46
  lines.push(`托付(${state.trusts.length})`);
@@ -83,3 +84,126 @@ export function formatSupervisorPanel(state, opts = {}) {
83
84
  export function formatSupervisorText(state, opts = {}) {
84
85
  return formatSupervisorPanel(state, opts).join("\n");
85
86
  }
87
+ /** 主动联系流水最多显示几条(缺省)。 */
88
+ const DEFAULT_CONTACT_LIMIT = 5;
89
+ /** 「在不在」的判定(**不猜**:心跳在 + 进程活着 = 在跑;装过但没跑 / 没装过 = 明说)。 */
90
+ function livenessVerdict(state) {
91
+ if (!state.status.present)
92
+ return "未安装(没有心跳 / 宿主状态文件)—— 助理从未常驻";
93
+ if (!state.status.running) {
94
+ const reason = state.status.notRunningReason === undefined ? "" : `(原因:${state.status.notRunningReason})`;
95
+ return `装过但当前没在运行${reason}`;
96
+ }
97
+ return "在跑(心跳在 + 进程活着)";
98
+ }
99
+ /** 当前模式(★干跑必须显式;被阻塞 / 预算耗尽一并点名)。 */
100
+ function runModeLine(state) {
101
+ if (!state.status.running)
102
+ return "—(值班进程没在跑 → 形态未知)";
103
+ const base = state.status.runMode === "dry-run"
104
+ ? "干跑(dry-run)—— 只演练,不会真的动手"
105
+ : state.status.runMode === "live"
106
+ ? "真跑(live)"
107
+ : "未知(心跳里没有形态记录)";
108
+ const extra = [];
109
+ if (state.budget.exhausted)
110
+ extra.push("预算耗尽");
111
+ if (state.blockers.length > 0)
112
+ extra.push(`被阻塞(${state.blockers.length} 项)`);
113
+ return extra.length === 0 ? base : `${base} · ${extra.join(" · ")}`;
114
+ }
115
+ /** 对端分布(**同一份目录**的数字:在线 = 忙碌 + 空闲;总数 = 在线 + 离线)。 */
116
+ function peerDistribution(peers) {
117
+ if (peers === undefined)
118
+ return "对端目录未初始化(协作层尚未起)";
119
+ const total = peers.peers.length;
120
+ const busy = peers.peers.filter((p) => p.status === "busy").length;
121
+ const idle = peers.peers.filter((p) => p.status === "idle").length;
122
+ const offline = peers.peers.filter((p) => p.status === "offline").length;
123
+ return `共 ${total} 个:在线 ${total - offline} · 忙碌 ${busy} · 空闲 ${idle} · 离线 ${offline}`;
124
+ }
125
+ /** 通道 → 人话(来源)。 */
126
+ function channelLabel(channel) {
127
+ if (channel === "session")
128
+ return "会话通道";
129
+ if (channel === "device")
130
+ return "设备通道";
131
+ return channel;
132
+ }
133
+ /** 联系结局 → 人话(结果)。 */
134
+ function outcomeLabel(outcome) {
135
+ switch (outcome) {
136
+ case "session":
137
+ return "已送达(会话)";
138
+ case "device":
139
+ return "已送达(设备)";
140
+ case "both":
141
+ return "已送达(会话+设备)";
142
+ case "skipped":
143
+ return "已跳过";
144
+ case "failed":
145
+ return "失败";
146
+ default:
147
+ return outcome;
148
+ }
149
+ }
150
+ /**
151
+ * 渲染交互面板的行(**只读**:没有任何可点的操作)。
152
+ *
153
+ * @param input 三份既有事实(监督聚合 / 对端目录 / 主动联系流水)。
154
+ */
155
+ export function formatInteractivePanel(input) {
156
+ const { state, peers, contacts } = input;
157
+ const contactLimit = input.contactLimit ?? DEFAULT_CONTACT_LIMIT;
158
+ const lines = [];
159
+ lines.push("监督面板(只读 · 数字与 status / peers / history / Web 看板同源)");
160
+ lines.push(`聚合于 ${formatSupervisorTime(state.generatedAt)} · 数据源 ${state.agentDir}`);
161
+ lines.push("");
162
+ // ---- ① 在不在 ----
163
+ lines.push("① 在不在");
164
+ lines.push(` 判定 ${livenessVerdict(state)}`);
165
+ lines.push(` 状态 ${formatSupervisorSummary(state)}`);
166
+ lines.push(` ${formatStatusLine(state)}`);
167
+ lines.push(` 模式 ${runModeLine(state)}`);
168
+ if (state.blockers.length === 0) {
169
+ lines.push(" 阻塞 无");
170
+ }
171
+ else {
172
+ const shown = state.blockers.slice(0, 3).map((b) => b.title).join(";");
173
+ lines.push(` 阻塞 ${state.blockers.length} 项:${shown}`);
174
+ }
175
+ lines.push(` 预算 ${formatBudgetLine(state)}`);
176
+ lines.push("");
177
+ // ---- ② 有多少 ----
178
+ lines.push("② 有多少(对端 · 与 /proactive peers --all 同源)");
179
+ lines.push(` 分布 ${peerDistribution(peers)}`);
180
+ if (peers === undefined) {
181
+ lines.push(" 名单 (无)");
182
+ }
183
+ else {
184
+ // 名单**逐字复用** `/proactive peers --all` 的渲染 → 两处不可能漂移。
185
+ for (const line of formatPeerDirectory(peers).split("\n"))
186
+ lines.push(line);
187
+ }
188
+ lines.push("");
189
+ // ---- ③ 有没有发生 ----
190
+ lines.push("③ 有没有发生(主动联系流水 · 同一份 contacts.jsonl)");
191
+ if (contacts.length === 0) {
192
+ lines.push(" 主动联系 从未联系过(清单为空)");
193
+ }
194
+ else {
195
+ const last = contacts[contacts.length - 1];
196
+ const target = last.targetAgentId === undefined ? "" : `(对象 ${last.targetAgentId})`;
197
+ lines.push(` 主动联系 共 ${contacts.length} 次 · 最近一次 ${formatSupervisorTime(last.ts)}${target}`);
198
+ const rows = contacts.slice(Math.max(0, contacts.length - contactLimit)).reverse();
199
+ lines.push(` 流水(最近 ${rows.length} 次)`);
200
+ for (const c of rows) {
201
+ const source = c.channels.length > 0 ? c.channels.map(channelLabel).join("+") : "无通道";
202
+ const to = c.targetAgentId ?? "(无对象)";
203
+ const reason = c.reason === undefined || c.reason === "" ? "" : `(${c.reason})`;
204
+ lines.push(` ${formatSupervisorTime(c.ts)} · ${source} · ${to} · ${outcomeLabel(c.outcome)}${reason}`);
205
+ }
206
+ }
207
+ lines.push(` 守恒 ${formatConservationLine(state)}`);
208
+ return lines;
209
+ }
@@ -0,0 +1,87 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><head><meta charset="utf-8">
3
+ <meta name="viewport" content="width=device-width,initial-scale=1">
4
+ <title>pi-proactive 使用手册</title>
5
+ <style>
6
+ :root{--fg:#1a1a1a;--mut:#666;--bg:#fff;--ac:#2b6cb0;--warn:#b45309;--code:#f6f8fa;--bd:#e5e7eb}
7
+ @media(prefers-color-scheme:dark){:root{--fg:#e6e6e6;--mut:#9aa0a6;--bg:#16181c;--ac:#7fb3e8;--warn:#e0a458;--code:#1f2329;--bd:#2b2f36}}
8
+ body{max-width:860px;margin:0 auto;padding:32px 20px 80px;background:var(--bg);color:var(--fg);
9
+ font:15px/1.75 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
10
+ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:34px 0 10px;padding-top:10px;border-top:1px solid var(--bd)}
11
+ h3{font-size:16px;margin:20px 0 6px}.v{color:var(--mut);font-size:13px;margin:0 0 8px}
12
+ code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
13
+ code{background:var(--code);padding:1px 5px;border-radius:4px}pre{background:var(--code);padding:12px 14px;border-radius:8px;overflow:auto}
14
+ table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px}th,td{border:1px solid var(--bd);padding:7px 9px;text-align:left;vertical-align:top}
15
+ th{background:var(--code)}.warn{border-left:3px solid var(--warn);padding-left:12px;margin:12px 0}
16
+ .ok{border-left:3px solid var(--ac);padding-left:12px;margin:12px 0}a{color:var(--ac)}
17
+ </style></head><body>
18
+ <h1>pi-proactive 使用手册</h1>
19
+ <p class="v">版本 0.2.0-beta.8 | 跨项目协作 · 主动触发 · 常驻助理 | 本手册随 npm 包发布</p>
20
+
21
+ <h2>1. 这是什么</h2>
22
+ <p>pi-proactive 是 Pi Coding Agent 的扩展插件,给它加上四件事:<b>跨进程/跨项目消息协作</b>、<b>空闲主动触发</b>、<b>常驻值班助理</b>、<b>可观测的协作状态</b>。</p>
23
+ <div class="ok"><b>它不做什么:</b>不替你做决策;不自动改你的项目文件;不管理模型额度(那是 provider 侧的事)。</div>
24
+
25
+ <h2>2. 安装</h2>
26
+ <h3>2.1 宿主实际加载的那一份(推荐)</h3>
27
+ <pre><code>cd ~/.pi/agent/npm &amp;&amp; npm install pi-proactive@latest</code></pre>
28
+ <p class="v">宿主的扩展来自 pi 的<b>包目录</b>,不是全局 npm 目录;用 <code>npm i -g</code> 装到别处不会影响宿主,反而多出第二份副本(宿主会报 duplicate runtime modules)。</p>
29
+ <h3>2.2 全局安装(CLI 用)</h3>
30
+ <pre><code>npm i -g --prefix ~/.npm-global pi-proactive@latest</code></pre>
31
+ <div class="warn"><b>关于 <code>--legacy-peer-deps</code>:</b>只在<b>混合安装目录</b>(该目录里有与本包无关的 peer 冲突)才需要;<b>干净空目录不需要</b>(typebox 已随包 bundled)。</div>
32
+
33
+ <h2>3. 三条使用入口</h2>
34
+ <table><tr><th>入口</th><th>适合</th><th>怎么用</th></tr>
35
+ <tr><td><b>命令面</b></td><td>你在 pi 会话里手动问</td><td><code>/proactive status</code> · <code>/proactive peers</code> · <code>/proactive doctor</code> · <code>/proactive history</code> · <code>/proactive assistant on|off|status</code></td></tr>
36
+ <tr><td><b>模型工具面</b></td><td>让 agent 自己查、自己协作</td><td><code>list_peers</code> · <code>message_agent</code> · <code>schedule_task</code> · <code>proactive_query</code> · <code>proactive_schedule</code> · <code>proactive_peer</code> · <code>proactive_manage</code></td></tr>
37
+ <tr><td><b>CLI</b></td><td>脚本化 / 监督</td><td><code>pi-proactive --help</code></td></tr></table>
38
+
39
+ <h2>3.5 App 模式(不想开终端就用它)</h2>
40
+ <pre><code>node ~/.pi/agent/npm/node_modules/pi-proactive/app/app.mjs start # 启动(起 pi + 注入插件 + 通知 + 面板)
41
+ node ~/.pi/agent/npm/node_modules/pi-proactive/app/app.mjs status # 一屏状态(助理在不在/对端多少/主动联系流水)
42
+ node ~/.pi/agent/npm/node_modules/pi-proactive/app/app.mjs describe # 三问:它是谁/在干什么/怎么让它做事
43
+ node ~/.pi/agent/npm/node_modules/pi-proactive/app/app.mjs guide # 引导(≤3 步,一步一问,随时可说"算了")
44
+ node ~/.pi/agent/npm/node_modules/pi-proactive/app/app.mjs stop # 停止(进程/socket/临时目录 七处零残留)</code></pre>
45
+ <table><tr><th>模式</th><th>说明</th></tr>
46
+ <tr><td>默认(专属 agent 根)</td><td>隔离干净,<b>但没有模型凭据 → 只能看不能做事</b>,App 会明说这一点</td></tr>
47
+ <tr><td><code>--shared</code></td><td>与宿主 pi 共用 <code>~/.pi/agent</code>(有凭据、能做事)→ 排障时先看这一点</td></tr></table>
48
+ <div class="warn"><b>Windows:</b>App 外壳暂未实现 Windows 分支(会明确报"尚未实现",不假装能跑)。</div>
49
+
50
+ <h2>4. 常驻助理(7×24)</h2>
51
+ <pre><code># 安装常驻(跨平台:macOS launchd · Linux systemd --user · Windows 计划任务)
52
+ node ~/.pi/agent/npm/node_modules/pi-proactive/tools/daemon-install.mjs
53
+ # 卸载(三处零残留:进程 / socket / 任务与目录)
54
+ node ~/.pi/agent/npm/node_modules/pi-proactive/tools/daemon-uninstall.mjs</code></pre>
55
+ <table><tr><th>平台</th><th>机制</th><th>验证</th></tr>
56
+ <tr><td>macOS</td><td>launchd 单元</td><td><code>launchctl print gui/$(id -u)/&lt;label&gt;</code></td></tr>
57
+ <tr><td>Linux</td><td>systemd --user</td><td><code>systemctl --user is-active &lt;label&gt;</code></td></tr>
58
+ <tr><td>Windows</td><td>计划任务(schtasks)</td><td><code>schtasks /Query /TN &lt;name&gt;</code></td></tr></table>
59
+ <div class="warn"><b>注意 agent 根路径:</b>unix socket 路径上限约 100 字节,安装器会自动选一个短路径;<b>手工指定长路径会导致端点建不出来</b>(表现为"看起来没残留"其实是观测失败)。</div>
60
+
61
+ <h2>5. 怎么读状态(一屏三块)</h2>
62
+ <table><tr><th>块</th><th>回答什么</th><th>在哪看</th></tr>
63
+ <tr><td>在不在</td><td>助理在跑吗(心跳 + 进程存活 + 当前模式:干跑/真跑/被阻塞/预算耗尽)</td><td><code>/proactive status</code> · <code>assistant status</code></td></tr>
64
+ <tr><td>有多少</td><td>对端 peers 的在线/忙碌/离线分布</td><td><code>/proactive peers</code> · <code>list_peers</code></td></tr>
65
+ <tr><td>有没有发生</td><td>主动联系流水(时刻/来源/对象/结果)</td><td><code>/proactive history</code></td></tr></table>
66
+ <div class="ok"><b>干跑(dry-run)必须显式显示</b>:界面显示"在跑"却零动作,等于没干活 —— 判定依据是心跳<b>且</b>模式。</div>
67
+
68
+ <h2>6. 故障排查(按成因分性)</h2>
69
+ <table><tr><th>现象</th><th>成因</th><th>处置</th></tr>
70
+ <tr><td>429 用量上限</td><td>订阅窗口耗尽(周期性)</td><td><b>排队到窗口重置</b>;不要反复重试</td></tr>
71
+ <tr><td>402 Insufficient Balance</td><td>按量通道余额不足</td><td>需人工处理(花钱决策)</td></tr>
72
+ <tr><td>403 quota</td><td>配额/权限/区域</td><td>人工调整;不要当抖动重试</td></tr>
73
+ <tr><td>连接超时</td><td>位置/网络不可达(本地端点只在局域网内)</td><td>降级到全局可达通道</td></tr>
74
+ <tr><td>配置改了没生效</td><td>未知配置项 → 整份回落默认</td><td>报错会指名键名(CONFIG_UNKNOWN_KEY);注意拼错会连带关掉空闲触发</td></tr></table>
75
+
76
+ <h2>7. 开发者向:残留枚举与对拍</h2>
77
+ <pre><code>node tools/enumerate-residue.mjs --agent-dir ~/.pi/agent --scope run,mailbox,agents --json --no-record
78
+ python3 scripts/残留对拍.py A.json B.json --normalize # 0=一致 4=空对空不给结论 5=有差异</code></pre>
79
+ <p>字段口径与三态定义见仓库 <code>docs/project/cp010-enumeration-contract.md</code>。</p>
80
+
81
+ <h2>8. 版本与变更</h2>
82
+ <ul>
83
+ <li><b>CHANGELOG</b>:<code>CHANGELOG.md</code>(随包发布)</li>
84
+ <li><b>破坏性变更</b>:<code>BREAKING-CHANGES.md</code>(随包发布;含迁移说明)</li>
85
+ <li><b>使用手册</b>:本文件 <code>manual/USAGE.html</code>(随包发布)</li>
86
+ </ul>
87
+ </body></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-proactive",
3
- "version": "0.2.0-beta.6",
3
+ "version": "0.2.0-beta.8",
4
4
  "description": "给 Pi 增加主动性:空闲时主动推进或提问、按时间点主动发起任务、多个会话之间对话协作。(专有软件:分发物不含源码,使用条款见包内 LICENSE)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -33,7 +33,10 @@
33
33
  "daemon/",
34
34
  "build/cli.mjs",
35
35
  "tools/cli.mjs",
36
- "daemon/run-daemon.mjs"
36
+ "daemon/run-daemon.mjs",
37
+ "app/",
38
+ "BREAKING-CHANGES.md",
39
+ "manual/USAGE.html"
37
40
  ],
38
41
  "pi": {
39
42
  "extensions": [
@@ -141,25 +141,25 @@ description: >-
141
141
  <!-- BEGIN:GEN commands -->
142
142
  | 命令 | 参数形态 | 做什么 | 状态 |
143
143
  | --- | --- | --- | --- |
144
- | `/proactive status` | `无` | 总览:开关、空闲计时、待触发任务、对端在线、上次主动、本小时用量。 | 已实现 · `pi-proactive/src/commands.ts:632` |
145
- | `/proactive on` | `无` | 打开全部「代理自发」行为(空闲触发、模型自调度、跨会话自发请求)。 | 已实现 · `pi-proactive/src/commands.ts:668` |
146
- | `/proactive off` | `无` | 关闭全部「代理自发」行为;已排期任务保留但不再触发。 | 已实现 · `pi-proactive/src/commands.ts:669` |
147
- | `/proactive test` | `无` | 立即模拟一次触发(走完整「护栏 → 投递」链路),用于验证主动能力是否真的生效。 | 已实现 · `pi-proactive/src/commands.ts:758` |
148
- | `/proactive tasks` | `无` | 列出所有待触发任务(编号 / 类型 / 下次到期 / 归属 / 触发次数)。 | 已实现 · `pi-proactive/src/commands.ts:761` |
149
- | `/proactive cancel` | `<id>` | 取消一个任务。 | 已实现 · `pi-proactive/src/commands.ts:787` |
150
- | `/proactive idle` | `on\|off` | 打开 / 关闭空闲触发并设定阈值(on 可带时长参数,省略 = 用上次设定)。 | 已实现 · `pi-proactive/src/commands.ts:827` |
151
- | `/proactive peers` | `[--all\|--idle\|--name <子串>\|--detail <agentId>]` | 列出已注册的对端与在线状态(名字 / 忙闲 / 最后心跳);可加 --all / --idle / --name / --detail。 | 已实现 · `pi-proactive/src/commands.ts:689` |
152
- | `/proactive spawn` | `[名字]` | 创建一个对端会话(子进程 pi --mode rpc);本会话退出时一并终止。 | 已实现 · `pi-proactive/src/commands.ts:735` |
153
- | `/proactive in` | `<时长> <内容>` | 排一个延迟任务(默认只属于当前会话)。 | 已实现 · `pi-proactive/src/commands.ts:805` |
154
- | `/proactive at` | `<HH:MM> <内容>` | 排一个绝对时间任务。 | 已实现 · `pi-proactive/src/commands.ts:806` |
155
- | `/proactive every` | `<时长> <内容>` | 排一个周期任务。 | 已实现 · `pi-proactive/src/commands.ts:807` |
156
- | `/proactive help` | `无` | 打印命令速查表(命令表 / 配置键 / 最常见故障 / 详细文档路径)。 | 已实现 · `pi-proactive/src/commands.ts:842` |
157
- | `/proactive history` | `[peer\|me] [条数]` | 查看最近的主动行为记录(时间 / 类型 / 目标 / 结果 / 被护栏拦下的原因)。 | 已实现 · `pi-proactive/src/commands.ts:765` |
158
- | `/proactive doctor` | `[--json]` | 只读体检:逐项健康判定 + 处置建议(--json 机器可读);不打扰、不耗额度。 | 已实现 · `pi-proactive/src/commands.ts:640` |
159
- | `/proactive progress` | `无` | 项目进度 + ETA:区间 + 置信度 + 归因 + 阻塞项(不给单点数字;口径 = scripts/progress-clock.py)。 | 已实现 · `pi-proactive/src/commands.ts:648` |
160
- | `/proactive tell` | `<peer> <内容> [--kind ask\|task\|notify]` | 人侧给另一个会话下指令(--kind ask\|task\|notify,缺省 ask 要回话);回信落回本会话。 | 已实现 · `pi-proactive/src/commands.ts:755` |
161
- | `/proactive assistant` | `on\|off\|status\|restart` | 常驻助理(7×24)控制:on 打开(后台装,不阻塞会话)/ off 关闭(回执含残留终检)/ status 看宿主·进程·心跳·上次联系 / restart 重装。与终端 CLI 共用同一套安装/卸载实现。 | 已实现 · `pi-proactive/src/commands.ts:724` |
162
- | `/proactive config` | `[undo] [--json]` | 列出全部配置项(当前值 / 缺省值 / 取值范围 / 一句说明),先报警写错的项;config undo 撤销上一次配置改动。 | 已实现 · `pi-proactive/src/commands.ts:655` |
144
+ | `/proactive status` | `无` | 总览:开关、空闲计时、待触发任务、对端在线、上次主动、本小时用量。 | 已实现 · `pi-proactive/src/commands.ts:633` |
145
+ | `/proactive on` | `无` | 打开全部「代理自发」行为(空闲触发、模型自调度、跨会话自发请求)。 | 已实现 · `pi-proactive/src/commands.ts:669` |
146
+ | `/proactive off` | `无` | 关闭全部「代理自发」行为;已排期任务保留但不再触发。 | 已实现 · `pi-proactive/src/commands.ts:670` |
147
+ | `/proactive test` | `无` | 立即模拟一次触发(走完整「护栏 → 投递」链路),用于验证主动能力是否真的生效。 | 已实现 · `pi-proactive/src/commands.ts:806` |
148
+ | `/proactive tasks` | `无` | 列出所有待触发任务(编号 / 类型 / 下次到期 / 归属 / 触发次数)。 | 已实现 · `pi-proactive/src/commands.ts:809` |
149
+ | `/proactive cancel` | `<id>` | 取消一个任务。 | 已实现 · `pi-proactive/src/commands.ts:835` |
150
+ | `/proactive idle` | `on\|off` | 打开 / 关闭空闲触发并设定阈值(on 可带时长参数,省略 = 用上次设定)。 | 已实现 · `pi-proactive/src/commands.ts:875` |
151
+ | `/proactive peers` | `[--all\|--idle\|--name <子串>\|--detail <agentId>]` | 列出已注册的对端与在线状态(名字 / 忙闲 / 最后心跳);可加 --all / --idle / --name / --detail。 | 已实现 · `pi-proactive/src/commands.ts:690` |
152
+ | `/proactive spawn` | `[名字]` | 创建一个对端会话(子进程 pi --mode rpc);本会话退出时一并终止。 | 已实现 · `pi-proactive/src/commands.ts:783` |
153
+ | `/proactive in` | `<时长> <内容>` | 排一个延迟任务(默认只属于当前会话)。 | 已实现 · `pi-proactive/src/commands.ts:853` |
154
+ | `/proactive at` | `<HH:MM> <内容>` | 排一个绝对时间任务。 | 已实现 · `pi-proactive/src/commands.ts:854` |
155
+ | `/proactive every` | `<时长> <内容>` | 排一个周期任务。 | 已实现 · `pi-proactive/src/commands.ts:855` |
156
+ | `/proactive help` | `无` | 打印命令速查表(命令表 / 配置键 / 最常见故障 / 详细文档路径)。 | 已实现 · `pi-proactive/src/commands.ts:890` |
157
+ | `/proactive history` | `[peer\|me] [条数]` | 查看最近的主动行为记录(时间 / 类型 / 目标 / 结果 / 被护栏拦下的原因)。 | 已实现 · `pi-proactive/src/commands.ts:813` |
158
+ | `/proactive doctor` | `[--json]` | 只读体检:逐项健康判定 + 处置建议(--json 机器可读);不打扰、不耗额度。 | 已实现 · `pi-proactive/src/commands.ts:641` |
159
+ | `/proactive progress` | `无` | 项目进度 + ETA:区间 + 置信度 + 归因 + 阻塞项(不给单点数字;口径 = scripts/progress-clock.py)。 | 已实现 · `pi-proactive/src/commands.ts:649` |
160
+ | `/proactive tell` | `<peer> <内容> [--kind ask\|task\|notify]` | 人侧给另一个会话下指令(--kind ask\|task\|notify,缺省 ask 要回话);回信落回本会话。 | 已实现 · `pi-proactive/src/commands.ts:803` |
161
+ | `/proactive assistant` | `on\|off\|status\|restart` | 常驻助理(7×24)控制:on 打开(后台装,不阻塞会话)/ off 关闭(回执含残留终检)/ status 看宿主·进程·心跳·上次联系 / restart 重装。与终端 CLI 共用同一套安装/卸载实现。 | 已实现 · `pi-proactive/src/commands.ts:772` |
162
+ | `/proactive config` | `[undo] [--json]` | 列出全部配置项(当前值 / 缺省值 / 取值范围 / 一句说明),先报警写错的项;config undo 撤销上一次配置改动。 | 已实现 · `pi-proactive/src/commands.ts:656` |
163
163
  <!-- END:GEN commands -->
164
164
 
165
165
  > 命令面真源 = 实现:上表由 `scripts/gen-doc-tables.sh` 从扩展的 `SUBCOMMANDS` + `switch (sub)` 分派自动生成(**共 19 个命令**),**不要手改**。