@zhushanwen/pi-llm-shared 0.3.0 → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-llm-shared",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared LLM invocation library for Pi extensions — model resolution (ref exact only), LLM calling (completeSimple), and config read/write with mtime caching. Shared library, not a Pi extension.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -17,9 +17,12 @@
17
17
  "src/",
18
18
  "index.ts"
19
19
  ],
20
+ "dependencies": {
21
+ "@zhushanwen/pi-file-lock": "0.1.1"
22
+ },
20
23
  "peerDependencies": {
21
- "@earendil-works/pi-ai": "*",
22
- "@earendil-works/pi-coding-agent": "*"
24
+ "@earendil-works/pi-ai": "^0.84.1",
25
+ "@earendil-works/pi-coding-agent": "^0.84.1"
23
26
  },
24
27
  "peerDependenciesMeta": {
25
28
  "@earendil-works/pi-ai": {
@@ -30,8 +33,9 @@
30
33
  }
31
34
  },
32
35
  "devDependencies": {
33
- "@earendil-works/pi-ai": "*",
34
- "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-ai": "^0.84.2",
37
+ "@earendil-works/pi-coding-agent": "^0.84.2",
38
+ "@vitest/coverage-v8": "^4.1.9",
35
39
  "vitest": "^4.1.8"
36
40
  },
37
41
  "scripts": {
@@ -1,10 +1,11 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
2
2
  import * as fs from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
 
7
7
  import { clearConfigCache, getConfigPath, loadConfig, saveConfig } from "../config.ts";
8
+ import * as fileLock from "@zhushanwen/pi-file-lock";
8
9
 
9
10
  // node:fs 的 ESM namespace 不可配置,vi.spyOn 对具名导出失效(vitest 限制)。
10
11
  // 用 vi.mock 包装 readFileSync/renameSync(默认走 actual,个别 test override),
@@ -128,6 +129,12 @@ describe("loadConfig", () => {
128
129
  });
129
130
 
130
131
  describe("saveConfig", () => {
132
+ /** tmp 残留断言(D1e 唯一化后 tmp 名为 <path>.tmp_<pid>_<rand>,用前缀 glob 断言)。 */
133
+ function tmpResidues(pkg: string): string[] {
134
+ const cfgDir = join(dir, "config");
135
+ return readdirSync(cfgDir).filter((f) => f.startsWith(`${pkg}-ext-config.json.tmp`));
136
+ }
137
+
131
138
  it("TC16 原子写:文件落盘 + 内容正确 + 无 tmp 残留", () => {
132
139
  const result = saveConfig("test", { b: 2 });
133
140
  expect(result.success).toBe(true);
@@ -135,7 +142,7 @@ describe("saveConfig", () => {
135
142
  const cfgPath = join(dir, "config", "test-ext-config.json");
136
143
  expect(existsSync(cfgPath)).toBe(true);
137
144
  expect(JSON.parse(readFileSync(cfgPath, "utf-8"))).toEqual({ b: 2 });
138
- expect(existsSync(`${cfgPath}.tmp`)).toBe(false); // 无 tmp 残留
145
+ expect(tmpResidues("test")).toEqual([]); // 无 tmp 残留(唯一化 tmp 名,前缀断言)
139
146
  });
140
147
 
141
148
  it("TC16 文件 mode 0o600", () => {
@@ -164,8 +171,8 @@ describe("saveConfig", () => {
164
171
 
165
172
  expect(result.success).toBe(false);
166
173
  expect(result.error).toContain("EPERM");
167
- // tmp 文件被 catch 块的 unlinkSync 清理
168
- expect(existsSync(join(dir, "config", "fail-ext-config.json.tmp"))).toBe(false);
174
+ // tmp 文件被 catch 块的 unlinkSync 清理(唯一化 tmp 名,前缀断言)
175
+ expect(tmpResidues("fail")).toEqual([]);
169
176
  // 目标文件未被创建(rename 失败)
170
177
  expect(existsSync(join(dir, "config", "fail-ext-config.json"))).toBe(false);
171
178
  });
@@ -185,9 +192,10 @@ describe("saveConfig", () => {
185
192
  expect(onWarning).toHaveBeenCalledTimes(1);
186
193
  const warning = String(onWarning.mock.calls[0][0]);
187
194
  expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "enoent-ext-config.json") + "'");
195
+ expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "enoent-ext-config.json") + "'");
188
196
  expect(warning).toContain("ENOENT");
189
- // tmp 清理 + 目标未创建
190
- expect(existsSync(join(dir, "config", "enoent-ext-config.json.tmp"))).toBe(false);
197
+ // tmp 清理(前缀断言)+ 目标未创建
198
+ expect(tmpResidues("enoent")).toEqual([]);
191
199
  expect(existsSync(join(dir, "config", "enoent-ext-config.json"))).toBe(false);
192
200
  });
193
201
 
@@ -204,8 +212,8 @@ describe("saveConfig", () => {
204
212
  const warning = String(onWarning.mock.calls[0][0]);
205
213
  expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "eperm-ext-config.json") + "'");
206
214
  expect(warning).toContain("EPERM");
207
- // tmp 清理(Windows 目标占用场景 rename 失败后 tmp 残留被清理)
208
- expect(existsSync(join(dir, "config", "eperm-ext-config.json.tmp"))).toBe(false);
215
+ // tmp 清理(Windows 目标占用场景 rename 失败后 tmp 残留被清理;前缀断言)
216
+ expect(tmpResidues("eperm")).toEqual([]);
209
217
  expect(existsSync(join(dir, "config", "eperm-ext-config.json"))).toBe(false);
210
218
  });
211
219
 
@@ -214,4 +222,26 @@ describe("saveConfig", () => {
214
222
  expect(saveConfig("test", { v: 2 }).success).toBe(true);
215
223
  expect(JSON.parse(readFileSync(join(dir, "config", "test-ext-config.json"), "utf-8"))).toEqual({ v: 2 });
216
224
  });
225
+
226
+ it("W4 锁不可用(ELOCKED 预算耗尽)→ {success:false} + 不降级无锁写(目标文件不落盘)", () => {
227
+ // 模拟 runtime 对端长期持锁:withFileLockSync 抛 ELOCKED。扩展侧契约 =
228
+ // 不降级无锁写(降级会与 runtime 持锁写交错丢字段),按保存失败返回。
229
+ const lockErr = Object.assign(new Error("[file-lock] lock unavailable: ELOCKED"), { code: "ELOCKED" });
230
+ const spy = vi.spyOn(fileLock, "withFileLockSync").mockImplementation(() => {
231
+ throw lockErr;
232
+ });
233
+ const onWarning = vi.fn();
234
+
235
+ try {
236
+ const result = saveConfig("lockbusy", { x: 1 }, onWarning);
237
+
238
+ expect(result.success).toBe(false);
239
+ expect(result.error).toContain("lock unavailable");
240
+ expect(onWarning).toHaveBeenCalledTimes(1);
241
+ // 关键:未降级写盘(无锁写会破坏与 runtime 的互斥)
242
+ expect(existsSync(join(dir, "config", "lockbusy-ext-config.json"))).toBe(false);
243
+ } finally {
244
+ spy.mockRestore();
245
+ }
246
+ });
217
247
  });
package/src/config.ts CHANGED
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync,
23
23
  import { dirname, join } from "node:path";
24
24
 
25
25
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
+ import { withFileLockSync } from "@zhushanwen/pi-file-lock";
26
27
 
27
28
  /** JSON 序列化缩进格数(permission/config.ts 同款)。 */
28
29
  const JSON_INDENT = 2;
@@ -114,13 +115,35 @@ export function loadConfig<T>(
114
115
  }
115
116
  }
116
117
 
117
- // ──────────────────────── 保存(原子写) ────────────────────────
118
+ // ──────────────────────── 保存(锁内原子写) ────────────────────────
118
119
 
119
120
  /**
120
- * 保存配置(原子写:tmp 文件 + rename)。
121
+ * 生成并发唯一的 tmp 文件名(D1e 附带风险修复:双侧 tmp 中间文件同名 `<path>.tmp`
122
+ * 并发可碰撞——runtime 侧 atomicWrite 未传 uniqueSuffix 也是固定名,扩展侧唯一化
123
+ * 后两侧名字空间不相交,碰撞面消除)。
124
+ *
125
+ * 后缀 = pid + 36 进制随机段:同进程多写方(多 session)与跨进程写方均不重名。
126
+ */
127
+ const TMP_RANDOM_BASE = 36;
128
+ const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
129
+ const TMP_RANDOM_SLICE_END = 10;
130
+ function uniqueTmpPath(configPath: string): string {
131
+ return `${configPath}.tmp_${process.pid}_${Math.random().toString(TMP_RANDOM_BASE).slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
132
+ }
133
+
134
+ /**
135
+ * 保存配置(锁内原子写:withFileLockSync + tmp 文件 + rename)。
121
136
  *
122
137
  * @returns 成功 {success:true};失败 {success:false, error}
123
138
  *
139
+ * 🔒 跨进程锁(D1e/W4,integrity-hardening.md §3.1,登记表 §6 rename-session 行):
140
+ * ext-config 家族被 xyz runtime(如 setRenameModel 写 model 字段,W1b 已持锁)与
141
+ * pi 子进程内扩展(本函数)双写。互斥只依赖同一 lockfile(<config>.lock),本侧
142
+ * withFileLockSync 协议与 runtime 侧 settings.json 写锁逐字对齐(realpath:false +
143
+ * stale 30s + busy-wait 1s 预算 fail-fast)。锁获取失败不降级无锁写——对端
144
+ * runtime 可能正持锁写,无锁写会交错丢字段;返回 {success:false} 由调用方按
145
+ * 保存失败处理(下次 save 重试)。
146
+ *
124
147
  * 原子性:writeFileSync(tmp) + renameSync(tmp→target),rename 是原子的(POSIX/Windows)。
125
148
  * tmp 失败清理(review RK3):writeFileSync 或 renameSync 抛错时,catch 块 unlinkSync(tmp)
126
149
  * 清理残留 tmp 文件(unlink 本身 try/catch,避免二次抛错)。
@@ -137,44 +160,55 @@ export function saveConfig(
137
160
  onWarning?: (msg: string) => void,
138
161
  ): { success: boolean; error?: string } {
139
162
  const configPath = getConfigPath(pkgName);
140
- const tmpPath = `${configPath}.tmp`;
163
+ const tmpPath = uniqueTmpPath(configPath);
141
164
  const content = `${JSON.stringify(config, null, JSON_INDENT)}\n`;
142
165
 
143
- try {
144
- mkdirSync(dirname(configPath), { recursive: true });
145
- writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
146
- renameSync(tmpPath, configPath);
147
-
148
- // 写后更新缓存(用新文件 mtime+size + 写入的 config)
166
+ const writeLocked = (): { success: boolean; error?: string } => {
149
167
  try {
150
- const newStat = statSync(configPath);
151
- configCache.set(configPath, {
152
- mtimeMs: newStat.mtimeMs,
153
- size: newStat.size,
154
- config: clone(config),
155
- });
156
- } catch (statErr) {
157
- // stat 失败不影响保存成功;缓存下次 load 时会重读
158
- console.warn(
159
- `[llm-shared] saveConfig stat after write failed:`,
160
- statErr instanceof Error ? statErr.message : String(statErr),
161
- );
168
+ mkdirSync(dirname(configPath), { recursive: true });
169
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
170
+ renameSync(tmpPath, configPath);
171
+
172
+ // 写后更新缓存(用新文件 mtime+size + 写入的 config
173
+ try {
174
+ const newStat = statSync(configPath);
175
+ configCache.set(configPath, {
176
+ mtimeMs: newStat.mtimeMs,
177
+ size: newStat.size,
178
+ config: clone(config),
179
+ });
180
+ } catch (statErr) {
181
+ // stat 失败不影响保存成功;缓存下次 load 时会重读
182
+ console.warn(
183
+ `[llm-shared] saveConfig stat after write failed:`,
184
+ statErr instanceof Error ? statErr.message : String(statErr),
185
+ );
186
+ }
187
+
188
+ return { success: true };
189
+ } catch (error) {
190
+ // RK3: 清理残留 tmp 文件(writeFileSync 或 renameSync 失败时 tmp 可能残留)
191
+ try {
192
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
193
+ } catch (cleanupErr) {
194
+ // tmp 清理失败不能阻塞保存失败的返回;记录原因
195
+ console.warn(
196
+ `[llm-shared] saveConfig tmp cleanup failed:`,
197
+ cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
198
+ );
199
+ }
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
202
+ return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
162
203
  }
204
+ };
163
205
 
164
- return { success: true };
165
- } catch (error) {
166
- // RK3: 清理残留 tmp 文件(writeFileSync 或 renameSync 失败时 tmp 可能残留)
167
- try {
168
- if (existsSync(tmpPath)) unlinkSync(tmpPath);
169
- } catch (cleanupErr) {
170
- // tmp 清理失败不能阻塞保存失败的返回;记录原因
171
- console.warn(
172
- `[llm-shared] saveConfig tmp cleanup failed:`,
173
- cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
174
- );
175
- }
176
- const message = error instanceof Error ? error.message : String(error);
177
- onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
178
- return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
206
+ try {
207
+ return withFileLockSync(configPath, writeLocked);
208
+ } catch (lockErr) {
209
+ // 锁获取失败(ELOCKED 预算耗尽等):不降级无锁写(见 docstring),按保存失败返回
210
+ const message = lockErr instanceof Error ? lockErr.message : String(lockErr);
211
+ onWarning?.(`[llm-shared] Config write lock unavailable at '${configPath}': ${message}`);
212
+ return { success: false, error: `Config write lock unavailable at '${configPath}': ${message}` };
179
213
  }
180
214
  }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // resolve: 模型解析(仅 ref 精确指定)
3
3
  // call: LLM 调用(completeSimple + 凭证 + 文本提取)
4
4
  // config: 泛型配置读写(mtime 缓存 + 原子写)
5
- export { resolveModel, type ModelSelector } from "./resolve.ts";
5
+ export { resolveModel, getCurrentModelId, type ModelSelector } from "./resolve.ts";
6
6
  export { callLLM, extractText, type CallLLMOptions, type CallLLMResult } from "./call.ts";
7
7
  export { getConfigPath, loadConfig, saveConfig, clearConfigCache } from "./config.ts";
8
8
  export { migrateLegacyConfig, type MigrationResult } from "./migrate.ts";
package/src/resolve.ts CHANGED
@@ -44,3 +44,9 @@ function resolveRef(ctx: ExtensionContext, ref: string): Model<Api> | null {
44
44
  export function resolveModel(ctx: ExtensionContext, selector: ModelSelector): Model<Api> | null {
45
45
  return resolveRef(ctx, selector.ref);
46
46
  }
47
+
48
+ /** 当前模型的 "provider/modelId" 复合串(model 缺失返回空串)——smart-context / model-switch 共用口径。 */
49
+ export function getCurrentModelId(model: { provider?: string; id?: string } | undefined | null): string {
50
+ if (!model) return "";
51
+ return `${model.provider ?? ""}/${model.id ?? ""}`;
52
+ }