@trim21/personal-pi-extensions 0.0.316 → 0.0.318

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/README.md CHANGED
@@ -348,7 +348,7 @@ pi -e ./src/talk/index.ts
348
348
 
349
349
  read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级诊断)。LSP protocol 是统一的,因此服务器不需要为每个语言写 adapter:用一份 JSON 配置声明如何启动即可。
350
350
 
351
- 配置文件(项目优先于全局,逐字段覆盖):
351
+ 配置文件(项目优先于全局):顶层字段(`enabled`/`disabled`、超时等)本地覆盖全局;`servers` 按服务器 id 合并——同名 id 本地整体覆盖、新增 id,全局其余服务器保留。
352
352
 
353
353
  - `~/.pi/agent/lsp.json`(全局)
354
354
  - `.pi/lsp.json`(项目)
@@ -382,7 +382,7 @@ read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级
382
382
  - `startupTimeoutMs` / `diagnosticsWaitMs`:per-server 超时,覆盖全局配置与默认值
383
383
  - `initializationOptions` 与 `settings` 按 LSP 语义分离:前者进 initialize 请求,后者进 didChangeConfiguration / workspace/configuration 请求
384
384
 
385
- 内置默认服务器(typescript / pyright / ruff / clangd)始终存在;`servers` 以 key 为服务器 id 与默认合并——同 key 覆盖、新 key 新增、`"enabled": false` 移除(如 `"clangd": { "enabled": false }`)。executable 的发现逻辑(如 tsserver 路径、venv 里的 python)不内置,需要时用 `bin` / `args` / `settings` 自行表达。
385
+ 内置默认服务器(typescript / pyright / ruff / clangd)始终存在;`servers` 以 key 为服务器 id 与默认合并——同 key 整体覆盖(整个配置替换默认)、新 key 新增、`"enabled": false` 移除(如 `"clangd": { "enabled": false }`)。executable 的发现逻辑(如 tsserver 路径、venv 里的 python)不内置,需要时用 `bin` / `args` / `settings` 自行表达。
386
386
 
387
387
  旧的 `enabled`(白名单)/ `disabled`(排除)与全局超时字段(`initializeTimeoutMs` 等)继续可用。
388
388
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.316",
3
+ "version": "0.0.318",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -14,7 +14,7 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { Type } from "typebox";
16
16
 
17
- import { createLspService, initLsp, type LspService } from "../lib/lsp/lsp.js";
17
+ import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
18
18
  import { guardWriteAccess } from "../lib/write-guard.js";
19
19
  import {
20
20
  type ClaudeCodeState,
@@ -390,7 +390,9 @@ export function registerFileTools(
390
390
  state.reads.set(key, snapshot);
391
391
  const diff = generateDiffString("", newString);
392
392
  throwIfAborted(signal);
393
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
393
+ const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
394
+ notify: (message, level) => ctx.ui.notify(message, level),
395
+ });
394
396
  return [
395
397
  `The file ${filePath} has been updated successfully.`,
396
398
  {
@@ -469,7 +471,9 @@ export function registerFileTools(
469
471
  ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
470
472
  : `The file ${filePath} has been updated successfully.`;
471
473
  throwIfAborted(signal);
472
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
474
+ const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
475
+ notify: (message, level) => ctx.ui.notify(message, level),
476
+ });
473
477
  return [
474
478
  text,
475
479
  {
@@ -547,7 +551,9 @@ export function registerFileTools(
547
551
  ? `File created successfully at: ${filePath}`
548
552
  : `The file ${filePath} has been updated successfully.`;
549
553
  throwIfAborted(signal);
550
- const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd);
554
+ const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
555
+ notify: (message, level) => ctx.ui.notify(message, level),
556
+ });
551
557
  return [
552
558
  text,
553
559
  {
@@ -598,8 +604,7 @@ function restoreFileReads(
598
604
  * 与主进程 index.ts 聚合加载时的行为一致。
599
605
  */
600
606
  export default function claudeCodeFileTools(pi: ExtensionAPI): void {
601
- const service = createLspService();
602
- initLsp(pi, service);
607
+ const service = registerLsp(pi);
603
608
  const state = createClaudeCodeState();
604
609
 
605
610
  // 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的
@@ -607,7 +612,6 @@ export default function claudeCodeFileTools(pi: ExtensionAPI): void {
607
612
  // 若文件在此期间被外部修改,Edit/Write 时的指纹对比仍会要求重新 Read,
608
613
  // 防呆语义不因重建而弱化。
609
614
  pi.on("session_start", (_event, ctx) => {
610
- service.setUi(ctx.ui);
611
615
  restoreFileReads(state, ctx.sessionManager);
612
616
  });
613
617
 
@@ -615,7 +619,6 @@ export default function claudeCodeFileTools(pi: ExtensionAPI): void {
615
619
  // session_start,扩展实例也不重建。这里同样重放当前分支,丢弃被抛弃分支
616
620
  // 的记账,避免 state 与当前分支脱节。
617
621
  pi.on("session_tree", (_event, ctx) => {
618
- service.setUi(ctx.ui);
619
622
  restoreFileReads(state, ctx.sessionManager);
620
623
  });
621
624
 
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * LSP 管理器:所有语言服务器连接的注册表与统一入口。
3
3
  *
4
- * - state(client 缓存、broken 集合、spawning 去重)是 createLspService
4
+ * - state(client 缓存、broken 集合、spawning 去重)是 service 工厂的
5
5
  * 闭包变量,不做成模块级全局;
6
6
  * - 配置来源:全局 `~/.pi/agent/lsp.json` + 本地 `<cwd>/.pi/lsp.json`
7
- * (本地逐字段覆盖全局):`servers` 数组配置驱动地定义语言服务器
8
- * (按 id 与内置默认服务器合并),`enabled`/`disabled` 白名单与各超时
9
- * 参数继续生效;配置在每个工具的调用 cwd 下惰性读取;
7
+ * (本地覆盖全局):`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局
8
+ * 其余服务器保留),`enabled`/`disabled` 白名单与各超时参数继续生效;
9
+ * 配置在每个工具的调用 cwd 下惰性读取;enabled/disabled 引用不存在的
10
+ * 服务器 id 是配置错误:全局配置在扩展加载(createLspService)时抛错,
11
+ * 本地配置在 session 开始预加载时通知,工具调用时校验抛错兜底;
10
12
  * - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入 broken
11
13
  * 集合(服务实例生命周期内不再重试);
12
- * - 工具只与 touchFile / diagnostics / lspDiagnosticsForFile 三个方法打交道。
14
+ * - 工具只与 touchFile / diagnostics / lspDiagnosticsForFile 三个方法打交道;通知回调按请求传入。
13
15
  */
14
16
 
17
+ import { readFileSync } from "node:fs";
15
18
  import { readFile } from "node:fs/promises";
16
19
  import { homedir } from "node:os";
17
20
  import { extname, join, normalize, sep } from "node:path";
@@ -23,7 +26,13 @@ import { Value } from "typebox/value";
23
26
  import { type LspServerAdapter } from "./adapter.js";
24
27
  import { create, type CreateInput, type Diagnostic, type Info as LspClient } from "./client.js";
25
28
  import { report } from "./diagnostic.js";
26
- import { createAdapters, serverConfigSchema } from "./server-config.js";
29
+ import {
30
+ createAdapters,
31
+ defaultServers,
32
+ mergeServerConfigs,
33
+ mergeServerRecords,
34
+ serverConfigSchema,
35
+ } from "./server-config.js";
27
36
 
28
37
  /** 超时值:number(毫秒,>=1)或字符串("500"、"5s"、"1m"),Parse 后由 toMs 统一换算。 */
29
38
  const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
@@ -99,9 +108,42 @@ async function readConfigFile(filePath: string): Promise<LspConfig> {
99
108
  }
100
109
  }
101
110
 
111
+ /** 同步版 readConfigFile(扩展加载时校验全局配置用)。 */
112
+ function readConfigFileSync(filePath: string): LspConfig {
113
+ try {
114
+ const raw = readFileSync(filePath, "utf8");
115
+ return Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
116
+ } catch {
117
+ return {};
118
+ }
119
+ }
120
+
121
+ /**
122
+ * enabled/disabled 引用的 id 必须存在于实际生效的服务器集合(注入的 adapters
123
+ * 或默认 + 配置 servers),否则抛配置错误(避免静默失效)。
124
+ */
125
+ function validateConfig(config: LspConfig, adapters?: LspServerAdapter[]): void {
126
+ const available = new Set(
127
+ adapters
128
+ ? adapters.map((adapter) => adapter.id)
129
+ : Object.keys(mergeServerConfigs(defaultServers, config.servers)),
130
+ );
131
+ const unknown = [...(config.enabled ?? []), ...(config.disabled ?? [])].filter(
132
+ (id) => !available.has(id),
133
+ );
134
+ if (unknown.length > 0) {
135
+ const list = [...available].toSorted().join(", ") || "none";
136
+ throw new Error(
137
+ `lsp.json: unknown server id in enabled/disabled: ${unknown.join(", ")} (available: ${list})`,
138
+ );
139
+ }
140
+ }
141
+
102
142
  /**
103
143
  * 合并后的生效配置:全局 `~/.pi/agent/lsp.json` 为基底,本地
104
- * `<cwd>/.pi/lsp.json` 逐字段覆盖。
144
+ * `<cwd>/.pi/lsp.json` 覆盖——顶层标量字段(enabled/disabled、超时等)本地
145
+ * 直接替换;`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局其余服务器
146
+ * 保留)。
105
147
  */
106
148
  export async function loadLspConfig(
107
149
  cwd: string,
@@ -111,14 +153,23 @@ export async function loadLspConfig(
111
153
  readConfigFile(globalConfigPath),
112
154
  readConfigFile(join(cwd, ".pi", "lsp.json")),
113
155
  ]);
114
- return { ...globalConfig, ...localConfig };
156
+ const servers = mergeServerRecords(globalConfig.servers, localConfig.servers);
157
+ return {
158
+ ...globalConfig,
159
+ ...localConfig,
160
+ ...(servers && { servers }),
161
+ };
115
162
  }
116
163
 
117
- /** 按配置过滤 adapter 列表。 */
164
+ /**
165
+ * 按配置过滤 adapter 列表。enabled/disabled 引用了实际生效集合中不存在的
166
+ * 服务器 id 时抛错(配置错误,避免静默失效)。
167
+ */
118
168
  export function filterAdapters(
119
169
  adapters: LspServerAdapter[],
120
170
  config: LspConfig,
121
171
  ): LspServerAdapter[] {
172
+ validateConfig(config, adapters);
122
173
  return adapters.filter((adapter) => {
123
174
  if (config.enabled && !config.enabled.includes(adapter.id)) return false;
124
175
  if (config.disabled?.includes(adapter.id)) return false;
@@ -130,15 +181,23 @@ interface LspState {
130
181
  clients: LspClient[];
131
182
  broken: Set<string>;
132
183
  spawning: Map<string, Promise<LspClient | undefined>>;
184
+ closing: boolean;
185
+ }
186
+
187
+ export interface LspRequestOptions {
188
+ notify?: ExtensionUIContext["notify"];
133
189
  }
134
190
 
135
191
  export interface LspService {
136
- touchFile(file: string, cwd: string, diagnostics?: "document" | "full"): Promise<void>;
192
+ touchFile(
193
+ file: string,
194
+ cwd: string,
195
+ diagnostics?: "document" | "full",
196
+ options?: LspRequestOptions,
197
+ ): Promise<void>;
137
198
  diagnostics(): Promise<Record<string, Diagnostic[]>>;
138
- lspDiagnosticsForFile(file: string, cwd: string): Promise<string>;
199
+ lspDiagnosticsForFile(file: string, cwd: string, options?: LspRequestOptions): Promise<string>;
139
200
  shutdownAll(): Promise<void>;
140
- /** 绑定当前会话的 UI 上下文,LSP 服务器启动失败时用它发错误通知(传 undefined 解绑)。 */
141
- setUi(ui?: Pick<ExtensionUIContext, "notify">): void;
142
201
  }
143
202
 
144
203
  /** 文件必须在工作目录内才启用 LSP(对齐 opencode 的 containsPath)。 */
@@ -158,14 +217,24 @@ export function createLspService(
158
217
  adapters?: LspServerAdapter[],
159
218
  globalConfigPath?: string,
160
219
  ): LspService {
220
+ // 扩展加载时校验全局配置(本地配置在 session_start 预加载时校验)
221
+ validateConfig(
222
+ readConfigFileSync(globalConfigPath ?? join(homedir(), ".pi", "agent", "lsp.json")),
223
+ adapters,
224
+ );
161
225
  const state: LspState = {
162
226
  clients: [],
163
227
  broken: new Set(),
164
228
  spawning: new Map(),
229
+ closing: false,
165
230
  };
166
- let ui: Pick<ExtensionUIContext, "notify"> | undefined;
167
231
 
168
- async function getClients(file: string, cwd: string): Promise<LspClient[]> {
232
+ async function getClients(
233
+ file: string,
234
+ cwd: string,
235
+ notify?: ExtensionUIContext["notify"],
236
+ ): Promise<LspClient[]> {
237
+ if (state.closing) return [];
169
238
  if (!containsPath(file, cwd)) return [];
170
239
  const config = await loadLspConfig(cwd, globalConfigPath);
171
240
  const timeout = timeoutOptions(config);
@@ -198,7 +267,7 @@ export function createLspService(
198
267
  const handle = await adapter.spawn(root, cwd);
199
268
  if (!handle) {
200
269
  state.broken.add(key);
201
- ui?.notify(
270
+ notify?.(
202
271
  `LSP server "${adapter.id}" is not available for ${root} (binary not found)`,
203
272
  "error",
204
273
  );
@@ -214,6 +283,10 @@ export function createLspService(
214
283
  diagnosticsDocumentWaitTimeoutMs:
215
284
  adapter.diagnosticsWaitMs ?? timeout.diagnosticsDocumentWaitTimeoutMs,
216
285
  });
286
+ if (state.closing) {
287
+ await client.shutdown();
288
+ return;
289
+ }
217
290
  const duplicate = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
218
291
  if (duplicate) {
219
292
  await client.shutdown();
@@ -223,7 +296,7 @@ export function createLspService(
223
296
  return client;
224
297
  } catch (error) {
225
298
  state.broken.add(key);
226
- ui?.notify(
299
+ notify?.(
227
300
  `LSP server "${adapter.id}" failed to start for ${root}: ${
228
301
  error instanceof Error ? error.message : String(error)
229
302
  }`,
@@ -252,8 +325,9 @@ export function createLspService(
252
325
  file: string,
253
326
  cwd: string,
254
327
  diagnostics?: "document" | "full",
328
+ options?: LspRequestOptions,
255
329
  ): Promise<void> {
256
- const clients = await getClients(file, cwd);
330
+ const clients = await getClients(file, cwd, options?.notify);
257
331
  await Promise.all(
258
332
  clients.map(async (client) => {
259
333
  const after = Date.now();
@@ -281,8 +355,12 @@ export function createLspService(
281
355
  * edit/write 用:等待文档诊断并返回该文件的 ERROR 报告(空串表示无错误)。
282
356
  * 内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
283
357
  */
284
- async function lspDiagnosticsForFile(file: string, cwd: string): Promise<string> {
285
- await touchFile(file, cwd, "document");
358
+ async function lspDiagnosticsForFile(
359
+ file: string,
360
+ cwd: string,
361
+ options?: LspRequestOptions,
362
+ ): Promise<string> {
363
+ await touchFile(file, cwd, "document", options);
286
364
  const all = await diagnostics();
287
365
  const normalized = normalize(file);
288
366
  return report(normalized, all[normalized] ?? []);
@@ -290,6 +368,8 @@ export function createLspService(
290
368
 
291
369
  /** 终止全部服务器进程(session_shutdown 时调用)。 */
292
370
  async function shutdownAll(): Promise<void> {
371
+ if (state.closing) return;
372
+ state.closing = true;
293
373
  await Promise.all(state.clients.map((client) => client.shutdown())).catch(() => {
294
374
  // 个别进程退出失败不阻止清理流程
295
375
  });
@@ -297,21 +377,25 @@ export function createLspService(
297
377
  state.broken.clear();
298
378
  }
299
379
 
300
- return {
301
- touchFile,
302
- diagnostics,
303
- lspDiagnosticsForFile,
304
- shutdownAll,
305
- setUi(nextUi) {
306
- ui = nextUi;
307
- },
308
- };
380
+ return { touchFile, diagnostics, lspDiagnosticsForFile, shutdownAll };
381
+ }
382
+
383
+ export interface LspServiceOptions {
384
+ adapters?: LspServerAdapter[];
385
+ globalConfigPath?: string;
309
386
  }
310
387
 
311
- /** 注册进程级生命周期:session_shutdown 时清理全部服务器进程。 */
312
- export function initLsp(pi: ExtensionAPI, service: LspService): void {
313
- // 测试里的 fake pi 没有事件订阅;生产环境 pi.on 必然存在
314
- pi.on?.("session_shutdown", () => {
315
- void service.shutdownAll();
388
+ /** 创建 LSP service 并注册 pi 的进程级清理生命周期。 */
389
+ export function registerLsp(pi: ExtensionAPI, options?: LspServiceOptions): LspService {
390
+ const service = createLspService(options?.adapters, options?.globalConfigPath);
391
+ pi.on?.("session_shutdown", () => service.shutdownAll());
392
+ // session 开始是最早能拿到本地配置 cwd 的时机:预加载并校验,配置错误立即通知
393
+ pi.on?.("session_start", (_event, ctx) => {
394
+ void loadLspConfig(ctx.cwd, options?.globalConfigPath)
395
+ .then((config) => validateConfig(config, options?.adapters))
396
+ .catch((error: unknown) => {
397
+ if (error instanceof Error) ctx.ui.notify?.(error.message, "error");
398
+ });
316
399
  });
400
+ return service;
317
401
  }
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * 配置文件沿用 lsp.json(全局 ~/.pi/agent/lsp.json + 本地 <cwd>/.pi/lsp.json):
6
6
  * 顶层 `servers` 是 id → 配置的 record,按 id 与内置默认服务器合并
7
- * (覆盖同名、新增 id、enabled:false 禁用),之后仍受现有 enabled/disabled
8
- * 白名单过滤。
7
+ * (同名 id 整体覆盖、新增 id、enabled:false 禁用),之后仍受现有
8
+ * enabled/disabled 白名单过滤。
9
9
  *
10
10
  * executable 发现统一由用户配置:bin 支持绝对路径 / 项目工作区
11
11
  * (node_modules/.bin、.venv/bin、venv/bin)/ PATH,不再内置各语言的
@@ -111,7 +111,19 @@ export const defaultServers: Record<string, ServerConfig> = {
111
111
  },
112
112
  };
113
113
 
114
- /** 用户 servers(id 配置)与默认配置合并:覆盖同名、新增 id、enabled:false 移除。 */
114
+ /** id 合并 servers record:同名 id 整体覆盖(不做逐字段 merge),其余保留;返回 undefined 表示没有任何 servers 定义。 */
115
+ export function mergeServerRecords(
116
+ ...records: (Readonly<Record<string, ServerConfig>> | undefined)[]
117
+ ): Record<string, ServerConfig> | undefined {
118
+ const merged: Record<string, ServerConfig> = {};
119
+ for (const record of records) {
120
+ if (!record) continue;
121
+ Object.assign(merged, record);
122
+ }
123
+ return Object.keys(merged).length > 0 ? merged : undefined;
124
+ }
125
+
126
+ /** 用户 servers(id → 配置)与默认配置合并:同名 id 整体覆盖、新增 id、enabled:false 移除。 */
115
127
  export function mergeServerConfigs(
116
128
  defaults: Readonly<Record<string, ServerConfig>>,
117
129
  user: Readonly<Record<string, ServerConfig>> | undefined,
@@ -2,7 +2,7 @@
2
2
  * Opencode File Tools —— read / edit / write 统一构建点。
3
3
  *
4
4
  * 三个工具在同一个 registerFileTools(pi, service) 里注册,共享同一个 LSP
5
- * service 实例(createLspService 的闭包变量),与 claude-code/files.ts 共享
5
+ * service 实例(registerLsp 创建的闭包变量),与 claude-code/files.ts 共享
6
6
  * read-snapshot state 的方式一致;不再用模块级全局缓存。
7
7
  *
8
8
  * 各工具行为对齐 opencode commit 999be62662(v1.2.25-1672-g999be62662,
@@ -30,7 +30,7 @@ import {
30
30
  } from "@earendil-works/pi-coding-agent";
31
31
  import { Type } from "typebox";
32
32
 
33
- import { createLspService, initLsp, type LspService } from "../lib/lsp/lsp.js";
33
+ import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
34
34
  import { guardWriteAccess } from "../lib/write-guard.js";
35
35
  import {
36
36
  detectLineEnding,
@@ -695,7 +695,6 @@ export function registerFileTools(pi: ExtensionAPI, service: LspService): void {
695
695
 
696
696
  /** 独立入口:创建 LSP service(闭包共享给三个工具)并注册。 */
697
697
  export default function opencodeFileTools(pi: ExtensionAPI): void {
698
- const service = createLspService();
699
- initLsp(pi, service);
698
+ const service = registerLsp(pi);
700
699
  registerFileTools(pi, service);
701
700
  }