@trim21/personal-pi-extensions 0.1.525 → 0.1.528

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.525",
3
+ "version": "0.1.528",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/bwrap/core.ts CHANGED
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { constants, type Dirent, existsSync, readFileSync } from "node:fs";
3
3
  import { access as fsAccess, readdir, realpath, stat } from "node:fs/promises";
4
4
  import { delimiter, join } from "node:path";
5
+ import process from "node:process";
5
6
  import { fileURLToPath } from "node:url";
6
7
 
7
8
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -353,6 +354,15 @@ async function realpathOrSelf(path: string): Promise<string> {
353
354
 
354
355
  export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Promise<string[]> {
355
356
  const args = ["--new-session", "--die-with-parent", "--unshare-user", "--unshare-pid"];
357
+ // 沙箱进程以调用方(pi)的 uid/gid 运行,而不是 userns 里的 0。
358
+ // net-allowlist 模式下命令先经 nsenter 进入 holder 的 userns(unshare -r 把 pi 的 uid 映射成 0),
359
+ // bwrap 默认继承该 uid 会让沙箱内 id/stat 自称 root、与宿主视角不一致;
360
+ // 直接模式(无 holder)下这两个值本就等于 bwrap 的 real uid,等价于默认行为。
361
+ const uid = process.getuid?.();
362
+ const gid = process.getgid?.();
363
+ if (uid !== undefined && gid !== undefined) {
364
+ args.push("--uid", String(uid), "--gid", String(gid));
365
+ }
356
366
  // --*-bind-try:配置的路径不存在时忽略该项而不是让整条命令失败
357
367
  for (const path of resolved.writablePaths) {
358
368
  const absolutePath = await realpathOrSelf(resolveBwrapPath(path, cwd));
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * LSP 服务器插件契约:每个语言服务器一个 adapter class,向管理器提供统一接口。
3
3
  *
4
- * - root 由管理器按 workingDir 计算(缺省调用 cwd);文件必须位于 root 之内
5
- * 且命中 include(未配置时全匹配)才会由该服务器处理;
4
+ * - root serverRoot 按文件的 rootMarkers / workingDir 解析(缺省调用 cwd);
5
+ * 文件必须位于 root 之内且命中 include(未配置时全匹配)才会由该服务器处理;
6
6
  * - spawn 返回 undefined 表示服务器不可用(二进制未安装)。
7
7
  */
8
8
 
9
9
  import type { ChildProcessWithoutNullStreams } from "node:child_process";
10
- import { resolve } from "node:path";
10
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+
12
+ import { exists } from "./bin.js";
11
13
 
12
14
  export interface LspServerHandle {
13
15
  process: ChildProcessWithoutNullStreams;
@@ -33,8 +35,14 @@ export interface LspServerAdapter {
33
35
  * 过滤在管理器的 client 匹配阶段完成,adapter 不再持有 findRoot。
34
36
  */
35
37
  readonly include?: readonly string[];
36
- /** 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即 cwd */
38
+ /** 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即 cwd。与 rootMarkers 互斥。 */
37
39
  readonly workingDir?: string;
40
+ /**
41
+ * 项目根标记文件名(精确匹配,目录名亦可):从调用 cwd 沿文件路径向下逐级查找,
42
+ * 第一个含任一标记的目录即 root(cwd 自身命中即 cwd),未命中回退 cwd。
43
+ * 与 workingDir 互斥。
44
+ */
45
+ readonly rootMarkers?: readonly string[];
38
46
  /** per-server initialize 握手超时(ms);缺省用全局配置 / client 默认。 */
39
47
  readonly startupTimeoutMs?: number;
40
48
  /** per-server 诊断等待时长(ms);缺省用全局配置 / client 默认。 */
@@ -42,7 +50,43 @@ export interface LspServerAdapter {
42
50
  spawn(root: string, cwd: string): Promise<LspServerHandle | undefined>;
43
51
  }
44
52
 
45
- /** 计算服务器 root:workingDir 未配置时即调用 cwd,否则相对 cwd 解析(绝对路径原样)。 */
46
- export function serverRoot(workingDir: string | undefined, cwd: string): string {
47
- return workingDir === undefined ? cwd : resolve(cwd, workingDir);
53
+ /**
54
+ * cwd target 的目录链(含两端)。target cwd 之外时只返回 cwd,
55
+ * 保证 rootMarkers 搜索不越过会话工作目录。
56
+ */
57
+ function dirChain(cwd: string, target: string): string[] {
58
+ const dirs = [cwd];
59
+ const rel = relative(cwd, target);
60
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return dirs;
61
+ let current = cwd;
62
+ for (const part of rel.split(sep)) {
63
+ current = join(current, part);
64
+ dirs.push(current);
65
+ }
66
+ return dirs;
67
+ }
68
+
69
+ /**
70
+ * 解析文件的服务器 root:
71
+ * - 配置了 rootMarkers(非空)时从 cwd 沿文件路径向下找第一个含任一标记的目录
72
+ * (cwd 自身命中即 cwd),路径上没有命中回退 cwd;
73
+ * - 否则即 workingDir(相对 cwd 解析,绝对路径原样)或 cwd。
74
+ *
75
+ * 取最外层(最靠近 cwd)的命中目录:cwd 是项目根时 root 恒为 cwd,rootMarkers
76
+ * 只在"cwd 是容器目录、其下有多个独立项目"时生效。反向的最近优先会让 root 比
77
+ * 根 tsconfig 更深,而 tsserver 的 tsconfig 搜索被 root 截断,根配置将不再加载。
78
+ */
79
+ export function serverRoot(
80
+ adapter: Pick<LspServerAdapter, "workingDir" | "rootMarkers">,
81
+ file: string,
82
+ cwd: string,
83
+ ): string {
84
+ const markers = adapter.rootMarkers ?? [];
85
+ if (markers.length > 0) {
86
+ for (const dir of dirChain(cwd, dirname(file))) {
87
+ if (markers.some((marker) => exists(join(dir, marker)))) return dir;
88
+ }
89
+ return cwd;
90
+ }
91
+ return adapter.workingDir === undefined ? cwd : resolve(cwd, adapter.workingDir);
48
92
  }
@@ -169,10 +169,22 @@ function toMs(value: number | string | undefined): number | undefined {
169
169
  return Number.isFinite(ms) && ms > 0 ? ms : undefined;
170
170
  }
171
171
 
172
- /** 把合并后的原始配置解析为生效配置:应用 configDefaults 缺省、字符串时长换算、白名单转 Set。 */
172
+ /**
173
+ * 把合并后的原始配置解析为生效配置:应用 configDefaults 缺省、字符串时长换算、
174
+ * 白名单转 Set;per-server 的 workingDir / rootMarkers 互斥校验在此完成
175
+ * (配置解析期抛错,与 enabled 白名单校验同层)。
176
+ */
173
177
  export function resolveConfig(raw: LspConfig): ResolvedLspConfig {
178
+ const servers = raw.servers ?? {};
179
+ for (const [id, server] of Object.entries(servers)) {
180
+ if (server.workingDir !== undefined && server.rootMarkers !== undefined) {
181
+ throw new Error(
182
+ `lsp.json: server "${id}": workingDir and rootMarkers are mutually exclusive`,
183
+ );
184
+ }
185
+ }
174
186
  return {
175
- servers: raw.servers ?? {},
187
+ servers,
176
188
  enabled: raw.enabled === undefined ? undefined : new Set(raw.enabled),
177
189
  disabled: raw.disabled === undefined ? undefined : new Set(raw.disabled),
178
190
  watch: {
@@ -336,7 +348,7 @@ interface LspState {
336
348
  disabled: boolean;
337
349
  /** root+serverID → 服务器状态,用于 footer status 显示。 */
338
350
  servers: Map<string, { serverID: string; root: string; state: "running" | "broken" }>;
339
- /** 当前会话工作目录(watcher 挂载点);变化时重建监听器。 */
351
+ /** 当前会话工作目录;变化时重算监听范围。 */
340
352
  cwd: string | undefined;
341
353
  /** client → adapter 扩展名集合,fan-out 时按扩展名过滤。 */
342
354
  clientExtensions: Map<LspClient, readonly string[]>;
@@ -517,19 +529,136 @@ export function createLspService(
517
529
 
518
530
  // ── 工作区文件监听(watcher)───────────────────────────────────────────────
519
531
 
520
- let watcher: WorkspaceWatcher | undefined;
521
- let watcherCwd: string | undefined;
532
+ /** 监听目录 监听器;范围 = 活跃 client 的 root,随 client 集合增量维护。 */
533
+ const watchers = new Map<string, WorkspaceWatcher>();
534
+ /** 因资源耗尽(ENOSPC / EMFILE)停用的监听目录;/lsp-reload 后重试。 */
535
+ const watcherFailed = new Set<string>();
522
536
 
523
- async function stopWatcher(): Promise<void> {
524
- const current = watcher;
525
- watcher = undefined;
526
- watcherCwd = undefined;
527
- if (current) {
528
- try {
529
- await current.stop();
530
- } catch {
537
+ /**
538
+ * client root 的有效监听目录:root 在 cwd 内即 root;root 是 cwd 的祖先时
539
+ * 退化为 cwd(client 只服务 cwd 内的文件);与 cwd 无交集时返回 undefined
540
+ */
541
+ function watchDirFor(root: string, cwd: string): string | undefined {
542
+ if (containsPath(root, cwd)) return root;
543
+ if (containsPath(cwd, root)) return cwd;
544
+ return undefined;
545
+ }
546
+
547
+ /** 期望监听的最外层目录集合:被其他目录包含的去掉,已停用的排除。 */
548
+ function desiredWatchRoots(cwd: string): string[] {
549
+ const dirs = new Set<string>();
550
+ for (const client of state.clients) {
551
+ const dir = watchDirFor(client.root, cwd);
552
+ if (dir && !watcherFailed.has(dir)) dirs.add(dir);
553
+ }
554
+ return [...dirs]
555
+ .filter((dir) => [...dirs].every((other) => other === dir || !containsPath(dir, other)))
556
+ .toSorted();
557
+ }
558
+
559
+ async function stopWatchers(): Promise<void> {
560
+ const current = [...watchers.values()];
561
+ watchers.clear();
562
+ await Promise.all(
563
+ current.map((watcher) =>
564
+ watcher.stop().catch(() => {
565
+ // 停止失败不影响流程
566
+ }),
567
+ ),
568
+ );
569
+ }
570
+
571
+ /** 为单个目录建立监听器;启动失败返回 undefined(已提示)。 */
572
+ async function startWatcher(
573
+ root: string,
574
+ watch: EffectiveWatchConfig,
575
+ notify?: ExtensionUIContext["notify"],
576
+ ): Promise<WorkspaceWatcher | undefined> {
577
+ const report = notify ?? sessionNotify;
578
+ const onError = (message: string): void => {
579
+ // 资源耗尽不可恢复:停用该目录直到 /lsp-reload(对齐 VS Code 对 ENOSPC 的处理)
580
+ if (!/ENOSPC|EMFILE|No space left on device/i.test(message)) {
581
+ report?.(message, "error");
582
+ return;
583
+ }
584
+ watcherFailed.add(root);
585
+ const current = watchers.get(root);
586
+ watchers.delete(root);
587
+ void current?.stop().catch(() => {
531
588
  // 停止失败不影响流程
589
+ });
590
+ report?.(
591
+ `${message}; stopped watching ${root} until /lsp-reload (raise fs.inotify.max_user_watches first)`,
592
+ "error",
593
+ );
594
+ };
595
+ try {
596
+ const watcher = await watchWorkspace(root, (changes) => void fanOut(changes), {
597
+ debounceMs: watch.debounceMs,
598
+ flushMs: watch.flushMs,
599
+ maxBatch: watch.maxBatch,
600
+ ignore: watch.ignore,
601
+ onError,
602
+ onTruncated: () =>
603
+ report?.(
604
+ "workspace file events truncated (batch limit exceeded); run /lsp-reload <id> if diagnostics look stale",
605
+ "warning",
606
+ ),
607
+ });
608
+ // 建立过程中已报不可恢复错误:直接停掉,不进监听表
609
+ if (watcherFailed.has(root)) {
610
+ await watcher.stop().catch(() => {
611
+ // 停止失败不影响流程
612
+ });
613
+ return undefined;
532
614
  }
615
+ return watcher;
616
+ } catch (error) {
617
+ report?.(
618
+ `workspace watcher failed to start for ${root}: ${
619
+ error instanceof Error ? error.message : String(error)
620
+ }`,
621
+ "error",
622
+ );
623
+ return undefined;
624
+ }
625
+ }
626
+
627
+ /**
628
+ * 让监听范围与当前活跃 client 集合一致:只对差集 stop / start,
629
+ * 已有目录的监听器保持不动(避免新增 client 时打断其他目录的事件流)。
630
+ */
631
+ async function reconcileWatchers(
632
+ cwd: string,
633
+ notify?: ExtensionUIContext["notify"],
634
+ ): Promise<void> {
635
+ if (state.closing || state.disabled) return;
636
+ let config: ResolvedLspConfig;
637
+ try {
638
+ config = await currentConfig(cwd);
639
+ } catch (error) {
640
+ // 配置重读失败(如 reload 时盘上配置损坏):保持现有监听范围,不使调用方失败
641
+ sessionNotify?.(
642
+ `LSP watcher: re-reading config failed, keeping current watch scope: ${
643
+ error instanceof Error ? error.message : String(error)
644
+ }`,
645
+ "warning",
646
+ );
647
+ return;
648
+ }
649
+ const desired = config.watch.enabled ? desiredWatchRoots(cwd) : [];
650
+ // Map 迭代期间删除当前项是安全的
651
+ for (const [root, watcher] of watchers) {
652
+ if (desired.includes(root)) continue;
653
+ watchers.delete(root);
654
+ void watcher.stop().catch(() => {
655
+ // 停止失败不影响流程
656
+ });
657
+ }
658
+ for (const root of desired) {
659
+ if (watchers.has(root)) continue;
660
+ const watcher = await startWatcher(root, config.watch, notify);
661
+ if (watcher) watchers.set(root, watcher);
533
662
  }
534
663
  }
535
664
 
@@ -581,38 +710,6 @@ export function createLspService(
581
710
  );
582
711
  }
583
712
 
584
- /** 首个 client 建立 / 会话 cwd 变化时(重)建监听器;watch.enabled: false 时不启动。 */
585
- async function ensureWatcher(cwd: string, notify?: ExtensionUIContext["notify"]): Promise<void> {
586
- if (state.closing || state.disabled) return;
587
- const config = await currentConfig(cwd);
588
- const watch = config.watch;
589
- if (!watch.enabled) return;
590
- if (watcher && watcherCwd === cwd) return;
591
- await stopWatcher();
592
- try {
593
- watcher = await watchWorkspace(cwd, (changes) => void fanOut(changes), {
594
- debounceMs: watch.debounceMs,
595
- flushMs: watch.flushMs,
596
- maxBatch: watch.maxBatch,
597
- ignore: watch.ignore,
598
- onError: (message) => notify?.(message, "error"),
599
- onTruncated: () =>
600
- notify?.(
601
- "workspace file events truncated (batch limit exceeded); run /lsp-reload <id> if diagnostics look stale",
602
- "warning",
603
- ),
604
- });
605
- watcherCwd = cwd;
606
- } catch (error) {
607
- notify?.(
608
- `workspace watcher failed to start for ${cwd}: ${
609
- error instanceof Error ? error.message : String(error)
610
- }`,
611
- "error",
612
- );
613
- }
614
- }
615
-
616
713
  /** 汇总当前所有 LSP server 状态并渲染到 footer status。 */
617
714
  function updateStatusText(): void {
618
715
  if (!renderStatus) return;
@@ -715,7 +812,7 @@ export function createLspService(
715
812
  state.brokenFailAt.delete(key);
716
813
  state.brokenNotifiedAt.delete(key);
717
814
  updateStatusText();
718
- void ensureWatcher(cwd, notify);
815
+ void reconcileWatchers(cwd, notify);
719
816
  return client;
720
817
  } catch (error) {
721
818
  reportStartupFailure(
@@ -748,16 +845,16 @@ export function createLspService(
748
845
  const extension = extname(file) || file;
749
846
  const result: LspClient[] = [];
750
847
 
751
- // 会话 cwd 变化时重建 watcher(首个 client 建立后启动)
848
+ // 会话 cwd 变化时重算监听范围(root 不在新 cwd 内的 client 不再被监听)
752
849
  if (state.cwd !== cwd) {
753
850
  state.cwd = cwd;
754
- await stopWatcher();
851
+ await reconcileWatchers(cwd);
755
852
  }
756
853
 
757
854
  for (const adapter of active) {
758
855
  if (adapterFilter && !adapterFilter(adapter)) continue;
759
856
  if (adapter.extensions.length > 0 && !adapter.extensions.includes(extension)) continue;
760
- const root = serverRoot(adapter.workingDir, cwd);
857
+ const root = serverRoot(adapter, file, cwd);
761
858
  if (!containsPath(file, root)) continue;
762
859
  if (!matchesInclude(adapter.include ?? [], file, root, cwd)) continue;
763
860
  const key = root + adapter.id;
@@ -966,7 +1063,8 @@ export function createLspService(
966
1063
  state.brokenFailAt.clear();
967
1064
  state.brokenNotifiedAt.clear();
968
1065
  state.servers.clear();
969
- await stopWatcher();
1066
+ await stopWatchers();
1067
+ watcherFailed.clear();
970
1068
  updateStatusText();
971
1069
  }
972
1070
 
@@ -987,15 +1085,19 @@ export function createLspService(
987
1085
  state.disabled = false;
988
1086
  state.brokenFailAt.clear();
989
1087
  state.brokenNotifiedAt.clear();
1088
+ watcherFailed.clear();
990
1089
  updateStatusText();
991
1090
  }
992
1091
 
993
1092
  /**
994
- * reload 后立即重启此前运行中的服务器,不再等下一次工具调用。只重启新配置
995
- * 中仍存在且启用的 server;无运行记录(如 /lsp-stop 之后)或配置重读失败时
996
- * 不动,保持惰性 spawn。返回成功重启的 server id。
1093
+ * reload 后立即重启此前运行中的 (server, root) 实例,不再等下一次工具调用。
1094
+ * 只重启新配置中仍存在且启用的 server;无运行记录(如 /lsp-stop 之后)或
1095
+ * 配置重读失败时不动,保持惰性 spawn。root 沿用重载前的值(reload 没有文件
1096
+ * 上下文,标记文件的变化留给下一次触碰自然生效)。返回成功重启的 server id。
997
1097
  */
998
- async function respawnRunning(serverIDs: readonly string[]): Promise<string[]> {
1098
+ async function respawnRunning(
1099
+ running: readonly { serverID: string; root: string }[],
1100
+ ): Promise<string[]> {
999
1101
  const cwd = state.cwd;
1000
1102
  if (!cwd) return [];
1001
1103
  let config: ResolvedLspConfig;
@@ -1011,25 +1113,25 @@ export function createLspService(
1011
1113
  return [];
1012
1114
  }
1013
1115
  const active = filterAdapters(adapters ?? createAdapters(config.servers), config);
1116
+ const unique = new Map(running.map((item) => [`${item.root}\0${item.serverID}`, item]));
1014
1117
  const restarted = await Promise.all(
1015
- serverIDs.map(async (serverID): Promise<string | undefined> => {
1118
+ [...unique.values()].map(async ({ serverID, root }): Promise<string | undefined> => {
1016
1119
  const adapter = active.find((candidate) => candidate.id === serverID);
1017
1120
  if (!adapter) return;
1018
- const root = serverRoot(adapter.workingDir, cwd);
1019
1121
  const client = await startClient(adapter, root, cwd, config);
1020
1122
  return client ? serverID : undefined;
1021
1123
  }),
1022
1124
  );
1023
- return restarted.filter((id): id is string => id !== undefined);
1125
+ return [...new Set(restarted.filter((id): id is string => id !== undefined))];
1024
1126
  }
1025
1127
 
1026
1128
  async function reload(serverID: string): Promise<string[]> {
1027
- const wasRunning = state.clients.some((client) => client.serverID === serverID);
1129
+ const targets = state.clients.filter((client) => client.serverID === serverID);
1130
+ const running = targets.map((client) => ({ serverID: client.serverID, root: client.root }));
1028
1131
  state.closing = true;
1029
1132
  // 配置缓存失效:立即重读盘上配置,让配置修改生效
1030
1133
  state.config = undefined;
1031
1134
  state.configCwd = undefined;
1032
- const targets = state.clients.filter((client) => client.serverID === serverID);
1033
1135
  await Promise.all(targets.map((client) => client.shutdown())).catch(() => {
1034
1136
  // 个别进程退出失败不阻止清理流程
1035
1137
  });
@@ -1041,16 +1143,21 @@ export function createLspService(
1041
1143
  state.brokenNotifiedAt.delete(key);
1042
1144
  state.servers.delete(key);
1043
1145
  }
1044
- if (state.clients.length === 0) await stopWatcher();
1045
1146
  state.closing = false;
1046
1147
  state.disabled = false;
1148
+ // reload 是资源耗尽后的重试入口:清掉停用记录并按新配置重算监听范围
1149
+ watcherFailed.clear();
1150
+ if (state.cwd) await reconcileWatchers(state.cwd);
1047
1151
  updateStatusText();
1048
- if (!wasRunning) return [];
1049
- return respawnRunning([serverID]);
1152
+ if (running.length === 0) return [];
1153
+ return respawnRunning(running);
1050
1154
  }
1051
1155
 
1052
1156
  async function reloadAll(): Promise<string[]> {
1053
- const runningIDs = [...new Set(state.clients.map((client) => client.serverID))];
1157
+ const running = state.clients.map((client) => ({
1158
+ serverID: client.serverID,
1159
+ root: client.root,
1160
+ }));
1054
1161
  state.closing = true;
1055
1162
  state.config = undefined;
1056
1163
  state.configCwd = undefined;
@@ -1062,11 +1169,12 @@ export function createLspService(
1062
1169
  state.brokenFailAt.clear();
1063
1170
  state.brokenNotifiedAt.clear();
1064
1171
  state.servers.clear();
1065
- await stopWatcher();
1066
1172
  state.closing = false;
1067
1173
  state.disabled = false;
1174
+ watcherFailed.clear();
1175
+ if (state.cwd) await reconcileWatchers(state.cwd);
1068
1176
  updateStatusText();
1069
- return respawnRunning(runningIDs);
1177
+ return respawnRunning(running);
1070
1178
  }
1071
1179
 
1072
1180
  function serverIDs(): string[] {
@@ -7,8 +7,9 @@
7
7
  * 覆盖、新增 id、全局其余保留),之后受顶层 enabled/disabled 列表过滤。
8
8
  * 没有内置默认服务器:未配置 servers 时不启动任何服务器。
9
9
  *
10
- * 不做向上查找项目根的猜测:root 即调用 cwd(或 per-server workingDir),
11
- * 文件归属由「文件位于 root 之内」+ include glob 判定。
10
+ * root 定位:per-server `rootMarkers` 从调用 cwd 沿文件路径向下找第一个含标记的
11
+ * 目录(未命中回退 cwd),或固定 `workingDir`(两者互斥,同时配置报错);文件归属
12
+ * 由「文件位于 root 之内」+ include glob 判定。
12
13
  *
13
14
  * executable 发现统一由用户配置:bin 支持绝对路径 / 项目工作区
14
15
  * (node_modules/.bin、.venv/bin、venv/bin)/ PATH,不再内置各语言的
@@ -32,9 +33,16 @@ export const serverConfigSchema = Type.Object({
32
33
  include: Type.Optional(Type.Array(Type.String())),
33
34
  /** 服务器类型:language(真语言服务器,缺省)或 linter(只实现 LSP 协议的 lint)。 */
34
35
  kind: Type.Optional(Type.Union([Type.Literal("language"), Type.Literal("linter")])),
36
+ /**
37
+ * 项目根标记文件名(精确匹配,目录名亦可):从调用 cwd 沿文件路径向下逐级查找,
38
+ * 第一个含任一标记的目录即 root(cwd 自身命中即 cwd),未命中回退 cwd;
39
+ * 与 workingDir 互斥。
40
+ */
41
+ rootMarkers: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
35
42
  /**
36
43
  * 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即 cwd。
37
44
  * 文件必须位于该目录内才会由本服务器处理;spawn 工作目录与 rootUri 均用它。
45
+ * 与 rootMarkers 互斥。
38
46
  */
39
47
  workingDir: Type.Optional(Type.String()),
40
48
  /** 可执行文件:绝对路径、相对调用 cwd 的路径,或名字(项目工作区优先,PATH 兜底)。 */
@@ -202,6 +210,7 @@ export class ConfigAdapter implements LspServerAdapter {
202
210
  readonly extensions: readonly string[] = [];
203
211
  readonly include: readonly string[];
204
212
  readonly workingDir: string | undefined;
213
+ readonly rootMarkers: readonly string[];
205
214
  readonly startupTimeoutMs: number | undefined;
206
215
  readonly diagnosticsWaitMs: number | undefined;
207
216
  readonly config: ServerConfig;
@@ -212,6 +221,7 @@ export class ConfigAdapter implements LspServerAdapter {
212
221
  this.kind = config.kind ?? "language";
213
222
  this.include = config.include ?? [];
214
223
  this.workingDir = config.workingDir;
224
+ this.rootMarkers = config.rootMarkers ?? [];
215
225
  this.startupTimeoutMs = config.startupTimeoutMs;
216
226
  this.diagnosticsWaitMs = config.diagnosticsWaitMs;
217
227
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
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、workingDir、initializationOptions、tsserver.path、watch 与诊断超时;或排查服务器起不来(binary not found / provides no tsserver.js)、typescript alias 依赖下 tsserver 找不到、诊断一直为空等问题。
3
+ description: Use when 编写、检查或排查 LSP 语言服务器配置 —— 项目本地 .pi/lsp.json 或全局 ~/.pi/agent/lsp.json:配置 typescript-language-server / pyright / ruff / gopls / clangd 等服务器的 bin、include、rootMarkers、workingDir、initializationOptions、tsserver.path、watch 与诊断超时;或排查服务器起不来(binary not found / provides no tsserver.js)、typescript alias 依赖下 tsserver 找不到、诊断一直为空、monorepo 子项目 root 不对等问题。
4
4
  ---
5
5
 
6
6
  # 配置 LSP 服务器(.pi/lsp.json)
@@ -14,14 +14,14 @@ description: Use when 编写、检查或排查 LSP 语言服务器配置 ——
14
14
 
15
15
  合并是**纯函数**(`mergeConfig(global, local)`),规则:
16
16
 
17
- | 段 | 合并方式 |
18
- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
- | 顶层其余字段 | 本地覆盖全局(浅合并) |
20
- | `servers` | 按服务器 id **整条覆盖**:本地写某个 id 会替换全局同 id 的整条记录,**不是字段级合并**。只改一个字段(如补 `initializationOptions`)也必须把 `include`/`workingDir`/`bin`/`args`/`languageIdByExtension` 全部带上,否则丢全局字段 |
21
- | `watch` | 字段级合并:全局为基底、本地逐字段覆盖;`ignore` 两侧**并集去重**(全局在前),本地写 watch 段不会清掉全局 ignore |
22
- | 某段全局本地都未出现 | 保持缺失(调用方用 `...(watch && { watch })` 省略键,不要输出空对象) |
17
+ | 段 | 合并方式 |
18
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
+ | 顶层其余字段 | 本地覆盖全局(浅合并) |
20
+ | `servers` | 按服务器 id **整条覆盖**:本地写某个 id 会替换全局同 id 的整条记录,**不是字段级合并**。只改一个字段(如补 `initializationOptions`)也必须把 `include`/`rootMarkers`/`workingDir`/`bin`/`args`/`languageIdByExtension` 全部带上,否则丢全局字段 |
21
+ | `watch` | 字段级合并:全局为基底、本地逐字段覆盖;`ignore` 两侧**并集去重**(全局在前),本地写 watch 段不会清掉全局 ignore |
22
+ | 某段全局本地都未出现 | 保持缺失(调用方用 `...(watch && { watch })` 省略键,不要输出空对象) |
23
23
 
24
- 配置经 typebox 校验:字段类型不符、非法时长格式等在读取时**直接抛错**;schema 外的未知字段(含已删除的遗留字段如 `rootMarkers`)**不拒绝**,以 warning notify 逐个上报。`version` 当前为 1。
24
+ 配置经 typebox 校验:字段类型不符、非法时长格式、per-server `workingDir` 与 `rootMarkers` 同现等在读取时**直接抛错**;schema 外的未知字段(含历史遗留字段如 `cwd`)**不拒绝**,以 warning notify 逐个上报。`version` 当前为 1。
25
25
 
26
26
  ## 顶层字段速查
27
27
 
@@ -33,22 +33,123 @@ diagnosticsFullWaitTimeoutMs / diagnosticsRequestTimeoutMs / initializeTimeoutMs
33
33
 
34
34
  - `enabled`:只启用列出的服务器 id(缺省 = 全部启用);`disabled`:从启用集中排除
35
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` 只在默认值里、**不可配**
36
+ - `watch`:`enabled` / `debounceMs`(缺省 300)/ `maxBatch`(缺省 500)/ `ignore`(glob,相对**每个被监听的项目根**的 POSIX 路径)。注意 `flushMs` 只在默认值里、**不可配**
37
+ - 监听范围不是整个 cwd,而是**当前活跃服务器 client 的项目根**:root 在 cwd 内就只监听 root(被其他 root 包含的 root 不重复监听,root 是 cwd 的祖先时退化为 cwd);没有活跃 client 就不监听。因此容器 cwd(`~/projects` 下多个仓库)里只有活跃服务器所在的项目会被 watch;`ignore` 的匹配基准也随之是各自的项目根。资源耗尽(ENOSPC)时该 root 的监听器会停止并提示一次,调大 `fs.inotify.max_user_watches` 后跑 `/lsp-reload` 重试
37
38
 
38
39
  ## servers.<id> 字段
39
40
 
40
- | 字段 | 说明 |
41
- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
42
- | `include` | 文件 glob(相对项目根或调用 cwd,任一命中即可);缺省匹配所有文件 |
43
- | `kind` | `language`(缺省)或 `linter`。rename / inspect 等符号级功能**只面向 `language`**;linter 只参与诊断 |
44
- | `workingDir` | 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即调用 cwd。文件必须位于该目录内才会由本服务器处理;spawn 工作目录与 rootUri 均用它 |
45
- | `bin` | 可执行文件:绝对路径、相对调用 cwd 的路径、或仅名字(项目工作区优先,PATH 兜底) |
46
- | `args` | 启动参数数组 |
47
- | `env` | 追加的环境变量:string 值支持 `{root}`/`{cwd}` 与 `${VAR}` 插值;`{ "sh": ["cmd","arg"] }` 启动时执行命令取 stdout(非零退出/空输出 → 启动失败并报错) |
48
- | `languageIdByExtension` | 扩展名(含点)→ languageId;缺省回退内置映射(见 `src/lib/lsp/language.ts`,覆盖主流语言) |
49
- | `startupTimeoutMs` / `diagnosticsWaitMs` | 覆盖该服务器的初始化握手 / 写文件后诊断等待(缺省用全局配置与 client 默认) |
50
- | `initializationOptions` | 透传给 initialize 请求;字符串值支持 `${VAR}` / `${VAR:-default}`(读 process env,**不支持 `{root}`/`{cwd}` 模板**) |
51
- | `settings` | `workspace/didChangeConfiguration` `workspace/configuration` 请求的负载;缺省回退 `initializationOptions` |
41
+ | 字段 | 说明 |
42
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `include` | 文件 glob(相对项目根或调用 cwd,任一命中即可);缺省匹配所有文件 |
44
+ | `kind` | `language`(缺省)或 `linter`。rename / inspect 等符号级功能**只面向 `language`**;linter 只参与诊断 |
45
+ | `rootMarkers` | 项目根标记文件名数组(精确匹配,目录名亦可,如 `pyproject.toml` / `go.mod` / `.git`):从调用 cwd 沿文件路径向下查找,第一个含标记的目录即 LSP rootcwd 自身命中即 cwd),未命中回退 cwd。**与 `workingDir` 互斥**(同现报配置错误) |
46
+ | `workingDir` | 服务器工作目录(即 LSP root):绝对路径或相对调用 cwd 的路径;缺省即调用 cwd。文件必须位于该目录内才会由本服务器处理;spawn 工作目录与 rootUri 均用它。**与 `rootMarkers` 互斥** |
47
+ | `bin` | 可执行文件:绝对路径、相对调用 cwd 的路径、或仅名字(项目工作区优先,PATH 兜底) |
48
+ | `args` | 启动参数数组 |
49
+ | `env` | 追加的环境变量:string 值支持 `{root}`/`{cwd}` 与 `${VAR}` 插值;`{ "sh": ["cmd","arg"] }` 启动时执行命令取 stdout(非零退出/空输出 → 启动失败并报错) |
50
+ | `languageIdByExtension` | 扩展名(含点)→ languageId;缺省回退内置映射(见 `src/lib/lsp/language.ts`,覆盖主流语言) |
51
+ | `startupTimeoutMs` / `diagnosticsWaitMs` | 覆盖该服务器的初始化握手 / 写文件后诊断等待(缺省用全局配置与 client 默认) |
52
+ | `initializationOptions` | 透传给 initialize 请求;字符串值支持 `${VAR}` / `${VAR:-default}`(读 process env,**不支持 `{root}`/`{cwd}` 模板**) |
53
+ | `settings` | `workspace/didChangeConfiguration` 与 `workspace/configuration` 请求的负载;缺省回退 `initializationOptions` |
54
+
55
+ ## 子目录项目:rootMarkers
56
+
57
+ cwd 本身不是项目根、其下并列多个独立项目时(如 `~/projects/a`、`~/projects/b`),用 `rootMarkers` 让服务器按各自项目根启动,不必为每个项目写一条 server 配置:
58
+
59
+ ```json
60
+ {
61
+ "servers": {
62
+ "pyright": {
63
+ "include": ["**/*.py", "**/*.pyi"],
64
+ "rootMarkers": ["pyproject.toml", "setup.py", "pyrightconfig.json"],
65
+ "bin": "pyright-langserver",
66
+ "args": ["--stdio"],
67
+ "languageIdByExtension": { ".py": "python", ".pyi": "python" }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ - 解析规则:从调用 cwd 沿文件路径向下找**第一个(最外层)**含任一标记的目录;cwd 是 `~/projects` 时,`~/projects/a/src/x.py` + `~/projects/a/pyproject.toml` → root 是 `~/projects/a`。cwd 自身含标记(仓库根有 `pyproject.toml`)时 root 恒为 cwd,`rootMarkers` 不起作用。
74
+ - 路径上没有命中时回退调用 cwd;搜索**不越过 cwd**(只看 cwd 及其后代)。
75
+ - 取最外层而不是离文件最近:tsserver 的 tsconfig 搜索从文件向上但**被 LSP root 截断**,root 比根 tsconfig 更深会让根配置完全加载不到;取最外层则更深的 tsconfig 仍由服务器自己向上找到。因此**不支持按子包定位**(每个 package 独立 root)——需要时用 `workingDir` 单条配置。
76
+ - 标记是**精确文件名**(目录名也可,如 `.git`),不支持 glob(C# `*.csproj` 这类暂不可用)。
77
+ - 同一服务器可为不同 root 各启动一个进程:`bin` 解析、`env` 的 `{root}`、项目工作区查找(`node_modules/.bin`、`.venv/bin`)都按各自 root 生效;footer status 与启动失败记录也按 root 分别显示。`/lsp-reload <id>` 会重启该服务器的所有 root 实例。
78
+ - 与 `workingDir` 互斥:需要固定单一 root 时用 `workingDir`,需要按子项目自动定位时用 `rootMarkers`,不要同时写。
79
+
80
+ ## Example
81
+
82
+ `~/.pi/agent/lsp.json` 可直接作为从零搭建的参考:七种服务器全部显式声明(无内置默认服务器),pyright / typescript 用 `rootMarkers` 支持子目录项目,actions 用 `env.sh` 启动时现取 token。
83
+
84
+ ```json
85
+ {
86
+ "initializeTimeoutMs": "1m",
87
+ "diagnosticsDocumentWaitTimeoutMs": "5s",
88
+ "watch": {
89
+ "ignore": ["**/.pdm-build"]
90
+ },
91
+ "servers": {
92
+ "rust-analyzer": {
93
+ "include": ["**/*.rs"],
94
+ "bin": "rust-analyzer",
95
+ "args": [],
96
+ "languageIdByExtension": { ".rs": "rust" }
97
+ },
98
+ "go": {
99
+ "include": ["**/*.go", "**/go.mod", "**/go.work"],
100
+ "bin": "gopls",
101
+ "args": ["serve"],
102
+ "languageIdByExtension": { ".go": "go" }
103
+ },
104
+ "pyright": {
105
+ "include": ["**/*.py", "**/*.pyi"],
106
+ "rootMarkers": ["pyproject.toml", "pyrightconfig.json"],
107
+ "bin": "pyright-langserver",
108
+ "args": ["--stdio"],
109
+ "languageIdByExtension": { ".py": "python", ".pyi": "python" }
110
+ },
111
+ "ruff": {
112
+ "include": ["**/*.pyi?"],
113
+ "bin": "ruff",
114
+ "args": ["server"],
115
+ "languageIdByExtension": { ".py": "python", ".pyi": "python" }
116
+ },
117
+ "typescript": {
118
+ "include": ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],
119
+ "rootMarkers": ["package.json", "tsconfig.json"],
120
+ "bin": "typescript-language-server",
121
+ "args": ["--stdio"],
122
+ "languageIdByExtension": {
123
+ ".ts": "typescript",
124
+ ".tsx": "typescriptreact",
125
+ ".js": "javascript",
126
+ ".jsx": "javascriptreact",
127
+ ".mjs": "javascript",
128
+ ".cjs": "javascript",
129
+ ".mts": "typescript",
130
+ ".cts": "typescript"
131
+ }
132
+ },
133
+ "yaml": {
134
+ "include": ["**/*.{yml,yaml}"],
135
+ "bin": "yaml-language-server",
136
+ "args": ["--stdio"],
137
+ "languageIdByExtension": { ".yml": "yaml", ".yaml": "yaml" }
138
+ },
139
+ "actions": {
140
+ "include": ["**/.github/workflows/*.ya?ml"],
141
+ "bin": "actions-languageserver",
142
+ "args": ["--stdio"],
143
+ "env": {
144
+ "GITHUB_TOKEN": { "sh": ["gh", "auth", "token"] }
145
+ },
146
+ "initializationOptions": {
147
+ "sessionToken": "${GITHUB_TOKEN}"
148
+ }
149
+ }
150
+ }
151
+ }
152
+ ```
52
153
 
53
154
  ## typescript-language-server:TS 从哪来(易踩坑)
54
155