@trim21/personal-pi-extensions 0.1.508 → 0.1.510
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 +2 -1
- package/src/lib/lsp/lsp.ts +24 -5
- package/src/lib/lsp/watcher.ts +69 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.510",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"dependencies": {
|
|
101
101
|
"@cortexkit/aft-bridge": "0.55.0",
|
|
102
102
|
"@mozilla/readability": "^0.6.0",
|
|
103
|
+
"@parcel/watcher": "^2.6.0",
|
|
103
104
|
"@vscode/tree-sitter-wasm": "^0.3.1",
|
|
104
105
|
"jsonc-parser": "^3.3.1",
|
|
105
106
|
"linkedom": "^0.18.0",
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -1039,14 +1039,29 @@ export function createLspManager(
|
|
|
1039
1039
|
const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath);
|
|
1040
1040
|
validateConfig(config, options?.adapters);
|
|
1041
1041
|
if (enabledServerCount(config, options?.adapters) === 0) return;
|
|
1042
|
+
// 下面两个闭包被 service 长期持有,可能在会话被替换或 reload 后仍触发
|
|
1043
|
+
// (session_shutdown 清理、后台 watcher 错误、in-flight spawn 失败),
|
|
1044
|
+
// 而旧 ctx 已被 pi 标记 stale,ctx.ui getter 会直接抛错。UI 写入是尽力
|
|
1045
|
+
// 而为的上报,stale 时静默跳过——抛错逃逸会变成 unhandled rejection,
|
|
1046
|
+
// pi 会因此整个退出。
|
|
1042
1047
|
const next = createLspService(options?.adapters, options?.globalConfigPath, {
|
|
1043
1048
|
// 会话级通知:任何通道触发的启动失败都主动上报(不只依赖请求方 notify)
|
|
1044
|
-
notify: (message, level) =>
|
|
1049
|
+
notify: (message, level) => {
|
|
1050
|
+
try {
|
|
1051
|
+
ctx.ui.notify(message, level);
|
|
1052
|
+
} catch {
|
|
1053
|
+
/* 旧会话 ctx 已失效或 UI 不可用:通知无处可去 */
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1045
1056
|
});
|
|
1046
1057
|
// footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
|
|
1047
|
-
next.attachStatus((text) =>
|
|
1048
|
-
|
|
1049
|
-
|
|
1058
|
+
next.attachStatus((text) => {
|
|
1059
|
+
try {
|
|
1060
|
+
ctx.ui.setStatus("lsp", text ? ctx.ui.theme.fg("accent", text) : undefined);
|
|
1061
|
+
} catch {
|
|
1062
|
+
/* 旧会话 ctx 已失效:footer 不再属于本 service */
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1050
1065
|
service = next;
|
|
1051
1066
|
hooks.onEnabled(pi, next);
|
|
1052
1067
|
} catch (error) {
|
|
@@ -1056,7 +1071,11 @@ export function createLspManager(
|
|
|
1056
1071
|
});
|
|
1057
1072
|
|
|
1058
1073
|
pi.on("session_shutdown", () => {
|
|
1059
|
-
|
|
1074
|
+
// 关停阶段 UI 已不可用(可能恰逢会话替换、旧 ctx 已 stale),清理失败
|
|
1075
|
+
// 无处上报,静默吞掉以避免 unhandled rejection 致 pi 退出
|
|
1076
|
+
void service?.shutdownAll().catch(() => {
|
|
1077
|
+
/* noop */
|
|
1078
|
+
});
|
|
1060
1079
|
});
|
|
1061
1080
|
|
|
1062
1081
|
// agent 生命周期边界显式刷新 status:agent 运行中 LSP server 才被惰性
|
package/src/lib/lsp/watcher.ts
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 工作区文件监听器:@parcel/watcher 事件源 + 去抖批量回调。
|
|
3
3
|
*
|
|
4
|
-
* - 事件源用
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* - 事件源用 @parcel/watcher(原生实现,Linux 正确管理 inotify 生命周期,
|
|
5
|
+
* 忽略目录不建 watch,事件已区分 create / update / delete),
|
|
6
|
+
* 映射为 LSP 的 created / changed / deleted;
|
|
7
|
+
* - parcel 不报告目标是否目录:非删除事件用 lstat 学习已知目录集合并丢弃
|
|
8
|
+
* 目录事件;删除事件据此标记 isDirectory,由上层对驻留文档补 deleted;
|
|
8
9
|
* - 尾部去抖(缺省 300ms)合并短时洪峰,最长 flushMs(缺省 1s)强制清批;
|
|
9
10
|
* - 内置忽略 `node_modules` / `.git` / `dist` / `build` / `.venv` / `venv` /
|
|
10
|
-
* `target` / `coverage
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 子树耗尽 watch 配额导致 ENOSPC 崩溃);运行环境不支持时静默回退为事件层过滤;
|
|
11
|
+
* `target` / `coverage`,配置可追加;忽略列表下传 parcel 的 `ignore`
|
|
12
|
+
* 选项做后端层排除(忽略目录不递归、不建 watch),事件层再用 minimatch
|
|
13
|
+
* 过滤一遍兜底语义差异;
|
|
14
14
|
* - 单批超过 maxBatch(缺省 500)截断并回调 onTruncated 提示一次;
|
|
15
|
-
* - 目录事件默认丢弃;目录被删除时上报(isDirectory: true
|
|
16
|
-
*
|
|
17
|
-
* 不抛错。
|
|
15
|
+
* - 目录事件默认丢弃;目录被删除时上报(isDirectory: true)。监听器启动或
|
|
16
|
+
* 运行期失败时调用 onError 降级,不抛错。
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
|
-
import { lstat
|
|
21
|
-
import {
|
|
19
|
+
import { lstat } from "node:fs/promises";
|
|
20
|
+
import { normalize, relative, sep } from "node:path";
|
|
22
21
|
|
|
22
|
+
import {
|
|
23
|
+
type AsyncSubscription,
|
|
24
|
+
type Event as ParcelWatcherEvent,
|
|
25
|
+
subscribe,
|
|
26
|
+
} from "@parcel/watcher";
|
|
23
27
|
import { minimatch } from "minimatch";
|
|
24
28
|
|
|
25
29
|
export type FileChangeType = "created" | "changed" | "deleted";
|
|
@@ -68,34 +72,6 @@ function isIgnored(path: string, dir: string, patterns: string[]): boolean {
|
|
|
68
72
|
return patterns.some((pattern) => minimatch(candidate, pattern));
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
/**
|
|
72
|
-
* 为内核层 `fs.watch` 的 `ignore` 选项派生 pattern:为每条「目录内容」形态的
|
|
73
|
-
* glob(尾部为通配目录段)追加「目录本身」形态并去重——Node 内部按相对路径
|
|
74
|
-
* 对每个子项逐一匹配,只给内容形态时忽略目录本身仍会建 watch。
|
|
75
|
-
*
|
|
76
|
-
* 与事件层 minimatch 的语义差异(Node 内部 matcher 固定 `matchBase: true`、
|
|
77
|
-
* `nonegate: true`):无斜杠 pattern 按 basename 匹配任意层级,`!` 否定在
|
|
78
|
-
* 内核层不生效。内核层只会少产生事件,差异部分仍由事件层兜底。
|
|
79
|
-
*/
|
|
80
|
-
function kernelIgnorePatterns(patterns: readonly string[]): string[] {
|
|
81
|
-
const derived = new Set<string>();
|
|
82
|
-
for (const pattern of patterns) {
|
|
83
|
-
derived.add(pattern);
|
|
84
|
-
if (pattern.endsWith("/**")) derived.add(pattern.slice(0, -3));
|
|
85
|
-
}
|
|
86
|
-
return [...derived];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* `ignore` 选项的运行时支持始于 Node 24.14 / 26,@types/node 24.x 尚未声明;
|
|
91
|
-
* 同时需剔除 fs 模块 WatchOptions.encoding 中的 "buffer" 字面量,否则不可赋给
|
|
92
|
-
* fs/promises watch 返回 string filename 的重载。
|
|
93
|
-
*/
|
|
94
|
-
type FsWatchOptionsWithIgnore = Omit<FsWatchOptions, "encoding"> & {
|
|
95
|
-
encoding?: BufferEncoding;
|
|
96
|
-
ignore?: readonly string[];
|
|
97
|
-
};
|
|
98
|
-
|
|
99
75
|
/**
|
|
100
76
|
* 启动对 dir 的递归监听。onBatch 收到去抖合并后的批次;stop 后不再回调。
|
|
101
77
|
* 返回的 promise 只在监听器无法建立(目录不存在等)时 reject——运行期
|
|
@@ -109,12 +85,14 @@ export function watchWorkspace(
|
|
|
109
85
|
const debounceMs = options?.debounceMs ?? 300;
|
|
110
86
|
const flushMs = options?.flushMs ?? 1_000;
|
|
111
87
|
const maxBatch = options?.maxBatch ?? 500;
|
|
112
|
-
const ignorePatterns =
|
|
113
|
-
const abort = new AbortController();
|
|
88
|
+
const ignorePatterns = [...DEFAULT_IGNORE, ...(options?.ignore ?? [])];
|
|
114
89
|
|
|
115
|
-
//
|
|
90
|
+
// 从非删除事件学习已知目录,目录被删除时据此标记 isDirectory
|
|
116
91
|
const seenDirectories = new Set<string>();
|
|
117
92
|
|
|
93
|
+
let stopped = false;
|
|
94
|
+
let subscription: AsyncSubscription | undefined;
|
|
95
|
+
|
|
118
96
|
let pending: FileChange[] = [];
|
|
119
97
|
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
120
98
|
let maxTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -139,17 +117,14 @@ export function watchWorkspace(
|
|
|
139
117
|
};
|
|
140
118
|
|
|
141
119
|
const push = (change: FileChange): void => {
|
|
120
|
+
if (stopped) return;
|
|
142
121
|
if (pending.length === 0) maxTimer = setTimeout(flush, flushMs);
|
|
143
122
|
pending.push(change);
|
|
144
123
|
if (flushTimer) clearTimeout(flushTimer);
|
|
145
124
|
flushTimer = setTimeout(flush, debounceMs);
|
|
146
125
|
};
|
|
147
126
|
|
|
148
|
-
const classify = async (
|
|
149
|
-
filename: string,
|
|
150
|
-
eventType: "rename" | "change",
|
|
151
|
-
): Promise<FileChange | undefined> => {
|
|
152
|
-
const path = normalize(join(dir, filename));
|
|
127
|
+
const classify = async (path: string, type: FileChangeType): Promise<FileChange | undefined> => {
|
|
153
128
|
if (path === dir) return undefined;
|
|
154
129
|
let isDirectory = false;
|
|
155
130
|
let exists = true;
|
|
@@ -161,50 +136,65 @@ export function watchWorkspace(
|
|
|
161
136
|
exists = false;
|
|
162
137
|
if (seenDirectories.delete(path)) isDirectory = true;
|
|
163
138
|
}
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
if (isDirectory) return undefined;
|
|
167
|
-
return { path, type: "changed", isDirectory };
|
|
168
|
-
}
|
|
169
|
-
// rename:创建 / 删除 / 移入移出
|
|
139
|
+
if (type === "deleted") return { path, type, isDirectory };
|
|
140
|
+
// 事件与 lstat 之间的竞态:目标已消失按删除处理
|
|
170
141
|
if (!exists) return { path, type: "deleted", isDirectory };
|
|
171
142
|
if (isDirectory) return undefined;
|
|
172
|
-
return { path, type
|
|
143
|
+
return { path, type, isDirectory };
|
|
173
144
|
};
|
|
174
145
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
);
|
|
146
|
+
const handleError = (error: unknown): void => {
|
|
147
|
+
if (stopped) return;
|
|
148
|
+
options?.onError?.(`workspace watcher failed for ${dir}: ${String(error)}`);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleEvents = (error: Error | null, events: ParcelWatcherEvent[]): void => {
|
|
152
|
+
if (error) {
|
|
153
|
+
handleError(error);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
void (async () => {
|
|
157
|
+
for (const event of events) {
|
|
158
|
+
if (stopped) return;
|
|
159
|
+
const path = normalize(event.path);
|
|
160
|
+
const type: FileChangeType =
|
|
161
|
+
event.type === "create" ? "created" : event.type === "update" ? "changed" : "deleted";
|
|
162
|
+
const change = await classify(path, type);
|
|
189
163
|
if (!change) continue;
|
|
190
164
|
if (isIgnored(change.path, dir, ignorePatterns)) continue;
|
|
191
165
|
push(change);
|
|
192
166
|
}
|
|
167
|
+
})();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
async function startSubscription(): Promise<void> {
|
|
171
|
+
try {
|
|
172
|
+
subscription = await subscribe(dir, handleEvents, { ignore: ignorePatterns });
|
|
173
|
+
if (stopped) {
|
|
174
|
+
await subscription.unsubscribe();
|
|
175
|
+
subscription = undefined;
|
|
176
|
+
}
|
|
193
177
|
} catch (error) {
|
|
194
|
-
|
|
195
|
-
options?.onError?.(`workspace watcher failed for ${dir}: ${String(error)}`);
|
|
178
|
+
handleError(error);
|
|
196
179
|
}
|
|
197
|
-
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const started = startSubscription();
|
|
198
183
|
|
|
199
184
|
// 目录不存在 / 无权限等启动期问题在这里暴露,让调用方可以降级
|
|
200
185
|
return lstat(dir).then(() => ({
|
|
201
186
|
async stop(): Promise<void> {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
187
|
+
stopped = true;
|
|
188
|
+
clearTimers();
|
|
189
|
+
if (subscription) {
|
|
190
|
+
try {
|
|
191
|
+
await subscription.unsubscribe();
|
|
192
|
+
} catch {
|
|
193
|
+
// 订阅已失败或重复 stop
|
|
194
|
+
}
|
|
195
|
+
subscription = undefined;
|
|
207
196
|
}
|
|
197
|
+
await started;
|
|
208
198
|
},
|
|
209
199
|
}));
|
|
210
200
|
}
|