@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 +1 -1
- package/src/bwrap/core.ts +10 -0
- package/src/lib/lsp/adapter.ts +51 -7
- package/src/lib/lsp/lsp.ts +173 -65
- package/src/lib/lsp/server-config.ts +12 -2
- package/src/skills/lsp-config/SKILL.md +122 -21
package/package.json
CHANGED
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));
|
package/src/lib/lsp/adapter.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LSP 服务器插件契约:每个语言服务器一个 adapter class,向管理器提供统一接口。
|
|
3
3
|
*
|
|
4
|
-
* - root
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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
|
}
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
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
|
|
848
|
+
// 会话 cwd 变化时重算监听范围(root 不在新 cwd 内的 client 不再被监听)
|
|
752
849
|
if (state.cwd !== cwd) {
|
|
753
850
|
state.cwd = cwd;
|
|
754
|
-
await
|
|
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
|
|
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
|
|
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
|
-
*
|
|
996
|
-
*
|
|
1093
|
+
* reload 后立即重启此前运行中的 (server, root) 实例,不再等下一次工具调用。
|
|
1094
|
+
* 只重启新配置中仍存在且启用的 server;无运行记录(如 /lsp-stop 之后)或
|
|
1095
|
+
* 配置重读失败时不动,保持惰性 spawn。root 沿用重载前的值(reload 没有文件
|
|
1096
|
+
* 上下文,标记文件的变化留给下一次触碰自然生效)。返回成功重启的 server id。
|
|
997
1097
|
*/
|
|
998
|
-
async function respawnRunning(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1049
|
-
return respawnRunning(
|
|
1152
|
+
if (running.length === 0) return [];
|
|
1153
|
+
return respawnRunning(running);
|
|
1050
1154
|
}
|
|
1051
1155
|
|
|
1052
1156
|
async function reloadAll(): Promise<string[]> {
|
|
1053
|
-
const
|
|
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(
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
41
|
+
| 字段 | 说明 |
|
|
42
|
+
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `include` | 文件 glob(相对项目根或调用 cwd,任一命中即可);缺省匹配所有文件 |
|
|
44
|
+
| `kind` | `language`(缺省)或 `linter`。rename / inspect 等符号级功能**只面向 `language`**;linter 只参与诊断 |
|
|
45
|
+
| `rootMarkers` | 项目根标记文件名数组(精确匹配,目录名亦可,如 `pyproject.toml` / `go.mod` / `.git`):从调用 cwd 沿文件路径向下查找,第一个含标记的目录即 LSP root(cwd 自身命中即 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
|
|