@trim21/personal-pi-extensions 0.1.509 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.509",
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",
@@ -1,25 +1,29 @@
1
1
  /**
2
- * 工作区文件监听器:单个递归 fs.watch + 去抖批量回调。
2
+ * 工作区文件监听器:@parcel/watcher 事件源 + 去抖批量回调。
3
3
  *
4
- * - 事件源用 `node:fs/promises` `watch(dir, { recursive: true, signal })`,
5
- * 不引入 chokidar(与仓库"FS 一律用 node:fs/promises"约定一致);
6
- * - create / delete / rename 在底层都表现为 `rename`,内容改动为 `change`,
7
- * LSP created / changed / deleted 类型由 `lstat` 判定;
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
- * - 忽略列表同时下传给 `fs.watch` 的 `ignore` 选项做内核层排除(Node >= 24.14 /
12
- * 26 的 recursive watch 对命中路径不创建 inotify watch,避免大型 `.git` 等
13
- * 子树耗尽 watch 配额导致 ENOSPC 崩溃);运行环境不支持时静默回退为事件层过滤;
11
+ * `target` / `coverage`,配置可追加;忽略列表下传 parcel 的 `ignore`
12
+ * 选项做后端层排除(忽略目录不递归、不建 watch),事件层再用 minimatch
13
+ * 过滤一遍兜底语义差异;
14
14
  * - 单批超过 maxBatch(缺省 500)截断并回调 onTruncated 提示一次;
15
- * - 目录事件默认丢弃;目录被删除时上报(isDirectory: true),由上层对其中
16
- * 的驻留文档补 deleted 事件。监听器启动或运行期失败时调用 onError 降级,
17
- * 不抛错。
15
+ * - 目录事件默认丢弃;目录被删除时上报(isDirectory: true)。监听器启动或
16
+ * 运行期失败时调用 onError 降级,不抛错。
18
17
  */
19
18
 
20
- import { lstat, watch, type WatchOptions as FsWatchOptions } from "node:fs/promises";
21
- import { join, normalize, relative, sep } from "node:path";
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 = kernelIgnorePatterns([...DEFAULT_IGNORE, ...(options?.ignore ?? [])]);
113
- const abort = new AbortController();
88
+ const ignorePatterns = [...DEFAULT_IGNORE, ...(options?.ignore ?? [])];
114
89
 
115
- // stat 成功事件学习已知目录,目录被删除时据此标记 isDirectory
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 (eventType === "change") {
165
- if (!exists) return { path, type: "deleted", isDirectory };
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: "created", isDirectory };
143
+ return { path, type, isDirectory };
173
144
  };
174
145
 
175
- const consumer = (async () => {
176
- try {
177
- const watchOptions: FsWatchOptionsWithIgnore = {
178
- recursive: true,
179
- signal: abort.signal,
180
- ignore: ignorePatterns,
181
- };
182
- const iterator = watch(dir, watchOptions);
183
- for await (const event of iterator) {
184
- if (!event.filename) continue;
185
- const change = await classify(
186
- event.filename,
187
- event.eventType === "change" ? "change" : "rename",
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
- if (abort.signal.aborted) return;
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
- abort.abort();
203
- try {
204
- await consumer;
205
- } catch {
206
- // 监听器已因 abort 正常退出
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
  }