@trim21/personal-pi-extensions 0.1.503 → 0.1.507

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.1.503",
3
+ "version": "0.1.507",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -43,6 +43,7 @@
43
43
  "@types/lodash-es": "^4.17.12",
44
44
  "@types/node": "^24.13.3",
45
45
  "@types/turndown": "^5.0.6",
46
+ "@typescript/native": "npm:typescript@^7.0.2",
46
47
  "@vitest/coverage-v8": "^4.1.10",
47
48
  "citty": "^0.2.2",
48
49
  "esbuild": "^0.28.2",
@@ -60,7 +61,7 @@
60
61
  "shlex": "^3.0.0",
61
62
  "tsx": "^4.23.12",
62
63
  "typebox": "1.3.1",
63
- "typescript": "^6.0.2",
64
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
64
65
  "typescript-eslint": "^8.67.0",
65
66
  "vitest": "^4.1.10"
66
67
  },
package/src/aft/zoom.md CHANGED
@@ -7,5 +7,3 @@
7
7
  - `contextLines`:符号前后附加的上下文行数,默认 3。
8
8
  - `callgraph: true`:附带同文件内 calls-out / called-by 调用关系标注,帮助理解符号如何被使用、依赖谁。
9
9
  - 适合先 aft_outline 拿到符号名与行号,再 zoom 具体实现;不要用它读整个文件(用 read)。
10
-
11
- 只支持本地文件路径,不支持 URL。
@@ -13,8 +13,8 @@
13
13
  * 是配置错误:全局配置在扩展加载(createLspService)时抛错,本地配置在
14
14
  * session 开始预加载时通知,工具调用时校验抛错兜底。disabled 中未注册
15
15
  * 的 id 直接忽略;
16
- * - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入 broken
17
- * 集合(服务实例生命周期内不再重试);
16
+ * - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入
17
+ * broken(冷却期内跳过,冷却过后下次触碰自动重试)并主动 notify;
18
18
  * - 工具只与 touchFile / notifyFile / diagnostics / lspDiagnosticsForFile 四个方法打交道;通知回调按请求传入。
19
19
  */
20
20
 
@@ -109,6 +109,11 @@ export const configDefaults = {
109
109
  maxOpenDocuments: clientDefaults.maxOpenDocuments,
110
110
  } as const;
111
111
 
112
+ /** 服务器启动失败后自动重试的冷却(ms);冷却内跳过,之后下次触碰自动重试。 */
113
+ const RETRY_COOLDOWN_MS = 60_000;
114
+ /** 同一服务器启动失败错误通知的最小间隔(ms),冷却重试反复失败时不刷屏。 */
115
+ const NOTIFY_INTERVAL_MS = 5 * 60_000;
116
+
112
117
  /** 生效的工作区监听配置(缺省值已应用)。 */
113
118
  export interface EffectiveWatchConfig {
114
119
  enabled: boolean;
@@ -292,7 +297,10 @@ export function filterAdapters(
292
297
 
293
298
  interface LspState {
294
299
  clients: LspClient[];
295
- broken: Set<string>;
300
+ /** root+serverID → 最近一次启动失败时间;冷却期过后允许自动重试。 */
301
+ brokenFailAt: Map<string, number>;
302
+ /** root+serverID → 最近一次启动失败错误通知时间;节流避免反复刷屏。 */
303
+ brokenNotifiedAt: Map<string, number>;
296
304
  spawning: Map<string, Promise<LspClient | undefined>>;
297
305
  closing: boolean;
298
306
  /** /lsp-stop 置 true:所有工具调用不再 spawn 服务器,直到 start/reload。 */
@@ -380,10 +388,10 @@ export interface LspService {
380
388
  shutdownAll(): Promise<void>;
381
389
  /** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
382
390
  stop(): Promise<void>;
383
- /** 解除禁用并清空 broken 缓存;服务器在下次工具调用时惰性启动。 */
391
+ /** 解除禁用;服务器在下次工具调用时惰性启动(启动失败会按冷却自动重试)。 */
384
392
  start(): void;
385
393
  /**
386
- * 重启指定服务器:关闭其全部 client、清除对应 broken 记录并解除禁用;
394
+ * 重启指定服务器:关闭其全部 client、清除对应失败记录并解除禁用;
387
395
  * 其余服务器不受影响。配置在下次工具调用时重新读取。
388
396
  */
389
397
  reload(serverID: string): Promise<void>;
@@ -408,10 +416,26 @@ function containsPath(file: string, cwd: string): boolean {
408
416
  * 内置默认服务器合并构建。globalConfigPath 供测试注入固定的全局配置
409
417
  * 路径,避免被本机 ~/.pi/agent/lsp.json 影响。
410
418
  */
419
+ export interface LspServiceStartupOptions {
420
+ /**
421
+ * 服务器启动失败后到允许自动重试的冷却时长(ms)。冷却期内该服务器被
422
+ * 跳过,冷却过后下次触碰匹配文件时自动重试,无需 /lsp-reload。
423
+ */
424
+ retryCooldownMs?: number;
425
+ /** 同一服务器的启动失败错误通知最小间隔(ms),防止反复重试刷屏。 */
426
+ notifyIntervalMs?: number;
427
+ /** 会话级通知:启动失败时主动上报,不依赖触发请求恰好携带 notify。 */
428
+ notify?: ExtensionUIContext["notify"];
429
+ }
430
+
411
431
  export function createLspService(
412
432
  adapters?: LspServerAdapter[],
413
433
  globalConfigPath?: string,
434
+ startupOptions?: LspServiceStartupOptions,
414
435
  ): LspService {
436
+ const retryCooldownMs = startupOptions?.retryCooldownMs ?? RETRY_COOLDOWN_MS;
437
+ const notifyIntervalMs = startupOptions?.notifyIntervalMs ?? NOTIFY_INTERVAL_MS;
438
+ const sessionNotify = startupOptions?.notify;
415
439
  // 扩展加载时校验全局配置(本地配置在 session_start 预加载时校验)
416
440
  validateConfig(
417
441
  resolveConfig(
@@ -421,7 +445,8 @@ export function createLspService(
421
445
  );
422
446
  const state: LspState = {
423
447
  clients: [],
424
- broken: new Set(),
448
+ brokenFailAt: new Map(),
449
+ brokenNotifiedAt: new Map(),
425
450
  spawning: new Map(),
426
451
  closing: false,
427
452
  disabled: false,
@@ -558,6 +583,33 @@ export function createLspService(
558
583
  notify?: ExtensionUIContext["notify"],
559
584
  adapterFilter?: (adapter: LspServerAdapter) => boolean,
560
585
  ): Promise<LspClient[]> {
586
+ /**
587
+ * 记录一次启动失败:进入 broken(冷却期内跳过)、渲染 status,并按节流
588
+ * 间隔主动 notify。错误上报优先走会话级 sessionNotify——不依赖触发请求
589
+ * 恰好携带 notify(否则 Read warm-up 等静默通道会把失败吞掉);未注入
590
+ * 会话通知时退回请求级 notify 兜底。
591
+ */
592
+ function reportStartupFailure(
593
+ key: string,
594
+ serverID: string,
595
+ root: string,
596
+ cause: string,
597
+ ): void {
598
+ const now = Date.now();
599
+ state.brokenFailAt.set(key, now);
600
+ state.servers.set(key, { serverID, root, state: "broken" });
601
+ updateStatusText();
602
+ const reporter = sessionNotify ?? notify;
603
+ const lastNotified = state.brokenNotifiedAt.get(key);
604
+ if (reporter && (lastNotified === undefined || now - lastNotified >= notifyIntervalMs)) {
605
+ state.brokenNotifiedAt.set(key, now);
606
+ reporter(
607
+ `LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
608
+ `Fix the issue or run /lsp-reload ${serverID} to retry now.`,
609
+ "error",
610
+ );
611
+ }
612
+ }
561
613
  if (state.closing || state.disabled) return [];
562
614
  if (!containsPath(file, cwd)) return [];
563
615
  const config = await loadLspConfig(cwd, globalConfigPath);
@@ -584,7 +636,12 @@ export function createLspService(
584
636
  const root = await adapter.findRoot(file, cwd);
585
637
  if (!root) continue;
586
638
  const key = root + adapter.id;
587
- if (state.broken.has(key)) continue;
639
+ const failedAt = state.brokenFailAt.get(key);
640
+ if (failedAt !== undefined) {
641
+ if (Date.now() - failedAt < retryCooldownMs) continue;
642
+ // 冷却已过:允许重试;重试成功后下面会清除 broken 记录
643
+ state.brokenFailAt.delete(key);
644
+ }
588
645
 
589
646
  const existing = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
590
647
  if (existing) {
@@ -603,13 +660,7 @@ export function createLspService(
603
660
  try {
604
661
  const handle = await adapter.spawn(root, cwd);
605
662
  if (!handle) {
606
- state.broken.add(key);
607
- state.servers.set(key, { serverID: adapter.id, root, state: "broken" });
608
- updateStatusText();
609
- notify?.(
610
- `LSP server "${adapter.id}" is not available for ${root} (binary not found)`,
611
- "error",
612
- );
663
+ reportStartupFailure(key, adapter.id, root, "binary not found");
613
664
  return;
614
665
  }
615
666
  const client = await create({
@@ -635,18 +686,18 @@ export function createLspService(
635
686
  state.clients.push(client);
636
687
  state.clientExtensions.set(client, adapter.extensions);
637
688
  state.servers.set(key, { serverID: adapter.id, root, state: "running" });
689
+ // 启动成功:清除失败记录,之后若再次失败会立即重新上报
690
+ state.brokenFailAt.delete(key);
691
+ state.brokenNotifiedAt.delete(key);
638
692
  updateStatusText();
639
693
  void ensureWatcher(cwd, notify);
640
694
  return client;
641
695
  } catch (error) {
642
- state.broken.add(key);
643
- state.servers.set(key, { serverID: adapter.id, root, state: "broken" });
644
- updateStatusText();
645
- notify?.(
646
- `LSP server "${adapter.id}" failed to start for ${root}: ${
647
- error instanceof Error ? error.message : String(error)
648
- }`,
649
- "error",
696
+ reportStartupFailure(
697
+ key,
698
+ adapter.id,
699
+ root,
700
+ error instanceof Error ? error.message : String(error),
650
701
  );
651
702
  return;
652
703
  }
@@ -845,7 +896,8 @@ export function createLspService(
845
896
  });
846
897
  state.clients = [];
847
898
  state.clientExtensions.clear();
848
- state.broken.clear();
899
+ state.brokenFailAt.clear();
900
+ state.brokenNotifiedAt.clear();
849
901
  state.servers.clear();
850
902
  await stopWatcher();
851
903
  updateStatusText();
@@ -866,7 +918,8 @@ export function createLspService(
866
918
  function start(): void {
867
919
  state.closing = false;
868
920
  state.disabled = false;
869
- state.broken.clear();
921
+ state.brokenFailAt.clear();
922
+ state.brokenNotifiedAt.clear();
870
923
  updateStatusText();
871
924
  }
872
925
 
@@ -880,7 +933,8 @@ export function createLspService(
880
933
  for (const client of targets) state.clientExtensions.delete(client);
881
934
  for (const [key, server] of state.servers) {
882
935
  if (server.serverID !== serverID) continue;
883
- state.broken.delete(key);
936
+ state.brokenFailAt.delete(key);
937
+ state.brokenNotifiedAt.delete(key);
884
938
  state.servers.delete(key);
885
939
  }
886
940
  if (state.clients.length === 0) await stopWatcher();
@@ -985,7 +1039,10 @@ export function createLspManager(
985
1039
  const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath);
986
1040
  validateConfig(config, options?.adapters);
987
1041
  if (enabledServerCount(config, options?.adapters) === 0) return;
988
- const next = createLspService(options?.adapters, options?.globalConfigPath);
1042
+ const next = createLspService(options?.adapters, options?.globalConfigPath, {
1043
+ // 会话级通知:任何通道触发的启动失败都主动上报(不只依赖请求方 notify)
1044
+ notify: (message, level) => ctx.ui.notify(message, level),
1045
+ });
989
1046
  // footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
990
1047
  next.attachStatus((text) =>
991
1048
  ctx.ui.setStatus("lsp", text ? ctx.ui.theme.fg("accent", text) : undefined),
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: lsp-config
3
+ description: Use when 编写、检查或排查 LSP 语言服务器配置 —— 项目本地 .pi/lsp.json 或全局 ~/.pi/agent/lsp.json:配置 typescript-language-server / pyright / ruff / gopls / clangd 等服务器的 bin、include、rootMarkers、initializationOptions、tsserver.path、watch 与诊断超时;或排查服务器起不来(binary not found / provides no tsserver.js)、typescript alias 依赖下 tsserver 找不到、诊断一直为空等问题。
4
+ ---
5
+
6
+ # 配置 LSP 服务器(.pi/lsp.json)
7
+
8
+ 本扩展的 LSP 层由配置驱动:**没有内置默认服务器**,一切服务器都在 lsp.json 里定义。配置解析在 `src/lib/lsp/lsp.ts`(`lspConfigSchema` / `watchConfigSchema` / 合并与校验)与 `src/lib/lsp/server-config.ts`(`serverConfigSchema`),schema 即权威文档;改代码先看这两处。
9
+
10
+ ## 两个配置文件与合并语义
11
+
12
+ - 全局:`~/.pi/agent/lsp.json`(所有项目的基底)
13
+ - 本地:`<项目根>/.pi/lsp.json`(本地覆盖;常被 gitignore,只在本机生效)
14
+
15
+ 合并是**纯函数**(`mergeConfig(global, local)`),规则:
16
+
17
+ | 段 | 合并方式 |
18
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | 顶层其余字段 | 本地覆盖全局(浅合并) |
20
+ | `servers` | 按服务器 id **整条覆盖**:本地写某个 id 会替换全局同 id 的整条记录,**不是字段级合并**。只改一个字段(如补 `initializationOptions`)也必须把 `include`/`rootMarkers`/`bin`/`args`/`cwd`/`languageIdByExtension` 全部带上,否则丢全局字段 |
21
+ | `watch` | 字段级合并:全局为基底、本地逐字段覆盖;`ignore` 两侧**并集去重**(全局在前),本地写 watch 段不会清掉全局 ignore |
22
+ | 某段全局本地都未出现 | 保持缺失(调用方用 `...(watch && { watch })` 省略键,不要输出空对象) |
23
+
24
+ 配置经 typebox **严格校验**(`Type.Object` 默认拒绝多余字段):未知的 `version`、多余键、非法时长格式都会在读取时抛错。`version` 当前为 1。
25
+
26
+ ## 顶层字段速查
27
+
28
+ ```
29
+ version / servers / enabled / disabled / watch / maxOpenDocuments /
30
+ diagnosticsDebounceMs / diagnosticsDocumentWaitTimeoutMs /
31
+ diagnosticsFullWaitTimeoutMs / diagnosticsRequestTimeoutMs / initializeTimeoutMs
32
+ ```
33
+
34
+ - `enabled`:只启用列出的服务器 id(缺省 = 全部启用);`disabled`:从启用集中排除
35
+ - 时长字段:毫秒数字,或带单位的字符串(`"300ms"` / `"5s"` / `"1m"` / `"2h"`,空单位按 ms);默认值见 `clientDefaults`(client.ts):debounce 150ms、document 等待 5s、full 等待 10s、pull 请求 3s、initialize 45s、`maxOpenDocuments` 32
36
+ - `watch`:`enabled` / `debounceMs`(缺省 300)/ `maxBatch`(缺省 500)/ `ignore`(glob,相对工作区根的 POSIX 路径)。注意 `flushMs` 只在默认值里、**不可配**
37
+
38
+ ## servers.<id> 字段
39
+
40
+ | 字段 | 说明 |
41
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
42
+ | `include` | 文件 glob(相对项目根或调用 cwd,任一命中即可);缺省匹配所有文件 |
43
+ | `kind` | `language`(缺省)或 `linter`。rename / inspect 等符号级功能**只面向 `language`**;linter 只参与诊断 |
44
+ | `rootMarkers` | 项目根标记文件名(从文件目录向上查找);缺省把调用 cwd 当根 |
45
+ | `bin` | 可执行文件:绝对路径、相对调用 cwd 的路径、或仅名字(项目工作区优先,PATH 兜底) |
46
+ | `args` | 启动参数数组 |
47
+ | `cwd` | 启动工作目录,支持 `{root}` / `{cwd}` 模板;缺省 `{root}` |
48
+ | `env` | 追加的环境变量:string 值支持 `{root}`/`{cwd}` 与 `${VAR}` 插值;`{ "sh": ["cmd","arg"] }` 启动时执行命令取 stdout(非零退出/空输出 → 启动失败并报错) |
49
+ | `languageIdByExtension` | 扩展名(含点)→ languageId;缺省回退内置映射(见 `src/lib/lsp/language.ts`,覆盖主流语言) |
50
+ | `startupTimeoutMs` / `diagnosticsWaitMs` | 覆盖该服务器的初始化握手 / 写文件后诊断等待(缺省用全局配置与 client 默认) |
51
+ | `initializationOptions` | 透传给 initialize 请求;字符串值支持 `${VAR}` / `${VAR:-default}`(读 process env,**不支持 `{root}`/`{cwd}` 模板**) |
52
+ | `settings` | `workspace/didChangeConfiguration` 与 `workspace/configuration` 请求的负载;缺省回退 `initializationOptions` |
53
+
54
+ ## typescript-language-server:TS 从哪来(易踩坑)
55
+
56
+ typescript-language-server **不内置 TypeScript**(零依赖)。启动时按下面顺序找一个可用的 tsserver,**全找不到就在 initialize 阶段报错退出**:
57
+
58
+ 1. `initializationOptions.tsserver.path`(UserSetting,优先级最高)
59
+ 2. workspace 探测:`<项目根>/node_modules/typescript/lib/tsserver.js`
60
+ 3. 自身 `require.resolve('typescript')`(即它安装位置能解析到的 typescript)
61
+
62
+ `tsserver.path` 必须指向 **`typescript/lib/tsserver.js` 或 `typescript/lib/` 目录的绝对路径**(或 PATH 可执行名);因为初始化字符串不支持 `{root}` 模板,无法用相对 workspace 的表达式。
63
+
64
+ **alias 依赖坑**:当项目把 typescript 写成 `npm:@typescript/typescript6`(alias stub)时,`node_modules/typescript/lib/` 下只有转发 stub(`typescript.js`/`tsserverlibrary.js`/`tsc.js`),**没有 `tsserver.js`** → workspace 探测失效;实体 tsserver 在 pnpm 虚拟 store:`node_modules/.pnpm/typescript@<真实版本>/node_modules/typescript/lib/tsserver.js`(目录名无 peer 后缀、相对稳定;**升级 typescript 版本后要同步更新路径**)。依赖为标准 `typescript` 包时 `node_modules/typescript` 直接是完整包,workspace 探测即命中、无需配 path。
65
+
66
+ 配 `initializationOptions.tsserver.path` 时,因为 servers 按 id 整条覆盖,本地 `.pi/lsp.json` 要写完整条目,示例:
67
+
68
+ ```json
69
+ {
70
+ "servers": {
71
+ "typescript": {
72
+ "include": ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],
73
+ "rootMarkers": ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"],
74
+ "bin": "typescript-language-server",
75
+ "args": ["--stdio"],
76
+ "cwd": "{root}",
77
+ "languageIdByExtension": {
78
+ ".ts": "typescript",
79
+ ".tsx": "typescriptreact",
80
+ ".js": "javascript",
81
+ ".jsx": "javascriptreact"
82
+ },
83
+ "initializationOptions": {
84
+ "tsserver": {
85
+ "path": "/path/to/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/lib/tsserver.js"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## 服务器启动失败的行为(改了配置后怎么验证)
94
+
95
+ - 启动失败(binary 缺失 / initialize 拒绝)会**主动 notify 报错**(server id、root、原因、提示 `/lsp-reload <id>`),不需要等请求方触发
96
+ - 失败进入 60s 冷却:冷却内该服务器被跳过,**冷却过后下次触碰自动重试**——修复配置后不用重启 agent,等冷却过即可,或立即 `/lsp-reload <id>` 重启指定服务器(同时清除失败记录)
97
+ - 命令:`/lsp-reload <id>`(重启单个)、`/lsp-stop`(停全部并禁用)、`/lsp-start`(重新启用)
98
+
99
+ ## 生效时机
100
+
101
+ - `servers` 配置在每次工具调用时惰性重读:**还没启动**的服务器,改 `bin`/`initializationOptions` 等下次触碰即生效;**已启动**的进程要用 `/lsp-reload <id>`(或重启 agent)才换新配置
102
+ - `session_start` 时若 enabled 服务器数为 0 则不创建 service,之后从无到有地启用需要重启 agent
103
+
104
+ ## 常见排查
105
+
106
+ | 现象 | 原因与修法 |
107
+ | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | notify `failed to start …: binary not found` | `bin` 不在 PATH(测试场景二进制缺失时整组跳过) |
109
+ | `… provides no tsserver.js. No other valid TypeScript installation was found` | workspace 的 `node_modules/typescript` 是 alias stub:配 `initializationOptions.tsserver.path` 指 `.pnpm` 实体,或换标准 typescript 依赖 |
110
+ | 诊断一直为空且无任何报错 | 服务器没匹配到文件(`include`/扩展名)、在 broken 冷却中、或文件在调用 cwd 之外(LSP 只在工作目录内启用) |
111
+ | 读取配置直接抛错 | typebox 严格校验拒绝:多余字段 / 未知 `version` / 非法时长格式 |