pi-patch-keeper 0.0.0-stage → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-plugins contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,43 @@
1
+ # pi-patch-keeper — extension load-order guard (pi extension)
2
+
3
+ Stops "force-type" extensions from silently dropping every later section.
4
+
5
+ ## Features
6
+
7
+ - Checks `packages` order in `~/.pi/agent/settings.json` at startup.
8
+ - Statically scans npm extension sources for unconditional `return { systemPrompt }` / `forceSystemPrompt`.
9
+ - Moves known force-type packages to the end of `packages` (settings backed up first).
10
+ - Speaks only when something is wrong; `/patch-keeper` reports, `/patch-keeper fix` repairs.
11
+ - This release contains no machine-specific patch healing (source patches, PiDeck asar, etc.).
12
+
13
+ ## Defaults
14
+
15
+ | Item | Default | Note |
16
+ | --- | --- | --- |
17
+ | Force-type packages | `npm:pi-rtk-optimizer`, `npm:pi-cache-optimizer` | override via `forceTail` |
18
+ | Auto repair | on | backup `settings.json.bak-force-order-*` |
19
+ | Startup notice | only on issue | silent when clean |
20
+ | Scope | `npm:` packages | local path packages are skipped |
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pi install npm:pi-patch-keeper@1.0.0
26
+ pi install ./pi-patch-keeper
27
+ ```
28
+
29
+ ## Config
30
+
31
+ ```json
32
+ { "forceTail": ["npm:pi-rtk-optimizer", "npm:pi-cache-optimizer"] }
33
+ ```
34
+
35
+ ## How it works
36
+
37
+ - Reads each npm package's `pi.extensions` entry and compares where sections and force appear: sections first means a compatibility fallback, not an unconditional force.
38
+ - Reports when any package still follows a force-type package.
39
+
40
+ ## Limits
41
+
42
+ - Only npm packages can be inspected.
43
+ - Reorders loading; never edits package code.
package/README.md CHANGED
@@ -1,3 +1,47 @@
1
- # Temporary Holding Version
1
+ # pi-patch-keeper — 扩展加载顺序守护(pi 扩展)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 防止「force 型扩展」静默吞掉其后所有 section 的 system 注入。
4
+
5
+ ## 来源与借鉴
6
+
7
+ 原创,无上游实现。设计灵感来自本机长期维护 PiDeck / pi 补丁的实践:补丁会被升级覆盖,故需要启动时自检与自愈。
8
+
9
+ ## 功能
10
+
11
+ - 启动时检查 `~/.pi/agent/settings.json` 的 `packages` 顺序。
12
+ - 静态扫描 npm 扩展源码,识别无条件 `return { systemPrompt }` / `forceSystemPrompt` 的包。
13
+ - 已知 force 型包自动移到 `packages` 末尾(先备份 settings)。
14
+ - 启动时只在发现问题时提示;`/patch-keeper` 随时查看状态,`/patch-keeper fix` 手动修复。
15
+ - 发布版不含任何本机补丁自愈逻辑(源码补丁、PiDeck asar 等已剥离)。
16
+
17
+ ## 默认配置
18
+
19
+ | 项 | 默认 | 说明 |
20
+ | --- | --- | --- |
21
+ | force 型包 | `npm:pi-rtk-optimizer`, `npm:pi-cache-optimizer` | `~/.pi/patch-keeper.json` 的 `forceTail` 可覆盖 |
22
+ | 自动修复顺序 | 开 | 写前备份 `settings.json.bak-force-order-*` |
23
+ | 启动提示 | 仅异常时 | 无问题不打扰 |
24
+ | 检测范围 | `npm:` 包 | 本地路径包跳过 |
25
+
26
+ ## 安装
27
+
28
+ ```bash
29
+ pi install npm:pi-patch-keeper@1.0.0
30
+ pi install ./pi-patch-keeper
31
+ ```
32
+
33
+ ## 配置示例
34
+
35
+ ```json
36
+ { "forceTail": ["npm:pi-rtk-optimizer", "npm:pi-cache-optimizer"] }
37
+ ```
38
+
39
+ ## 机制
40
+
41
+ - 读取每个 npm 包的 `pi.extensions` 入口,检查 `before_agent_start` 中 section 写入与 force 出现的先后:先 sections 后 force 视为兼容兜底,不计入疑似。
42
+ - 判定「force 型包之后仍有其他包」即报问题。
43
+
44
+ ## 限制
45
+
46
+ - 只能读 `npm:` 包;读不到源码的包跳过。
47
+ - 不修包内代码,只调整加载顺序。
package/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * pi-patch-keeper — extension load-order guard for the pi coding agent.
3
+ *
4
+ * Force-type extensions (those returning { systemPrompt } / forceSystemPrompt)
5
+ * replace the whole system prompt, silently dropping every section written by
6
+ * handlers that run after them. This extension keeps known force-type packages
7
+ * at the tail of settings.json "packages" and reports packages that still follow
8
+ * one of them.
9
+ *
10
+ * Release edition: no machine-specific patch healing (source patches, PiDeck
11
+ * asar, memory fixes) is included.
12
+ */
13
+ import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ const HOME = os.homedir();
18
+ const SETTINGS = path.join(HOME, ".pi", "agent", "settings.json");
19
+ const CONFIG = path.join(HOME, ".pi", "patch-keeper.json");
20
+ const NPM_DIR = path.join(HOME, ".pi", "agent", "npm", "node_modules");
21
+ const DEFAULT_TAIL = ["npm:pi-rtk-optimizer", "npm:pi-cache-optimizer"];
22
+
23
+ function safeRead(p: string): string | null {
24
+ try { return readFileSync(p, "utf8"); } catch { return null; }
25
+ }
26
+
27
+ function readJson(p: string): Record<string, unknown> | null {
28
+ const t = safeRead(p);
29
+ if (t === null) return null;
30
+ try { return JSON.parse(t) as Record<string, unknown>; } catch { return null; }
31
+ }
32
+
33
+ function forceTail(): string[] {
34
+ const c = readJson(CONFIG);
35
+ const t = c?.forceTail;
36
+ const list = Array.isArray(t) ? t.filter((x): x is string => typeof x === "string") : DEFAULT_TAIL;
37
+ return list.length ? list : DEFAULT_TAIL;
38
+ }
39
+
40
+ function packagesOf(): string[] | null {
41
+ const s = readJson(SETTINGS);
42
+ const p = s?.packages;
43
+ return Array.isArray(p) ? p.filter((x): x is string => typeof x === "string") : null;
44
+ }
45
+
46
+ /** Problems in the current load order (empty = OK). */
47
+ export function forceOrderIssues(packages: string[]): string[] {
48
+ const out: string[] = [];
49
+ const tail = forceTail();
50
+ const present = tail.filter((n) => packages.includes(n));
51
+ if (!present.length) return out;
52
+ const minForce = Math.min(...present.map((n) => packages.indexOf(n)));
53
+ const later = packages.slice(minForce + 1).filter((n) => !tail.includes(n));
54
+ if (later.length) out.push(
55
+ "force-type package is followed by " + later.length + " package(s) (" + later.slice(0, 2).join(" / ") +
56
+ ") — their sections are dropped; move " + tail.join(" / ") + " to the end");
57
+ if (tail.every((n) => packages.includes(n)) && packages.indexOf(tail[0]) > packages.indexOf(tail[1]))
58
+ out.push("force order should be " + tail.join(" -> ") + " (the most likely trigger goes last)");
59
+ return out;
60
+ }
61
+
62
+ /** Reordered packages, or null when already correct. */
63
+ export function normalizeForceOrder(packages: string[]): string[] | null {
64
+ const tail = forceTail();
65
+ const present = tail.filter((n) => packages.includes(n));
66
+ if (!present.length) return null;
67
+ const next = [...packages.filter((n) => !tail.includes(n)), ...present];
68
+ return next.join("\n") === packages.join("\n") ? null : next;
69
+ }
70
+
71
+ /** Repair settings.json order; backs up first. Returns a message or null. */
72
+ function fixForceOrder(): string | null {
73
+ const s = readJson(SETTINGS);
74
+ if (!s) return null;
75
+ const packages = Array.isArray(s.packages) ? (s.packages as string[]) : [];
76
+ const next = normalizeForceOrder(packages);
77
+ if (!next) return null;
78
+ try {
79
+ copyFileSync(SETTINGS, SETTINGS + ".bak-force-order-" + Date.now());
80
+ s.packages = next;
81
+ writeFileSync(SETTINGS, JSON.stringify(s, null, 2), "utf8");
82
+ } catch (e) { return "force order repair failed: " + (e as Error).message; }
83
+ return "force order repaired: " + forceTail().filter((n) => next.includes(n)).join(" / ") + " moved to the end";
84
+ }
85
+
86
+ const FORCE_RE = /return\s*\{\s*systemPrompt|forceSystemPrompt/;
87
+ const SECTION_RE = /sections\s*(\[|\.)/;
88
+
89
+ /** Classify static force candidates: sections-first means a legacy-host fallback. */
90
+ function classify(packages: string[]): { force: string[]; fallback: string[] } {
91
+ const force: string[] = [];
92
+ const fallback: string[] = [];
93
+ const tail = forceTail();
94
+ for (const name of packages) {
95
+ if (!name.startsWith("npm:") || tail.includes(name)) continue;
96
+ const dir = path.join(NPM_DIR, name.slice(4));
97
+ if (!existsSync(dir)) continue;
98
+ let entries: string[] = [];
99
+ const pj = readJson(path.join(dir, "package.json")) as { pi?: { extensions?: unknown } } | null;
100
+ const ex = pj?.pi?.extensions;
101
+ entries = Array.isArray(ex) ? ex.filter((x): x is string => typeof x === "string") : [];
102
+ for (const rel of entries.slice(0, 3)) {
103
+ const src = safeRead(path.join(dir, rel));
104
+ if (src === null) continue;
105
+ const i = src.indexOf("before_agent_start");
106
+ if (i < 0) continue;
107
+ const body = src.slice(i, i + 4000);
108
+ const fi = body.search(FORCE_RE);
109
+ if (fi < 0) continue;
110
+ const si = body.search(SECTION_RE);
111
+ if (si >= 0 && si < fi) fallback.push(name); else force.push(name);
112
+ break;
113
+ }
114
+ }
115
+ return { force, fallback };
116
+ }
117
+
118
+ function report(): string[] {
119
+ const lines: string[] = [];
120
+ const pk = packagesOf();
121
+ if (!pk) { lines.push("settings: cannot read " + SETTINGS); return lines; }
122
+ const issues = forceOrderIssues(pk);
123
+ lines.push("force order: " + (issues.length ? issues[0] : "OK"));
124
+ const tail = forceTail();
125
+ lines.push("force packages: " + tail.filter((n) => pk.includes(n)).join(" / ") + (tail.some((n) => !pk.includes(n)) ? " (partial)" : ""));
126
+ const { force, fallback } = classify(pk);
127
+ lines.push("static scan: " + (force.length ? "force-like " + force.join(" / ") : "none"));
128
+ lines.push("fallbacks: " + (fallback.length ? fallback.join(" / ") : "none"));
129
+ return lines;
130
+ }
131
+
132
+ export default function (pi: ExtensionAPI) {
133
+ pi.registerCommand("patch-keeper", {
134
+ description: "Show / repair extension load order (force-type packages must load last)",
135
+ handler: async (args: string, ctx: ExtensionContext) => {
136
+ const arg = (args || "").trim().toLowerCase();
137
+ if (arg === "fix") {
138
+ const r = fixForceOrder();
139
+ ctx.ui.notify(r ?? "patch-keeper: load order already correct", "info");
140
+ return;
141
+ }
142
+ ctx.ui.notify("patch-keeper\n" + report().join("\n"), "info");
143
+ },
144
+ });
145
+
146
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
147
+ try {
148
+ const pk = packagesOf();
149
+ if (!pk) return;
150
+ const msgs: string[] = [];
151
+ for (const x of forceOrderIssues(pk)) msgs.push("patch-keeper: " + x);
152
+ const fixed = fixForceOrder();
153
+ if (fixed) msgs.push("patch-keeper: " + fixed);
154
+ const { force } = classify(pk);
155
+ const tail = forceTail();
156
+ const mid = force.filter((n) => pk.indexOf(n) < pk.length - 1 && !tail.includes(n));
157
+ if (mid.length) msgs.push("patch-keeper: force-like package not last: " + mid.join(" / ") + " — move it to the end of packages");
158
+ for (const m of msgs) ctx.ui.notify(m, "warning");
159
+ } catch { /* detection must never break the session */ }
160
+ });
161
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "pi-patch-keeper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Guards extension load order so later sections are not silently dropped.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi",
9
+ "pi-extension",
10
+ "pi-coding-agent"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./index.ts"
15
+ ]
16
+ },
17
+ "files": [
18
+ "index.ts",
19
+ "README.md",
20
+ "README.en.md",
21
+ "LICENSE"
22
+ ],
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*"
25
+ }
26
+ }