personal-bot 0.2.2 → 0.2.4

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.
Files changed (45) hide show
  1. package/CONTEXT.md +4 -0
  2. package/README.md +39 -6
  3. package/README.zh-CN.md +39 -6
  4. package/bin/pbot.mjs +3 -0
  5. package/docs/README.md +2 -0
  6. package/docs/file-storage.md +7 -2
  7. package/docs/history.md +40 -0
  8. package/docs/installation-design.md +224 -0
  9. package/docs/release.md +2 -2
  10. package/docs/technical-design.md +7 -2
  11. package/docs/validation.md +18 -2
  12. package/lib/common/package-source.js +16 -0
  13. package/lib/extension/setup-cli.js +136 -0
  14. package/lib/extension/setup-guard.js +11 -0
  15. package/lib/integrations/installation/pi-bridge.js +131 -0
  16. package/lib/integrations/installation/pi-setup.js +193 -0
  17. package/lib/integrations/installation/processes.js +155 -0
  18. package/lib/integrations/installation/workspace.js +21 -0
  19. package/lib/services/installation/setup-service.js +121 -0
  20. package/lib/ui/setup.js +52 -0
  21. package/npm-shrinkwrap.json +29 -4
  22. package/package.json +26 -4
  23. package/resources/agents/coordinator.md +1 -0
  24. package/resources/installer/plugins.json +16 -0
  25. package/resources/prompts/history-summary.md +7 -0
  26. package/scripts/README.md +4 -0
  27. package/src/common/package-source.ts +16 -0
  28. package/src/extension/command.ts +5 -3
  29. package/src/extension/history.ts +71 -0
  30. package/src/extension/lifecycle.ts +4 -2
  31. package/src/extension/setup-cli.ts +109 -0
  32. package/src/extension/setup-guard.ts +12 -0
  33. package/src/integrations/installation/pi-bridge.ts +107 -0
  34. package/src/integrations/installation/pi-setup.ts +162 -0
  35. package/src/integrations/installation/processes.ts +124 -0
  36. package/src/integrations/installation/workspace.ts +18 -0
  37. package/src/integrations/pi/history.ts +107 -0
  38. package/src/model/history.ts +31 -0
  39. package/src/pbot.ts +15 -2
  40. package/src/services/history/history-service.ts +145 -0
  41. package/src/services/installation/setup-service.ts +145 -0
  42. package/src/storage/history-files.ts +25 -0
  43. package/src/tools/history/tool.ts +42 -0
  44. package/src/tools/register.ts +5 -3
  45. package/src/ui/setup.ts +40 -0
@@ -9,14 +9,16 @@ git diff --check
9
9
  npm pack --dry-run
10
10
  ```
11
11
 
12
- npm test 先构建 dist,再串行执行 Node 测试。测试使用真实本地文件、进程、计算与 Pi 1.4.0 SDK。Pi 开发依赖与相关传递组件统一锁定 1.4.0;宿主提供的包仍按 Pi 要求声明为 peer,不增加运行时副本。
12
+ npm test 先构建测试导入 dist 和安装 CLI 的 lib,再串行执行 Node 测试。测试使用真实本地文件、进程、计算与 Pi 1.4.0 SDK。Pi 开发依赖与相关传递组件统一锁定 1.4.0;宿主提供的包声明为 optional peer,不增加运行时副本。发布包包含 bin/lib,扩展仍由 Pi 加载 src,dist 不进入发布包。
13
13
 
14
14
  | 范围 | 检查内容 |
15
15
  | --- | --- |
16
16
  | 领域与基础模块 | 工具契约、状态转换、版本兼容、原子保存和非法路径 |
17
+ | tests/installation/ | 真实子进程、Pi 原生配置、来源与资源过滤保留、过期计划、CLI 打包入口、无模型认证时的初始化分发与拦截 |
17
18
  | tests/smoke/ | 实际 tarball 加载、资料准备、独立 Python setup 脚本 |
18
19
  | tests/e2e/ | Pi SDK/RPC 命令与工具、确认、持久化、重启和 Bash 环境继承 |
19
20
  | 专家协作 | 真实 tmux/Pi 进程、无任务待命、原 inbox、消息顺序、分支归属、通知领取和异常退出 |
21
+ | 历史查询 | 真实 SDK 保存的问答、补充输入、工具过滤、分支隔离、四叉分组、原文与摘要搜索、正文分页、重载复用、空会话不误报、旧目录复制、跨目录读取、缺失原文时保留存档、旧路径清理、按会话 ID 重新关联与工具契约 |
20
22
  | 原生会话存储 | 真实 CLI 的默认 cwd 目录、settings/环境变量优先级、tmux 旧环境覆盖、空待命恢复、进入已停止会话不入队或重放旧任务、绝对路径绑定、身份检查、固定叶节点只读报告与旧布局保留 |
21
23
  | 团队协作 | 轮流顺序、并行上限与轮间汇合、暂停/领取、原文分页、并发保存、mixed 隐私边界与未知结果 |
22
24
  | 团队宿主接入 | Pi 1.4.0 的用户/custom 消息 before_agent_start、turn_end、原生 context 去重、子 reload 不重跑、窄通信工具与实例关闭 |
@@ -31,6 +33,19 @@ npm test 先构建 dist,再串行执行 Node 测试。测试使用真实本地
31
33
 
32
34
  Node 测试不下载第三方 Python 依赖;原生库集成验证见 [Python 说明](../python/README.md)。
33
35
 
36
+ ## 安装器验收(2026-11-03)
37
+
38
+ 本次实现完成以下验证。安装验收使用临时工作区与独立的用户目录、npm cache/prefix,不使用个人凭据或模型请求:
39
+
40
+ - Linux、Node 24.15.0、npm 11.19.1、Pi SDK 1.4.0:`npm ci --ignore-scripts`、`npm run check`、`npm test` 全部通过,99 项测试无跳过;`npm pack --dry-run` 通过。
41
+ - macOS、Node 24.22.2、npm 11.19.1:17 项安装测试通过;实际 tarball 的全局安装与 npm exec 均可执行 `pbot --version`,两种安装都未下载 Pi SDK。现有 Pi 1.5.0 managed launcher 与实际安装根目录的识别通过。
42
+ - Linux 真实终端:从无 Pi 的独立 prefix 安装 Pi 1.5.0,再通过 Pi 安装 registry 的 pbot 0.2.3;重复运行、原生项目信任确认和不启动 Pi 的选择均成功。另将临时 Pi 安装为 1.4.0,确认 setup 通过原生 `pi update` 升级至 1.5.0。工作区仅有 `npm:personal-bot@latest` 声明,未选插件未安装,未直接创建 AGENTS.md。
43
+ - 全局 npm 资源过滤的真实终端验收:工作区声明继承 `extensions: []` 和 `skills: []`,全局配置原文未变化,未启用被禁用的资源。
44
+ - 本次打包的 pbot 与 Pi 1.5.0 SDK 完成 `/pbot init` 分发,创建资料且不调用模型;没有 pbot 时,保护扩展拦截未分发输入。共享 Python 环境使用真实的不可用临时目录,验证失败提示和资料初始化的独立性,不作为 Python 依赖安装成功的证据。
45
+ - 系统 Python 缺少 ensurepip 时,真实 CLI 在安装软件前退出。正向终端验收使用已存在的 Python 3.13.13,没有更改系统 Python。服务器原有 npm 12.0.2 的 `npm ci` 未接受仓库 shrinkwrap,本次仓库回归使用临时安装的 npm 11.19.1,未更换服务器 npm。
46
+
47
+ 仍需验收 Homebrew/各 Linux 包管理器的提权与 tmux 实际安装、macOS 完整安装交互、Pi TUI 登录及模型选择、配套插件的外部服务与模型调用。同一工作区不应并发运行多个安装器;各原生包管理器不提供跨组件事务。全局 Git/本地来源与工作区 npm 来源发生身份冲突时,安装器保留现状并要求先用 Pi 原生配置调整加载范围,不自动移除全局来源。
48
+
34
49
  ## 已批准的真实模型冒烟(0.1.3)
35
50
 
36
51
  2026-10-28 在 Linux、Node.js 24.15.0、tmux 3.3a、Pi 1.1.0 下验收当时的 0.1.3 源码。0.2.0 改用原生会话位置及开发依赖升级至 Pi 1.4.0 后均未追加真实模型冒烟,以下结果不作为新布局或新宿主版本的模型验收。主会话与两个独立子进程均使用 openai-codex/gpt-6-astra、low 思考级别;复用同一对成员,roundRobin 和 parallel 各运行一轮。这是同型号多 agent 验收,不是异构模型组合验收。
@@ -57,12 +72,13 @@ Node 测试不下载第三方 Python 依赖;原生库集成验证见 [Python
57
72
 
58
73
  | 范围 | 验收重点 |
59
74
  | --- | --- |
75
+ | 历史摘要 | 真实模型的摘要质量、递归汇总后按大意回溯、取消与恢复的实际模型请求、长会话扫描性能及人工浏览 |
60
76
  | 真实模型与专家 | 0.2.0 原生会话存储下的委派交付与认证模型恢复、运行中 steer/follow-up、独立复核、通知推进和实际用量 |
61
77
  | 真实模型团队 | 异构模型组合、三人及多轮讨论、并行轮间互相引用、自动重试/恢复、人工 mixed 输入、团队/私人工作切换、长时间运行及实际账单 |
62
78
  | Task Conductor | 实际多节点产物、至少两轮周期任务、Pi 关闭或重载后的查询与防重复提交 |
63
79
  | CLI/MCP | 认证、发现、重连、动态工具、取消与业务结果 |
64
80
  | TUI/RPC | 人工 attach、焦点、批准、查看器、长时间运行和会话导航 |
65
- | npm 分发 | 全新依赖安装、Pi 安装加载、OIDC 发布与公开 registry |
81
+ | npm 分发 | 包含安装器的新版本 OIDC 发布、公开 registry 的 npx 入口与用户环境反馈 |
66
82
  | 平台与存储 | macOS 整体验收、断电持久性与文件系统差异 |
67
83
 
68
84
  研究与资料方法使用真实任务资料验收,重点检查来源追溯、口径一致、缺失数据处理、历史前提复核、产物引用和结项依据。知识沉淀需检查是否提炼方法与口径,以及后续任务能否据此重新取数。探索任务应区分实验失败与假设被否定。
@@ -0,0 +1,16 @@
1
+ import { prerelease, satisfies, valid } from "semver";
2
+ export function npmName(source) {
3
+ return /^npm:((?:@[a-z0-9._-]+\/)?[a-z0-9._-]+)(?:@[^\s]+)?$/.exec(source)?.[1];
4
+ }
5
+ export function ordinaryNpm(source) {
6
+ const name = npmName(source);
7
+ return name !== undefined && (source === `npm:${name}` || source === `npm:${name}@latest`);
8
+ }
9
+ export function checkStableVersion(version, nodeRange) {
10
+ if (!valid(version) || prerelease(version))
11
+ throw new Error(`latest 不是稳定版本:${version}`);
12
+ if (nodeRange && !satisfies(process.versions.node, nodeRange)) {
13
+ throw new Error(`目标版本要求 Node.js ${nodeRange},当前为 ${process.versions.node}。请先升级 Node.js。`);
14
+ }
15
+ return version;
16
+ }
@@ -0,0 +1,136 @@
1
+ import { mkdir, readFile, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+ import { executeSetup, packageChoices, parseCatalog, parseSetupArgs } from "../services/installation/setup-service.js";
5
+ import { npmName, ordinaryNpm } from "../common/package-source.js";
6
+ import { PiBootstrapRequired, PiSetup, piPackageName } from "../integrations/installation/pi-setup.js";
7
+ import { inspectSystem, SetupProcesses } from "../integrations/installation/processes.js";
8
+ import { resolveWorkspace } from "../integrations/installation/workspace.js";
9
+ import { SetupUI } from "../ui/setup.js";
10
+ const root = new URL("../../", import.meta.url);
11
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
12
+ export async function main(args) {
13
+ let options;
14
+ try {
15
+ options = parseSetupArgs(args);
16
+ }
17
+ catch (error) {
18
+ console.error(String(error));
19
+ return 2;
20
+ }
21
+ const manifest = JSON.parse(await readFile(new URL("package.json", root), "utf8"));
22
+ if (options.action === "version") {
23
+ console.log(manifest.version);
24
+ return 0;
25
+ }
26
+ if (options.action === "help") {
27
+ console.log("Personal Bot " + manifest.version + "\n\n用法:pbot setup [--workspace <目录>]\n" +
28
+ " npx personal-bot@latest setup\n\nsetup 安装或升级 Pi、pbot 和选中的插件,然后启动 Pi 执行 /pbot init。\n" +
29
+ "支持 Linux/macOS,需要 Node.js 22.19+、npm 和 Python 3.11+。\n--help 显示帮助;--version 显示安装器版本。");
30
+ return 0;
31
+ }
32
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
33
+ console.error("setup 需要交互终端。请在终端运行 npx personal-bot@latest setup。");
34
+ return 2;
35
+ }
36
+ const controller = new AbortController();
37
+ const cancel = () => controller.abort(new Error("安装已取消;已完成的步骤保留。"));
38
+ process.on("SIGINT", cancel);
39
+ process.on("SIGTERM", cancel);
40
+ const ui = new SetupUI(controller), processes = new SetupProcesses(controller.signal);
41
+ try {
42
+ const cwd = process.cwd();
43
+ ui.text("Personal Bot " + manifest.version + " — 安装与升级\n正在检查 Node/npm、Python 和 tmux…");
44
+ const system = await inspectSystem(processes, cwd);
45
+ const supplied = options.workspace ?? await ui.ask("工作区目录(当前、已有或新建目录)", cwd);
46
+ const workspace = await resolveWorkspace(supplied, cwd, homedir());
47
+ const host = new PiSetup(processes, system.npm, cwd);
48
+ let pi = await host.inspectPi();
49
+ let installed;
50
+ try {
51
+ installed = await host.inspectPackages(pi, workspace);
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof PiBootstrapRequired))
55
+ throw error;
56
+ const version = await host.latest(piPackageName);
57
+ ui.text(error.message + "\n需先安装 Pi 才能使用其原生接口核对这些来源。目标版本:" + version);
58
+ if (!await ui.confirm("先通过 npm 安装 Pi,再继续选择工作区插件"))
59
+ return 130;
60
+ pi = await host.ensurePi(undefined, version);
61
+ installed = await host.inspectPackages(pi, workspace);
62
+ }
63
+ const catalog = parseCatalog(JSON.parse(await readFile(new URL("resources/installer/plugins.json", root), "utf8")));
64
+ const choices = packageChoices(catalog, installed), optional = choices.filter(choice => !choice.required);
65
+ ui.text("\nPersonal Bot 必装。选择配套插件(取消勾选不会卸载已有插件):");
66
+ const selected = await ui.selectMany(optional.map(choice => choice.plugin.label + " — " + choice.plugin.description), optional.flatMap((choice, index) => choice.selected ? [index] : []));
67
+ optional.forEach((choice, index) => { choice.selected = selected.includes(index); });
68
+ for (const choice of choices.filter(item => item.selected)) {
69
+ if (choice.existing.some(pkg => !ordinaryNpm(pkg.source))) {
70
+ ui.text("\n" + choice.plugin.label + " 的已有来源:\n" + choice.existing.map(pkg => " " + pkg.scope + ": " + pkg.source + " (" + (pkg.version ?? "版本未知") + ")").join("\n"));
71
+ choice.keep = !await ui.confirm("切换到 npm 最新稳定版", false);
72
+ }
73
+ if (choice.keep) {
74
+ if (choice.existing.length !== 1 || !choice.existing[0]?.installedPath)
75
+ choice.error = "保留来源未安装或存在多份来源,请先通过 Pi 原生配置处理。";
76
+ }
77
+ else {
78
+ const others = choice.existing.filter(pkg => !npmName(pkg.source));
79
+ if (others.length > 1 || others.some(pkg => pkg.scope === "user")) {
80
+ choice.error = "请先用 pi config 调整全局或重复来源的加载范围,再切换到 npm。";
81
+ }
82
+ else {
83
+ try {
84
+ choice.target = await host.latest(npmName(choice.plugin.source));
85
+ }
86
+ catch (error) {
87
+ controller.signal.throwIfAborted();
88
+ choice.error = String(error);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ const plan = { workspace, piVersion: await host.latest(piPackageName), packages: choices,
94
+ ...(pi ? { pi } : {}), ...(system.tmux ? { tmux: system.tmux } : {}) };
95
+ ui.text("\n安装计划\n工作区:" + workspace + "\nPi:" + (pi ? pi.command + " (" + pi.version + ")" : "通过 npm 全局安装") + " → " + plan.piVersion);
96
+ if (system.tmux)
97
+ ui.text("tmux:" + [system.tmux.command, ...system.tmux.args].map(quote).join(" "));
98
+ for (const choice of choices) {
99
+ ui.text(" " + choice.plugin.label + ": " + (!choice.selected ? "本次跳过" : choice.error ??
100
+ (choice.keep ? "保留 " + choice.existing[0]?.source : (choice.existing.map(pkg => pkg.version ?? "未知").join(", ") || "未安装") + " → " + choice.target)));
101
+ if (choice.selected && choice.existing.some(pkg => pkg.filtered))
102
+ ui.text(" 保留已有资源过滤;如 pbot 被禁用,请通过 pi config --local 启用。");
103
+ }
104
+ const blocking = choices.find(choice => choice.required && choice.error);
105
+ if (blocking)
106
+ throw new Error(blocking.error);
107
+ ui.text("升级前请停止活动 pbot 子会话和相关 Python 脚本。Pi 为当前用户共用,插件只处理此工作区。");
108
+ if (!await ui.confirm("执行以上计划"))
109
+ return 130;
110
+ await mkdir(workspace, { recursive: true });
111
+ if (await realpath(workspace) !== workspace)
112
+ throw new Error("工作区路径已发生变化,请重新运行 setup。");
113
+ const result = await executeSetup(plan, host, item => ui.text("[" + item.state + "] " + item.name + ": " + item.detail), controller.signal);
114
+ const failed = result.results.some(item => item.state === "failed");
115
+ if (!result.pi)
116
+ return 1;
117
+ ui.text("\n软件安装阶段结束。初始化、Python 环境和外部服务的状态由 Pi/pbot/插件分别报告。");
118
+ if (await ui.confirm(failed ? "部分插件失败,是否仍先启动 Pi" : "立即启动 Pi", !failed)) {
119
+ process.stdin.pause();
120
+ await host.start(result.pi, workspace);
121
+ }
122
+ else {
123
+ ui.text("稍后执行:\ncd " + quote(workspace) + "\n" + quote(result.pi.command) + " --extension " +
124
+ quote(fileURLToPath(new URL("extension/setup-guard.js", new URL("../", import.meta.url)))) + " '/pbot init'");
125
+ }
126
+ return failed ? 1 : 0;
127
+ }
128
+ catch (error) {
129
+ console.error(error instanceof Error ? error.message : String(error));
130
+ return controller.signal.aborted ? 130 : 1;
131
+ }
132
+ finally {
133
+ process.off("SIGINT", cancel);
134
+ process.off("SIGTERM", cancel);
135
+ }
136
+ }
@@ -0,0 +1,11 @@
1
+ // Native extension commands run before input handlers. Reaching this handler for
2
+ // /pbot init means it was not dispatched; never let the setup input reach a model.
3
+ export default function setupGuard(pi) {
4
+ pi.on("input", (event, ctx) => {
5
+ if (event.text.trim() !== "/pbot init")
6
+ return { action: "continue" };
7
+ ctx.ui.notify("pbot 未加载或命令存在冲突。请检查项目信任和 pi config --local 后重新运行 setup。", "error");
8
+ ctx.shutdown();
9
+ return { action: "handled" };
10
+ });
11
+ }
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { npmName } from "../../common/package-source.js";
6
+ async function sdkAt(root) {
7
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
8
+ if (manifest.name !== "@earendil-works/pi-coding-agent")
9
+ throw new Error("不是支持的 Pi 安装。");
10
+ return import(pathToFileURL(join(root, "dist/index.js")).href);
11
+ }
12
+ const sourceOf = (entry) => {
13
+ if (typeof entry === "string")
14
+ return entry;
15
+ if (entry && typeof entry === "object" && "source" in entry && typeof entry.source === "string")
16
+ return entry.source;
17
+ throw new Error("无效的 Pi packages 声明。");
18
+ };
19
+ export async function inspectPackages(root, workspace) {
20
+ const sdk = await sdkAt(root), agentDir = sdk.getAgentDir();
21
+ // Inspect project declarations as data only. This does not load extensions, execute
22
+ // project npmCommand, grant project trust, or persist migrated settings.
23
+ const settings = sdk.SettingsManager.fromStorage({ withLock(scope, read) {
24
+ const file = scope === "global" ? join(agentDir, "settings.json") : join(workspace, sdk.CONFIG_DIR_NAME, "settings.json");
25
+ let content;
26
+ try {
27
+ content = readFileSync(file, "utf8");
28
+ }
29
+ catch (error) {
30
+ if (error.code !== "ENOENT")
31
+ throw error;
32
+ }
33
+ if (content !== undefined) {
34
+ const value = JSON.parse(content);
35
+ if (!value || typeof value !== "object" || Array.isArray(value))
36
+ throw new Error(`无效的 Pi 配置:${file}`);
37
+ const packages = value.packages;
38
+ if (packages !== undefined) {
39
+ if (!Array.isArray(packages))
40
+ throw new Error(`packages 必须是数组:${file}`);
41
+ packages.forEach(sourceOf);
42
+ }
43
+ }
44
+ if (read(content) !== undefined)
45
+ throw new Error("安装检查不得修改 Pi 配置。");
46
+ } }, { projectTrusted: true });
47
+ const errors = settings.drainErrors();
48
+ if (errors.length)
49
+ throw new Error(errors.map(item => `${item.scope}: ${item.error.message}`).join("\n"));
50
+ const manager = new sdk.DefaultPackageManager({ cwd: workspace, agentDir, settingsManager: settings });
51
+ const result = [];
52
+ for (const pkg of manager.listConfiguredPackages()) {
53
+ const entries = (pkg.scope === "project" ? settings.getProjectSettings() : settings.getGlobalSettings()).packages ?? [];
54
+ const entry = entries.find(item => sourceOf(item) === pkg.source);
55
+ let name = npmName(pkg.source), version;
56
+ if (pkg.installedPath) {
57
+ let file = pkg.installedPath;
58
+ if ((await stat(file)).isDirectory())
59
+ file = join(file, "package.json");
60
+ else
61
+ file = ""; // Single-file extensions have no npm identity.
62
+ if (file) {
63
+ try {
64
+ const manifest = JSON.parse(await readFile(file, "utf8"));
65
+ if (name && manifest.name !== name)
66
+ throw new Error(`安装包身份不匹配:${pkg.source}`);
67
+ name = typeof manifest.name === "string" ? manifest.name : name;
68
+ version = typeof manifest.version === "string" ? manifest.version : undefined;
69
+ }
70
+ catch (error) {
71
+ if (error.code !== "ENOENT")
72
+ throw error;
73
+ }
74
+ }
75
+ }
76
+ result.push({ source: pkg.source, scope: pkg.scope, filtered: pkg.filtered, entry,
77
+ ...(pkg.installedPath ? { installedPath: pkg.installedPath } : {}), ...(name ? { name } : {}), ...(version ? { version } : {}) });
78
+ }
79
+ return result;
80
+ }
81
+ export async function replaceProjectSource(root, workspace, old, target) {
82
+ const fromProject = old.scope === "project" && !npmName(old.source);
83
+ const fromUser = old.scope === "user" && !!npmName(old.source);
84
+ if (!fromProject && !fromUser)
85
+ throw new Error("此操作只替换工作区自定义来源,或继承全局 npm 资源过滤。");
86
+ const sdk = await sdkAt(root);
87
+ const settings = sdk.SettingsManager.create(workspace, sdk.getAgentDir(), { projectTrusted: true });
88
+ const errors = settings.drainErrors();
89
+ if (errors.length)
90
+ throw new Error(errors.map(item => item.error.message).join("\n"));
91
+ const packages = settings.getProjectSettings().packages ?? [];
92
+ const originals = fromProject ? packages : settings.getGlobalSettings().packages ?? [];
93
+ const previous = originals.find(entry => sourceOf(entry) === old.source);
94
+ if (previous === undefined || JSON.stringify(previous) !== JSON.stringify(old.entry))
95
+ throw new Error("插件配置已发生变化,请重新运行 setup。");
96
+ if (!packages.some(entry => sourceOf(entry) === target))
97
+ throw new Error("目标包尚未安装,保留原声明。");
98
+ const manager = new sdk.DefaultPackageManager({ cwd: workspace, agentDir: sdk.getAgentDir(), settingsManager: settings });
99
+ const installed = manager.getInstalledPath(target, "project");
100
+ if (!installed || !npmName(target))
101
+ throw new Error("目标 npm 包尚未安装,保留原声明。");
102
+ const manifest = JSON.parse(await readFile(join(installed, "package.json"), "utf8"));
103
+ if (manifest.name !== npmName(target))
104
+ throw new Error("目标安装包身份不符,保留原声明。");
105
+ // Change only declarations; do not call remove(), which deletes managed git caches.
106
+ const replacement = typeof previous === "string" ? target : { ...previous, source: target };
107
+ const next = fromProject
108
+ ? packages.filter(entry => sourceOf(entry) !== target).map(entry => sourceOf(entry) === old.source ? replacement : entry)
109
+ : packages.map(entry => sourceOf(entry) === target ? replacement : entry);
110
+ settings.setProjectPackages(next);
111
+ await settings.flush();
112
+ const failures = settings.drainErrors();
113
+ if (failures.length)
114
+ throw new Error(failures.map(item => item.error.message).join("\n"));
115
+ }
116
+ async function main() {
117
+ const [action, root, workspace, input] = process.argv.slice(2);
118
+ if (!root || !workspace)
119
+ throw new Error("缺少 Pi 路径或工作区。");
120
+ if (action === "inspect")
121
+ console.log(JSON.stringify(await inspectPackages(root, workspace)));
122
+ else if (action === "replace" && input) {
123
+ const { old, target } = JSON.parse(input);
124
+ await replaceProjectSource(root, workspace, old, target);
125
+ }
126
+ else
127
+ throw new Error("无效的安装检查操作。");
128
+ }
129
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
130
+ await main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
131
+ }
@@ -0,0 +1,193 @@
1
+ import { access, readFile, realpath } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import { checkStableVersion, npmName } from "../../common/package-source.js";
7
+ import { findExecutable, npmGlobalBinary, SetupProcesses } from "./processes.js";
8
+ export const piPackageName = "@earendil-works/pi-coding-agent";
9
+ export class PiBootstrapRequired extends Error {
10
+ }
11
+ const bridge = fileURLToPath(new URL("./pi-bridge.js", import.meta.url));
12
+ const guard = fileURLToPath(new URL("../../extension/setup-guard.js", import.meta.url));
13
+ async function packageRoot(command, version, env) {
14
+ const resolved = await realpath(command);
15
+ let directory = dirname(resolved);
16
+ while (true) {
17
+ try {
18
+ const pkg = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
19
+ if (pkg.name === piPackageName && pkg.version === version)
20
+ return directory;
21
+ }
22
+ catch (error) {
23
+ if (error.code !== "ENOENT")
24
+ throw error;
25
+ }
26
+ const parent = dirname(directory);
27
+ if (parent === directory)
28
+ break;
29
+ directory = parent;
30
+ }
31
+ const roots = [join(dirname(dirname(resolved)), "install"), env.PI_MANAGED_INSTALL_ROOT].filter((root) => !!root);
32
+ for (const root of roots) {
33
+ try {
34
+ // The native managed launcher resides beside install/. Only accept a matching
35
+ // launcher, not an unrelated PI_MANAGED_INSTALL_ROOT inherited from another Pi.
36
+ const launcher = await realpath(join(dirname(root), "bin/pi"));
37
+ if (launcher !== resolved)
38
+ continue;
39
+ const current = (await readFile(join(root, "current-version"), "utf8")).trim();
40
+ if (current !== version || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(current))
41
+ continue;
42
+ const candidate = join(root, "releases", current, "node_modules", piPackageName);
43
+ const pkg = JSON.parse(await readFile(join(candidate, "package.json"), "utf8"));
44
+ if (pkg.name === piPackageName && pkg.version === version)
45
+ return candidate;
46
+ }
47
+ catch (error) {
48
+ if (error.code !== "ENOENT")
49
+ throw error;
50
+ }
51
+ }
52
+ throw new Error(`无法定位 ${command} 对应的 Pi 包。请先按此 Pi 的原生安装方式修复,不会覆盖该命令。`);
53
+ }
54
+ export class PiSetup {
55
+ processes;
56
+ npm;
57
+ cwd;
58
+ constructor(processes, npm, cwd) {
59
+ this.processes = processes;
60
+ this.npm = npm;
61
+ this.cwd = cwd;
62
+ }
63
+ async inspectPi(command) {
64
+ const executable = command ?? await findExecutable("pi", this.processes.env);
65
+ if (!executable)
66
+ return undefined;
67
+ const version = await this.processes.run(executable, ["--version"], this.cwd);
68
+ if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version))
69
+ throw new Error(`PATH 中的 ${executable} 不是可识别的 Pi。`);
70
+ return { command: executable, version, root: await packageRoot(executable, version, this.processes.env) };
71
+ }
72
+ async latest(name) {
73
+ const text = await this.processes.run(this.npm, ["view", `${name}@latest`, "version", "engines", "--json",
74
+ "--fetch-retries=0", "--fetch-timeout=15000"], this.cwd);
75
+ const value = JSON.parse(text);
76
+ if (typeof value.version !== "string")
77
+ throw new Error(`无法取得 ${name} 的最新版本。`);
78
+ return checkStableVersion(value.version, value.engines?.node);
79
+ }
80
+ async inspectPackages(pi, workspace) {
81
+ if (!pi) {
82
+ // A fresh machine can be planned without loading a Pi SDK. Existing declarations
83
+ // require Pi's own resolver; don't guess git cache paths or package identities.
84
+ const agentDir = this.processes.env.PI_CODING_AGENT_DIR ?? join(this.processes.env.HOME ?? homedir(), ".pi/agent");
85
+ for (const file of [join(agentDir, "settings.json"), join(workspace, ".pi/settings.json")]) {
86
+ try {
87
+ const settings = JSON.parse(await readFile(file, "utf8"));
88
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
89
+ throw new Error(`无效的 Pi 配置:${file}`);
90
+ const packages = settings.packages;
91
+ if (packages !== undefined && !Array.isArray(packages))
92
+ throw new Error(`packages 必须是数组:${file}`);
93
+ if (Array.isArray(packages) && packages.length > 0) {
94
+ throw new PiBootstrapRequired(`已有插件配置但未找到 Pi:${file}`);
95
+ }
96
+ }
97
+ catch (error) {
98
+ if (error.code !== "ENOENT")
99
+ throw error;
100
+ }
101
+ }
102
+ return [];
103
+ }
104
+ const data = await this.processes.run(process.execPath, [bridge, "inspect", pi.root, workspace], this.cwd);
105
+ return JSON.parse(data);
106
+ }
107
+ async installTmux(command) {
108
+ await this.processes.run(command.command, command.args, this.cwd, { interactive: true });
109
+ const tmux = await findExecutable("tmux", this.processes.env);
110
+ if (!tmux)
111
+ throw new Error("tmux 安装后仍不在 PATH 中,请修复 PATH 后重试。");
112
+ await this.processes.run(tmux, ["-V"], this.cwd);
113
+ }
114
+ async ensurePi(existing, version) {
115
+ if (existing) {
116
+ if (existing.version !== version)
117
+ await this.processes.run(existing.command, ["update"], this.cwd, { interactive: true });
118
+ const installed = await this.inspectPi(existing.command);
119
+ if (!installed || installed.version !== version)
120
+ throw new Error(`Pi 实际版本 ${installed?.version ?? "未知"} 与目标 ${version} 不同。请用原安装方式更新后重试。`);
121
+ return installed;
122
+ }
123
+ const prefix = await this.processes.run(this.npm, ["prefix", "--global"], this.cwd);
124
+ if (!prefix || !prefix.startsWith("/"))
125
+ throw new Error("无法确定 npm 全局安装目录。");
126
+ try {
127
+ await access(prefix, constants.W_OK);
128
+ }
129
+ catch {
130
+ throw new Error(`npm 全局目录不可写:${prefix}。请先配置当前用户的 npm prefix 或 Node.js 版本管理器。`);
131
+ }
132
+ await this.processes.run(this.npm, ["install", "--global", "--ignore-scripts", "--engine-strict",
133
+ `${piPackageName}@${version}`], this.cwd, { interactive: true });
134
+ const installed = await this.inspectPi(npmGlobalBinary(prefix));
135
+ if (!installed || installed.version !== version)
136
+ throw new Error("Pi 安装版本验证失败。");
137
+ const onPath = await findExecutable("pi", this.processes.env);
138
+ if (!onPath || await realpath(onPath) !== await realpath(installed.command)) {
139
+ throw new Error(`Pi 已安装到 ${installed.command},请把 ${join(prefix, "bin")} 加入 PATH 后重新运行 setup。`);
140
+ }
141
+ return installed;
142
+ }
143
+ async verifyKeptPackage(pi, workspace, choice) {
144
+ const old = choice.existing[0];
145
+ if (choice.existing.length !== 1 || !old)
146
+ throw new Error("保留来源不可用或存在多份来源,请先通过 pi config 处理。");
147
+ const current = (await this.inspectPackages(pi, workspace)).find(pkg => pkg.source === old.source && pkg.scope === old.scope);
148
+ if (!current?.installedPath || current.name !== npmName(choice.plugin.source) || JSON.stringify(current.entry) !== JSON.stringify(old.entry)) {
149
+ throw new Error("保留来源已变化或不可读取,请重新运行 setup。");
150
+ }
151
+ return current.source;
152
+ }
153
+ async installPackage(pi, workspace, choice) {
154
+ const name = npmName(choice.plugin.source);
155
+ if (!name || !choice.target)
156
+ throw new Error("缺少目标包或目标版本。");
157
+ // Re-read just before mutation so a stale plan cannot replace a changed source.
158
+ const current = (await this.inspectPackages(pi, workspace)).filter(pkg => pkg.name === name);
159
+ for (const old of choice.existing) {
160
+ const actual = current.find(pkg => pkg.scope === old.scope && pkg.source === old.source);
161
+ if (!actual || JSON.stringify(actual.entry) !== JSON.stringify(old.entry))
162
+ throw new Error(`${name} 配置已改变,请重新运行 setup。`);
163
+ }
164
+ if (current.some(pkg => !choice.existing.some(old => old.scope === pkg.scope && old.source === pkg.source) &&
165
+ !(pkg.scope === "user" && npmName(pkg.source) && choice.existing.some(old => old.scope === "project" && npmName(old.source))))) {
166
+ throw new Error(`${name} 出现新的来源,请重新运行 setup。`);
167
+ }
168
+ const otherSources = choice.existing.filter(pkg => !npmName(pkg.source));
169
+ if (otherSources.length > 1 || otherSources.some(pkg => pkg.scope === "user")) {
170
+ throw new Error("存在不同身份的全局来源或多份自定义来源。请先用 pi config 调整加载范围后重试。");
171
+ }
172
+ const globals = choice.existing.filter(pkg => pkg.scope === "user" && npmName(pkg.source));
173
+ if (globals.length > 1 && globals.some(pkg => pkg.filtered))
174
+ throw new Error("存在多份全局 npm 资源过滤,请先用 pi config 调整后重试。");
175
+ const template = otherSources[0] ?? globals.find(pkg => pkg.filtered);
176
+ const target = `npm:${name}@latest`;
177
+ await this.processes.run(pi.command, ["install", "--local", target], workspace, { interactive: true });
178
+ const records = await this.inspectPackages(pi, workspace);
179
+ const installed = records.find(pkg => pkg.scope === "project" && pkg.source === target);
180
+ if (!installed?.installedPath || installed.version !== choice.target) {
181
+ throw new Error(`${name} 实际版本 ${installed?.version ?? "未知"} 与目标 ${choice.target} 不同,请检查 registry 后重试。`);
182
+ }
183
+ if (template) {
184
+ await this.processes.run(process.execPath, [bridge, "replace", pi.root, workspace,
185
+ JSON.stringify({ old: template, target })], this.cwd);
186
+ }
187
+ return installed.version;
188
+ }
189
+ async start(pi, workspace) {
190
+ await access(guard, constants.R_OK);
191
+ await this.processes.run(pi.command, ["--extension", guard, "/pbot init"], resolve(workspace), { interactive: true, timeoutMs: 0 });
192
+ }
193
+ }