@trim21/personal-pi-extensions 0.1.575 → 0.1.577

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/README.md CHANGED
@@ -138,7 +138,9 @@ bwrap 已集成进 bash 工具实现(opencode 风格 `bash` 位于 `src/openco
138
138
 
139
139
  所有匹配策略按顺序尝试,第一个匹配成功即返回。同时自动处理 BOM、CRLF/LF 行尾转换和文件写入队列。`filePath` 接受绝对路径或相对工作目录的路径。
140
140
 
141
- edit 要求目标文件已被 `read` 读过且内容未变(内容指纹比对,与 Claude Code 风格 Edit 同一套语义):没读过报 `File has not been read yet. Read it first before editing it.`,读后文件被外部改动报 `File has been modified since read...`,两种情况都要重新 `read`。`read`/`edit`/`write`/`lsp-rename` 都会刷新记账并随工具结果持久化,session 恢复 / fork / rewind 后依然有效;`write` 本身不要求先读,但写后会刷新记账,紧随其后的 `edit` 不必重新读。
141
+ edit 要求目标文件已被 `read` 读过且内容未变(内容指纹比对,与 Claude Code 风格 Edit 同一套语义):没读过报 `File has not been read yet. Read it first before writing to it.`,读后文件被外部改动报 `File has been modified since read...`,两种情况都要重新 `read`。
142
+
143
+ `write` 不要求先读——没读过的文件可以直接写;但如果该文件已被读过、之后又被外部改动(或删除),`write` 会与 `edit` 一样要求重新 `read`,不让读取记录过期的内容被盲覆盖。`read`/`edit`/`write`/`lsp-rename` 都会刷新记账并随工具结果持久化,session 恢复 / fork / rewind 后依然有效;写完也会刷新,紧随其后的 `edit` 不必重新读。
142
144
 
143
145
  ### 使用
144
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.575",
3
+ "version": "0.1.577",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -45,6 +45,25 @@ export async function fileDigest(filePath: string): Promise<string> {
45
45
  return hash.digest("hex");
46
46
  }
47
47
 
48
+ /** ENOENT / ENOTDIR:路径不存在。 */
49
+ function isMissingPath(error: unknown): boolean {
50
+ return (
51
+ error instanceof Error &&
52
+ "code" in error &&
53
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
54
+ );
55
+ }
56
+
57
+ /** 磁盘上当前内容的指纹;文件已不存在时返回 undefined(视为「与读取时不同」)。 */
58
+ export async function digestIfExists(filePath: string): Promise<string | undefined> {
59
+ try {
60
+ return await fileDigest(filePath);
61
+ } catch (error) {
62
+ if (isMissingPath(error)) return undefined;
63
+ throw error;
64
+ }
65
+ }
66
+
48
67
  /**
49
68
  * 记账 key:解析 symlink 后的真实路径。文件尚不存在(Write 新建 / Edit 空
50
69
  * old_string 创建)时 realpath 抛 ENOENT,回退到调用方给出的路径。
@@ -53,13 +72,7 @@ export async function readStateKey(filePath: string): Promise<string> {
53
72
  try {
54
73
  return await realpath(filePath);
55
74
  } catch (error) {
56
- if (
57
- error instanceof Error &&
58
- "code" in error &&
59
- (error.code === "ENOENT" || error.code === "ENOTDIR")
60
- ) {
61
- return filePath;
62
- }
75
+ if (isMissingPath(error)) return filePath;
63
76
  throw error;
64
77
  }
65
78
  }
@@ -89,6 +102,27 @@ export function requireCurrentRead(
89
102
  }
90
103
  }
91
104
 
105
+ /**
106
+ * 过期校验:该文件已有读取记录时,要求磁盘上的当前指纹与记录一致,否则拒绝
107
+ * 写入;从未读过(没有记录)时直接放行。
108
+ *
109
+ * opencode 的 write 用这个:没读过的文件允许直接写,读过之后再被外部改动就必须
110
+ * 重新 read。`currentDigest` 为 undefined(文件已不存在)同样算过期。
111
+ */
112
+ export function requireUnchangedRead(
113
+ state: ReadsState,
114
+ key: string,
115
+ currentDigest: string | undefined,
116
+ ): void {
117
+ const readSnapshot = state.reads.get(key);
118
+ if (!readSnapshot) return;
119
+ if (currentDigest === undefined || readSnapshot.digest !== currentDigest) {
120
+ throw new Error(
121
+ "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
122
+ );
123
+ }
124
+ }
125
+
92
126
  /**
93
127
  * 从工具结果 details 恢复已读记账(跨进程 resume / reload / fork)。数据来自
94
128
  * session 文件,可能缺失或损坏:逐条 TypeBox 校验,非法条目丢弃。只接受
@@ -307,6 +307,16 @@ interface ServerCapabilities {
307
307
  export const clientDefaults = {
308
308
  diagnosticsDebounceMs: 150,
309
309
  diagnosticsDocumentWaitTimeoutMs: 5_000,
310
+ /**
311
+ * document 模式等待上限的补充:该文档上一份诊断为空(服务器手里是干净文档)时,
312
+ * 内容变化后只用这段安静期等新 push,不再等满 diagnosticsDocumentWaitTimeoutMs。
313
+ *
314
+ * typescript-language-server 的 FileDiagnostics.update 在「该类诊断上一轮为空、
315
+ * 这一轮仍为空」时直接 return 不推送,且它不实现 pull 诊断;这类文档等满整个
316
+ * 窗口也只会在窗口末尾返回同样的「无诊断」,白等一次编辑。诊断集合变成非空时
317
+ * 服务器必定推送,所以短安静期不会漏掉这次变更引入的错误。
318
+ */
319
+ diagnosticsSilentWaitTimeoutMs: 1_500,
310
320
  diagnosticsFullWaitTimeoutMs: 10_000,
311
321
  diagnosticsRequestTimeoutMs: 3_000,
312
322
  initializeTimeoutMs: 45_000,
@@ -321,6 +331,8 @@ export interface CreateInput {
321
331
  /** 可覆盖的超时参数(缺省用 client 默认值,由全局/本地 lsp.json 配置注入)。 */
322
332
  diagnosticsDebounceMs?: number;
323
333
  diagnosticsDocumentWaitTimeoutMs?: number;
334
+ /** 上一份诊断为空的文档的等待上限;缺省见 clientDefaults。 */
335
+ diagnosticsSilentWaitTimeoutMs?: number;
324
336
  diagnosticsFullWaitTimeoutMs?: number;
325
337
  diagnosticsRequestTimeoutMs?: number;
326
338
  initializeTimeoutMs?: number;
@@ -522,6 +534,8 @@ export async function create(input: CreateInput): Promise<LspClient> {
522
534
  const diagnosticsDebounceMs = input.diagnosticsDebounceMs ?? clientDefaults.diagnosticsDebounceMs;
523
535
  const diagnosticsDocumentWaitTimeoutMs =
524
536
  input.diagnosticsDocumentWaitTimeoutMs ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs;
537
+ const diagnosticsSilentWaitTimeoutMs =
538
+ input.diagnosticsSilentWaitTimeoutMs ?? clientDefaults.diagnosticsSilentWaitTimeoutMs;
525
539
  const diagnosticsFullWaitTimeoutMs =
526
540
  input.diagnosticsFullWaitTimeoutMs ?? clientDefaults.diagnosticsFullWaitTimeoutMs;
527
541
  const diagnosticsRequestTimeoutMs =
@@ -599,6 +613,8 @@ export async function create(input: CreateInput): Promise<LspClient> {
599
613
  at: Date.now(),
600
614
  version: typeof params.version === "number" ? params.version : undefined,
601
615
  });
616
+ const document = files[filePath];
617
+ if (document !== undefined) document.lastPushEmpty = params.diagnostics.length === 0;
602
618
  updatePushDiagnostics(filePath, params.diagnostics);
603
619
  },
604
620
  );
@@ -702,7 +718,15 @@ export async function create(input: CreateInput): Promise<LspClient> {
702
718
  await connection.sendNotification("workspace/didChangeConfiguration", { settings });
703
719
  }
704
720
 
705
- const files: Record<string, { version: number; text: string } | undefined> = {};
721
+ /**
722
+ * syncedAt:最后一次把该文档内容同步给服务器的时刻(didOpen / didChange)。
723
+ * lastPushEmpty:服务器对该文档最近一次 push 是否为空(无诊断),随驻留记录
724
+ * 一起在 didClose 时清除——只在当前驻留周期内成立。
725
+ */
726
+ const files: Record<
727
+ string,
728
+ { version: number; text: string; syncedAt: number; lastPushEmpty?: boolean } | undefined
729
+ > = {};
706
730
 
707
731
  // ── 驻留 LRU ────────────────────────────────────────────────────────────────
708
732
 
@@ -1009,7 +1033,20 @@ export async function create(input: CreateInput): Promise<LspClient> {
1009
1033
  after?: number;
1010
1034
  signal?: AbortSignal;
1011
1035
  }): Promise<void> {
1036
+ // 服务器对当前内容的最新结论已在手(最后一次 push 晚于最后一次内容同步,
1037
+ // 即此后没有再通知过内容变化):直接返回已有结果。部分服务器在诊断集合
1038
+ // 不变时不再推送(typescript-language-server 空→空不发布),等新 push 只会
1039
+ // 耗满整个窗口后得到同样的「无诊断」。
1040
+ const known = published.get(request.path);
1041
+ const document = files[request.path];
1042
+ if (known !== undefined && document !== undefined && known.at >= document.syncedAt) return;
1043
+
1012
1044
  const startedAt = request.after ?? Date.now();
1045
+ // 「上一份 push 为空」的文档用短安静期(见 clientDefaults.diagnosticsSilentWaitTimeoutMs)。
1046
+ const budget =
1047
+ files[request.path]?.lastPushEmpty === true
1048
+ ? Math.min(diagnosticsDocumentWaitTimeoutMs, diagnosticsSilentWaitTimeoutMs)
1049
+ : diagnosticsDocumentWaitTimeoutMs;
1013
1050
  // pull 与 push 语义相同:都是等「当前文档版本」的诊断结果,统一一个循环。
1014
1051
  // 先 pull(拿到即返回);pull 超时说明服务器未响应,不再重试 pull,只等
1015
1052
  // 版本匹配的 push 兜底;版本不匹配的 push 一律忽略(防迟到旧结果)。
@@ -1017,11 +1054,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
1017
1054
  path: request.path,
1018
1055
  version: request.version,
1019
1056
  after: startedAt,
1020
- timeout: diagnosticsDocumentWaitTimeoutMs,
1057
+ timeout: budget,
1021
1058
  });
1022
1059
 
1023
1060
  while (!connectionClosed && !request.signal?.aborted) {
1024
- const remaining = diagnosticsDocumentWaitTimeoutMs - (Date.now() - startedAt);
1061
+ const remaining = budget - (Date.now() - startedAt);
1025
1062
  if (remaining <= 0) return;
1026
1063
  const result = await requestDocumentDiagnostics(request.path);
1027
1064
  if (result.matched) return;
@@ -1088,13 +1125,21 @@ export async function create(input: CreateInput): Promise<LspClient> {
1088
1125
 
1089
1126
  const document = files[resolvedPath];
1090
1127
  if (document !== undefined) {
1128
+ // 内容与服务器已知文本一致:不重复通知(didChange 会把已有诊断判为过期,
1129
+ // 而部分服务器对内容未产生新结论的文档不再推送,见 diagnosticsSilentWaitTimeoutMs)。
1130
+ if (document.text === text) {
1131
+ touch(resolvedPath);
1132
+ return document.version;
1133
+ }
1134
+
1091
1135
  // didChange:内容已变,旧诊断立即失效。清空缓存避免等待窗口内服务器
1092
1136
  // 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。
1093
1137
  pushDiagnostics.delete(resolvedPath);
1094
1138
  pullDiagnostics.delete(resolvedPath);
1095
1139
 
1096
1140
  const next = document.version + 1;
1097
- files[resolvedPath] = { version: next, text };
1141
+ // 保留 lastPushEmpty:安静期判据看的是「变更前服务器最后一份结论是否为空」
1142
+ files[resolvedPath] = { ...document, version: next, text, syncedAt: Date.now() };
1098
1143
  documentVersions.set(resolvedPath, next);
1099
1144
  await connection.sendNotification("textDocument/didChange", {
1100
1145
  textDocument: { uri, version: next },
@@ -1118,7 +1163,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
1118
1163
  await connection.sendNotification("textDocument/didOpen", {
1119
1164
  textDocument: { uri, languageId, version: 0, text },
1120
1165
  });
1121
- files[resolvedPath] = { version: 0, text };
1166
+ files[resolvedPath] = { version: 0, text, syncedAt: Date.now() };
1122
1167
  documentVersions.set(resolvedPath, 0);
1123
1168
  touch(resolvedPath);
1124
1169
  await evictExcess();
@@ -86,6 +86,8 @@ const lspConfigSchema = Type.Object({
86
86
  diagnosticsDebounceMs: Type.Optional(timeoutValue),
87
87
  /** document 模式诊断等待上限(ms)。 */
88
88
  diagnosticsDocumentWaitTimeoutMs: Type.Optional(timeoutValue),
89
+ /** 上一份诊断为空的文档的等待上限(ms);见 clientDefaults.diagnosticsSilentWaitTimeoutMs。 */
90
+ diagnosticsSilentWaitTimeoutMs: Type.Optional(timeoutValue),
89
91
  /** full 模式诊断等待上限(ms)。 */
90
92
  diagnosticsFullWaitTimeoutMs: Type.Optional(timeoutValue),
91
93
  /** 单次 pull 诊断请求超时(ms)。 */
@@ -140,6 +142,7 @@ export interface ResolvedLspConfig {
140
142
  /** 以下超时均为换算后的毫秒数(缺省见 configDefaults / clientDefaults)。 */
141
143
  diagnosticsDebounceMs: number;
142
144
  diagnosticsDocumentWaitTimeoutMs: number;
145
+ diagnosticsSilentWaitTimeoutMs: number;
143
146
  diagnosticsFullWaitTimeoutMs: number;
144
147
  diagnosticsRequestTimeoutMs: number;
145
148
  initializeTimeoutMs: number;
@@ -198,6 +201,8 @@ export function resolveConfig(raw: LspConfig): ResolvedLspConfig {
198
201
  diagnosticsDebounceMs: toMs(raw.diagnosticsDebounceMs) ?? clientDefaults.diagnosticsDebounceMs,
199
202
  diagnosticsDocumentWaitTimeoutMs:
200
203
  toMs(raw.diagnosticsDocumentWaitTimeoutMs) ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs,
204
+ diagnosticsSilentWaitTimeoutMs:
205
+ toMs(raw.diagnosticsSilentWaitTimeoutMs) ?? clientDefaults.diagnosticsSilentWaitTimeoutMs,
201
206
  diagnosticsFullWaitTimeoutMs:
202
207
  toMs(raw.diagnosticsFullWaitTimeoutMs) ?? clientDefaults.diagnosticsFullWaitTimeoutMs,
203
208
  diagnosticsRequestTimeoutMs:
@@ -791,6 +796,7 @@ export function createLspService(
791
796
  diagnosticsDebounceMs: config.diagnosticsDebounceMs,
792
797
  diagnosticsDocumentWaitTimeoutMs:
793
798
  adapter.diagnosticsWaitMs ?? config.diagnosticsDocumentWaitTimeoutMs,
799
+ diagnosticsSilentWaitTimeoutMs: config.diagnosticsSilentWaitTimeoutMs,
794
800
  diagnosticsFullWaitTimeoutMs: config.diagnosticsFullWaitTimeoutMs,
795
801
  diagnosticsRequestTimeoutMs: config.diagnosticsRequestTimeoutMs,
796
802
  initializeTimeoutMs: adapter.startupTimeoutMs ?? config.initializeTimeoutMs,
@@ -11,8 +11,9 @@
11
11
  * 不接 PDF、不接 <system-reminder>;图片 magic 检测保留。
12
12
  * - edit:匹配引擎 + 把 old/new 转到文件换行后再替换;写后等待文档诊断。
13
13
  * edit 要求文件已被 read 读过且内容未变(read 记账见下)。
14
- * - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。write 本身
15
- * 不要求先 read,但会刷新记账,保证紧接着的 edit 不必重新 read。
14
+ * - write:BOM 保留(source.bom || next.bom);写后同 edit 的诊断输出。write 不
15
+ * 要求先 read(未读过的文件可直接写),但若已有读取记录而磁盘内容已变,则要求
16
+ * 重新 read;写完刷新记账,保证紧接着的 edit 不必重新 read。
16
17
  *
17
18
  * read 记账:read / edit / write / lsp-rename 各自把受影响文件的内容指纹写进
18
19
  * details.reads(随 session 持久化),session_start / session_tree 时从当前分支
@@ -37,11 +38,13 @@ import { Type } from "typebox";
37
38
 
38
39
  import {
39
40
  createReadsState,
41
+ digestIfExists,
40
42
  fileDigest,
41
43
  type FileSnapshot,
42
44
  type ReadsState,
43
45
  readStateKey,
44
46
  requireCurrentRead,
47
+ requireUnchangedRead,
45
48
  restoreReads,
46
49
  snapshotOf,
47
50
  } from "../lib/file-reads.js";
@@ -790,6 +793,13 @@ function registerWriteTool(
790
793
  async () => {
791
794
  signal?.throwIfAborted();
792
795
 
796
+ const key = await readStateKey(absolutePath);
797
+ // write 不要求先读过;但若已有读取记录,磁盘内容必须仍是读取时的样子,
798
+ // 否则先重新 read(外部改动过的文件不让盲写覆盖)
799
+ if (state.reads.has(key)) {
800
+ requireUnchangedRead(state, key, await digestIfExists(absolutePath));
801
+ }
802
+
793
803
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
794
804
  // 否则用新内容自带的 BOM
795
805
  let existing: Buffer | undefined;
@@ -810,8 +820,7 @@ function registerWriteTool(
810
820
  await mkdir(dir, { recursive: true });
811
821
  signal?.throwIfAborted();
812
822
  await writeFile(absolutePath, desiredBom + nextText, "utf8");
813
- // write 不要求先 read,但写后记账:紧接着的 edit 不该再要求重新 read
814
- const key = await readStateKey(absolutePath);
823
+ // 写后记账(key 在写入前已算过):紧接着的 edit 不该再要求重新 read
815
824
  const snapshot = snapshotOf(desiredBom + nextText);
816
825
  state.reads.set(key, snapshot);
817
826
  const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
@@ -27,12 +27,13 @@ description: Use when 编写、检查或排查 LSP 语言服务器配置 ——
27
27
 
28
28
  ```
29
29
  version / servers / enabled / disabled / watch / maxOpenDocuments /
30
- diagnosticsDebounceMs / diagnosticsDocumentWaitTimeoutMs /
30
+ diagnosticsDebounceMs / diagnosticsDocumentWaitTimeoutMs / diagnosticsSilentWaitTimeoutMs /
31
31
  diagnosticsFullWaitTimeoutMs / diagnosticsRequestTimeoutMs / initializeTimeoutMs
32
32
  ```
33
33
 
34
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
35
+ - 时长字段:毫秒数字,或带单位的字符串(`"300ms"` / `"5s"` / `"1m"` / `"2h"`,空单位按 ms);默认值见 `clientDefaults`(client.ts):debounce 150ms、document 等待 5s、安静期 1.5s、full 等待 10s、pull 请求 3s、initialize 45s、`maxOpenDocuments` 32
36
+ - `diagnosticsSilentWaitTimeoutMs`:只用于「该文档上一份诊断为空」的情形——内容变化后最多只等这段安静期,不再等满 `diagnosticsDocumentWaitTimeoutMs`。typescript-language-server 在诊断集合空→空时不重复推送且不实现 pull 诊断,这类文档等满整个窗口也只会得到同样的「无诊断」,白等一次编辑;集合变成非空时服务器必定推送,所以调小它不会漏掉变更引入的错误,调大则更保守(默认 1.5s,约等于实测热态推送延迟的三倍)。设为不小于 `diagnosticsDocumentWaitTimeoutMs` 即等价于不做这个缩短
36
37
  - `watch`:`enabled` / `debounceMs`(缺省 300)/ `maxBatch`(缺省 500)/ `ignore`(glob,相对**每个被监听的项目根**的 POSIX 路径)。注意 `flushMs` 只在默认值里、**不可配**
37
38
  - 监听范围不是整个 cwd,而是**当前活跃服务器 client 的项目根**:root 在 cwd 内就只监听 root(被其他 root 包含的 root 不重复监听,root 是 cwd 的祖先时退化为 cwd);没有活跃 client 就不监听。因此容器 cwd(`~/projects` 下多个仓库)里只有活跃服务器所在的项目会被 watch;`ignore` 的匹配基准也随之是各自的项目根。资源耗尽(ENOSPC)时该 root 的监听器会停止并提示一次,调大 `fs.inotify.max_user_watches` 后跑 `/lsp-reload` 重试
38
39