@trim21/personal-pi-extensions 0.0.315 → 0.0.317

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": "@trim21/personal-pi-extensions",
3
- "version": "0.0.315",
3
+ "version": "0.0.317",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -14,8 +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";
18
- import { type ToolPendant } from "../lib/pendant.js";
17
+ import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
19
18
  import { guardWriteAccess } from "../lib/write-guard.js";
20
19
  import {
21
20
  type ClaudeCodeState,
@@ -46,43 +45,6 @@ function formatFileSize(bytes: number): string {
46
45
  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
47
46
  }
48
47
 
49
- /** Edit/Write 结果的可折叠面板:diff(```diff 代码块)+ LSP errors(有则显示)。 */
50
- export function buildEditPendant(options: {
51
- filePath: string;
52
- diff: string;
53
- diagnosticText: string;
54
- }): ToolPendant {
55
- const lines = [`## Diff — \`${options.filePath}\``, "", "```diff", options.diff, "```"];
56
- if (options.diagnosticText !== "") {
57
- lines.push("", "## LSP errors", "", "```text", options.diagnosticText, "```");
58
- }
59
- return { markdown: lines.join("\n"), expanded: true };
60
- }
61
-
62
- /** Read 结果的可折叠面板:路径、总行数、读取范围与文件大小。 */
63
- export function buildReadPendant(options: {
64
- filePath: string;
65
- byteSize: number;
66
- offset?: number;
67
- limit?: number;
68
- totalLines?: number;
69
- type?: string;
70
- }): ToolPendant {
71
- const lines = ["## Read", "", `- **Path**: \`${options.filePath}\``];
72
- if (options.type) lines.push(`- **Type**: ${options.type}`);
73
- if (options.totalLines !== undefined) {
74
- const start = options.offset ?? 1;
75
- const end = Math.min(
76
- options.limit === undefined ? options.totalLines : start + options.limit - 1,
77
- options.totalLines,
78
- );
79
- lines.push(`- **Total lines**: ${options.totalLines}`);
80
- if (start <= end) lines.push(`- **Range**: ${start}-${end}`);
81
- }
82
- lines.push(`- **Size**: ${formatFileSize(options.byteSize)}`);
83
- return { markdown: lines.join("\n"), expanded: false };
84
- }
85
-
86
48
  /** Tool guidance, kept in markdown so it reads like documentation. */
87
49
  const READ_PROMPT = readFileSync(fileURLToPath(new URL("read.md", import.meta.url)), "utf8").trim();
88
50
  const EDIT_PROMPT = readFileSync(fileURLToPath(new URL("edit.md", import.meta.url)), "utf8").trim();
@@ -107,8 +69,6 @@ export interface FileToolDetails {
107
69
  * session 文件,resume 后由 session_start 重建 reads state。
108
70
  */
109
71
  reads?: Record<string, FileSnapshot>;
110
- /** 可折叠的 diff / LSP 结果面板(pi TUI 渲染)。 */
111
- pendant?: ToolPendant;
112
72
  }
113
73
 
114
74
  function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnapshot {
@@ -326,17 +286,7 @@ export function registerFileTools(
326
286
  const snapshot = snapshotOf(image, false);
327
287
  const key = await readStateKey(filePath);
328
288
  state.reads.set(key, snapshot);
329
- return {
330
- content,
331
- details: {
332
- reads: { [key]: snapshot },
333
- pendant: buildReadPendant({
334
- filePath,
335
- byteSize: image.length,
336
- type: imageMime,
337
- }),
338
- },
339
- };
289
+ return { content, details: { reads: { [key]: snapshot } } };
340
290
  }
341
291
 
342
292
  const buffer = await readFile(filePath);
@@ -366,16 +316,7 @@ export function registerFileTools(
366
316
  });
367
317
  return {
368
318
  content: [{ type: "text", text: formatted.text }],
369
- details: {
370
- reads: { [key]: snapshot },
371
- pendant: buildReadPendant({
372
- filePath,
373
- byteSize: buffer.length,
374
- offset: params.offset ?? 1,
375
- limit: params.limit,
376
- totalLines: formatted.totalLines,
377
- }),
378
- },
319
+ details: { reads: { [key]: snapshot } },
379
320
  };
380
321
  },
381
322
  });
@@ -449,7 +390,9 @@ export function registerFileTools(
449
390
  state.reads.set(key, snapshot);
450
391
  const diff = generateDiffString("", newString);
451
392
  throwIfAborted(signal);
452
- 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
+ });
453
396
  return [
454
397
  `The file ${filePath} has been updated successfully.`,
455
398
  {
@@ -528,7 +471,9 @@ export function registerFileTools(
528
471
  ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
529
472
  : `The file ${filePath} has been updated successfully.`;
530
473
  throwIfAborted(signal);
531
- 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
+ });
532
477
  return [
533
478
  text,
534
479
  {
@@ -544,17 +489,7 @@ export function registerFileTools(
544
489
  const text = diagnosticText
545
490
  ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
546
491
  : message;
547
- return {
548
- content: [{ type: "text" as const, text }],
549
- details: {
550
- ...details,
551
- pendant: buildEditPendant({
552
- filePath,
553
- diff: details.diff ?? "",
554
- diagnosticText,
555
- }),
556
- },
557
- };
492
+ return { content: [{ type: "text" as const, text }], details };
558
493
  },
559
494
  });
560
495
 
@@ -616,7 +551,9 @@ export function registerFileTools(
616
551
  ? `File created successfully at: ${filePath}`
617
552
  : `The file ${filePath} has been updated successfully.`;
618
553
  throwIfAborted(signal);
619
- 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
+ });
620
557
  return [
621
558
  text,
622
559
  {
@@ -632,17 +569,7 @@ export function registerFileTools(
632
569
  const text = diagnosticText
633
570
  ? `${message}\n\nLSP errors detected in this file, please fix:\n${diagnosticText}`
634
571
  : message;
635
- return {
636
- content: [{ type: "text" as const, text }],
637
- details: {
638
- ...details,
639
- pendant: buildEditPendant({
640
- filePath,
641
- diff: details.diff ?? "",
642
- diagnosticText,
643
- }),
644
- },
645
- };
572
+ return { content: [{ type: "text" as const, text }], details };
646
573
  },
647
574
  });
648
575
  }
@@ -677,8 +604,7 @@ function restoreFileReads(
677
604
  * 与主进程 index.ts 聚合加载时的行为一致。
678
605
  */
679
606
  export default function claudeCodeFileTools(pi: ExtensionAPI): void {
680
- const service = createLspService();
681
- initLsp(pi, service);
607
+ const service = registerLsp(pi);
682
608
  const state = createClaudeCodeState();
683
609
 
684
610
  // 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的
@@ -1,7 +1,7 @@
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
7
  * (本地逐字段覆盖全局):`servers` 数组配置驱动地定义语言服务器
@@ -9,14 +9,14 @@
9
9
  * 参数继续生效;配置在每个工具的调用 cwd 下惰性读取;
10
10
  * - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入 broken
11
11
  * 集合(服务实例生命周期内不再重试);
12
- * - 工具只与 touchFile / diagnostics / lspDiagnosticsForFile 三个方法打交道。
12
+ * - 工具只与 touchFile / diagnostics / lspDiagnosticsForFile 三个方法打交道;通知回调按请求传入。
13
13
  */
14
14
 
15
15
  import { readFile } from "node:fs/promises";
16
16
  import { homedir } from "node:os";
17
17
  import { extname, join, normalize, sep } from "node:path";
18
18
 
19
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
20
20
  import { type Static, Type } from "typebox";
21
21
  import { Value } from "typebox/value";
22
22
 
@@ -130,12 +130,22 @@ interface LspState {
130
130
  clients: LspClient[];
131
131
  broken: Set<string>;
132
132
  spawning: Map<string, Promise<LspClient | undefined>>;
133
+ closing: boolean;
134
+ }
135
+
136
+ export interface LspRequestOptions {
137
+ notify?: ExtensionUIContext["notify"];
133
138
  }
134
139
 
135
140
  export interface LspService {
136
- touchFile(file: string, cwd: string, diagnostics?: "document" | "full"): Promise<void>;
141
+ touchFile(
142
+ file: string,
143
+ cwd: string,
144
+ diagnostics?: "document" | "full",
145
+ options?: LspRequestOptions,
146
+ ): Promise<void>;
137
147
  diagnostics(): Promise<Record<string, Diagnostic[]>>;
138
- lspDiagnosticsForFile(file: string, cwd: string): Promise<string>;
148
+ lspDiagnosticsForFile(file: string, cwd: string, options?: LspRequestOptions): Promise<string>;
139
149
  shutdownAll(): Promise<void>;
140
150
  }
141
151
 
@@ -160,9 +170,15 @@ export function createLspService(
160
170
  clients: [],
161
171
  broken: new Set(),
162
172
  spawning: new Map(),
173
+ closing: false,
163
174
  };
164
175
 
165
- async function getClients(file: string, cwd: string): Promise<LspClient[]> {
176
+ async function getClients(
177
+ file: string,
178
+ cwd: string,
179
+ notify?: ExtensionUIContext["notify"],
180
+ ): Promise<LspClient[]> {
181
+ if (state.closing) return [];
166
182
  if (!containsPath(file, cwd)) return [];
167
183
  const config = await loadLspConfig(cwd, globalConfigPath);
168
184
  const timeout = timeoutOptions(config);
@@ -195,6 +211,10 @@ export function createLspService(
195
211
  const handle = await adapter.spawn(root, cwd);
196
212
  if (!handle) {
197
213
  state.broken.add(key);
214
+ notify?.(
215
+ `LSP server "${adapter.id}" is not available for ${root} (binary not found)`,
216
+ "error",
217
+ );
198
218
  return;
199
219
  }
200
220
  const client = await create({
@@ -207,6 +227,10 @@ export function createLspService(
207
227
  diagnosticsDocumentWaitTimeoutMs:
208
228
  adapter.diagnosticsWaitMs ?? timeout.diagnosticsDocumentWaitTimeoutMs,
209
229
  });
230
+ if (state.closing) {
231
+ await client.shutdown();
232
+ return;
233
+ }
210
234
  const duplicate = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
211
235
  if (duplicate) {
212
236
  await client.shutdown();
@@ -214,8 +238,14 @@ export function createLspService(
214
238
  }
215
239
  state.clients.push(client);
216
240
  return client;
217
- } catch {
241
+ } catch (error) {
218
242
  state.broken.add(key);
243
+ notify?.(
244
+ `LSP server "${adapter.id}" failed to start for ${root}: ${
245
+ error instanceof Error ? error.message : String(error)
246
+ }`,
247
+ "error",
248
+ );
219
249
  return;
220
250
  }
221
251
  })();
@@ -239,8 +269,9 @@ export function createLspService(
239
269
  file: string,
240
270
  cwd: string,
241
271
  diagnostics?: "document" | "full",
272
+ options?: LspRequestOptions,
242
273
  ): Promise<void> {
243
- const clients = await getClients(file, cwd);
274
+ const clients = await getClients(file, cwd, options?.notify);
244
275
  await Promise.all(
245
276
  clients.map(async (client) => {
246
277
  const after = Date.now();
@@ -268,8 +299,12 @@ export function createLspService(
268
299
  * edit/write 用:等待文档诊断并返回该文件的 ERROR 报告(空串表示无错误)。
269
300
  * 内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
270
301
  */
271
- async function lspDiagnosticsForFile(file: string, cwd: string): Promise<string> {
272
- await touchFile(file, cwd, "document");
302
+ async function lspDiagnosticsForFile(
303
+ file: string,
304
+ cwd: string,
305
+ options?: LspRequestOptions,
306
+ ): Promise<string> {
307
+ await touchFile(file, cwd, "document", options);
273
308
  const all = await diagnostics();
274
309
  const normalized = normalize(file);
275
310
  return report(normalized, all[normalized] ?? []);
@@ -277,6 +312,8 @@ export function createLspService(
277
312
 
278
313
  /** 终止全部服务器进程(session_shutdown 时调用)。 */
279
314
  async function shutdownAll(): Promise<void> {
315
+ if (state.closing) return;
316
+ state.closing = true;
280
317
  await Promise.all(state.clients.map((client) => client.shutdown())).catch(() => {
281
318
  // 个别进程退出失败不阻止清理流程
282
319
  });
@@ -287,10 +324,14 @@ export function createLspService(
287
324
  return { touchFile, diagnostics, lspDiagnosticsForFile, shutdownAll };
288
325
  }
289
326
 
290
- /** 注册进程级生命周期:session_shutdown 时清理全部服务器进程。 */
291
- export function initLsp(pi: ExtensionAPI, service: LspService): void {
292
- // 测试里的 fake pi 没有事件订阅;生产环境 pi.on 必然存在
293
- pi.on?.("session_shutdown", () => {
294
- void service.shutdownAll();
295
- });
327
+ export interface LspServiceOptions {
328
+ adapters?: LspServerAdapter[];
329
+ globalConfigPath?: string;
330
+ }
331
+
332
+ /** 创建 LSP service 并注册 pi 的进程级清理生命周期。 */
333
+ export function registerLsp(pi: ExtensionAPI, options?: LspServiceOptions): LspService {
334
+ const service = createLspService(options?.adapters, options?.globalConfigPath);
335
+ pi.on?.("session_shutdown", () => service.shutdownAll());
336
+ return service;
296
337
  }
@@ -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
  }