@trim21/personal-pi-extensions 0.1.642 → 0.1.644

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.642",
3
+ "version": "0.1.644",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -17,7 +17,8 @@ import {
17
17
  createReadsState,
18
18
  type FileSnapshot,
19
19
  type ReadsState,
20
- readStateKey,
20
+ recordRead,
21
+ recordReads,
21
22
  requireCurrentRead,
22
23
  restoreReads,
23
24
  snapshotOf,
@@ -247,13 +248,11 @@ export function registerFileTools(
247
248
  { type: "text", text: `Read image file [${imageMime}]` },
248
249
  { type: "image", data, mimeType: imageMime },
249
250
  ];
250
- const snapshot = snapshotOf(image, false);
251
- const key = await readStateKey(filePath);
252
- state.reads.set(key, snapshot);
251
+ const reads = await recordRead(state, filePath, snapshotOf(image, false));
253
252
  return {
254
253
  content,
255
254
  details: {
256
- reads: { [key]: snapshot },
255
+ reads,
257
256
  pendant: {
258
257
  subtitle: formatSubtitlePath(ctx.cwd, filePath),
259
258
  title: "Read",
@@ -264,7 +263,6 @@ export function registerFileTools(
264
263
 
265
264
  const offset = params.offset ?? 1;
266
265
  const limit = params.limit;
267
- const key = await readStateKey(filePath);
268
266
  const buffer = await readFile(filePath);
269
267
 
270
268
  if (isBinary(buffer.subarray(0, SAMPLE_BYTES))) {
@@ -285,8 +283,7 @@ export function registerFileTools(
285
283
  `File content (${estimatedTokens} tokens) exceeds maximum allowed tokens (${MAX_READ_TOKENS}). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.`,
286
284
  );
287
285
  }
288
- const snapshot = snapshotOf(buffer);
289
- state.reads.set(key, snapshot);
286
+ const reads = await recordRead(state, filePath, snapshotOf(buffer));
290
287
  // 与 Edit / Write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
291
288
  const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
292
289
  notify: (message, level) => ctx.ui.notify(message, level),
@@ -300,7 +297,7 @@ export function registerFileTools(
300
297
  },
301
298
  ],
302
299
  details: {
303
- reads: { [key]: snapshot },
300
+ reads,
304
301
  pendant: {
305
302
  subtitle: formatSubtitlePath(
306
303
  ctx.cwd,
@@ -384,9 +381,7 @@ export function registerFileTools(
384
381
  await mkdir(dirname(filePath), { recursive: true });
385
382
  }
386
383
  await writeFile(filePath, newString, "utf8");
387
- const snapshot = snapshotOf(newString);
388
- const key = await readStateKey(filePath);
389
- state.reads.set(key, snapshot);
384
+ const reads = await recordRead(state, filePath, snapshotOf(newString));
390
385
  const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
391
386
  signal?.throwIfAborted();
392
387
  const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
@@ -399,7 +394,7 @@ export function registerFileTools(
399
394
  diff: diff.diff,
400
395
  patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
401
396
  firstChangedLine: diff.firstChangedLine,
402
- reads: { [key]: snapshot },
397
+ reads,
403
398
  },
404
399
  diagnostics,
405
400
  ];
@@ -436,8 +431,7 @@ export function registerFileTools(
436
431
  "File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
437
432
  );
438
433
  }
439
- const key = await readStateKey(filePath);
440
- requireCurrentRead(state, key, filePath, content);
434
+ await requireCurrentRead(state, filePath, content);
441
435
  await access(filePath, constants.R_OK | constants.W_OK);
442
436
  signal?.throwIfAborted();
443
437
  const original = content.toString("utf8");
@@ -450,8 +444,7 @@ export function registerFileTools(
450
444
  signal,
451
445
  });
452
446
  await writeFile(filePath, restored, "utf8");
453
- const snapshot = snapshotOf(restored);
454
- state.reads.set(key, snapshot);
447
+ const reads = await recordRead(state, filePath, snapshotOf(restored));
455
448
  // patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
456
449
  const diff = generateDiffString(
457
450
  convertLeadingTabsToSpaces(original),
@@ -475,7 +468,7 @@ export function registerFileTools(
475
468
  convertLeadingTabsToSpaces(restored),
476
469
  ),
477
470
  firstChangedLine: diff.firstChangedLine,
478
- reads: { [key]: snapshot },
471
+ reads,
479
472
  },
480
473
  diagnostics,
481
474
  ];
@@ -527,13 +520,11 @@ export function registerFileTools(
527
520
  [string, FileToolDetails, DiagnosticReport]
528
521
  >(filePath, async () => {
529
522
  let original: string | undefined;
530
- let key: string | undefined;
531
523
  try {
532
524
  const value = await stat(filePath);
533
525
  if (value.isFile()) {
534
526
  const content = await readFile(filePath);
535
- key = await readStateKey(filePath);
536
- requireCurrentRead(state, key, filePath, content);
527
+ await requireCurrentRead(state, filePath, content);
537
528
  original = content.toString("utf8");
538
529
  }
539
530
  } catch (error) {
@@ -552,10 +543,7 @@ export function registerFileTools(
552
543
  });
553
544
  await mkdir(dirname(filePath), { recursive: true });
554
545
  await writeFile(filePath, params.content, "utf8");
555
- const snapshot = snapshotOf(params.content);
556
- // 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
557
- const resolvedKey = key ?? (await readStateKey(filePath));
558
- state.reads.set(resolvedKey, snapshot);
546
+ const reads = await recordRead(state, filePath, snapshotOf(params.content));
559
547
  const diff = generateDiffString(original ?? "", params.content);
560
548
  const text =
561
549
  original === undefined
@@ -572,7 +560,7 @@ export function registerFileTools(
572
560
  diff: diff.diff,
573
561
  patch: generateUnifiedPatch(filePath, original ?? "", params.content),
574
562
  firstChangedLine: diff.firstChangedLine,
575
- reads: { [resolvedKey]: snapshot },
563
+ reads,
576
564
  },
577
565
  diagnostics,
578
566
  ];
@@ -639,16 +627,14 @@ export default function claudeCodeFileTools(
639
627
  onEnabled: (pi, service) => {
640
628
  registerLspRenameTool(pi, service, {
641
629
  policy,
642
- recordReads: async (applied) => {
643
- const reads: Record<string, FileSnapshot> = {};
644
- for (const fileEdit of applied) {
645
- const key = await readStateKey(fileEdit.path);
646
- const snapshot = snapshotOf(fileEdit.newText);
647
- state.reads.set(key, snapshot);
648
- reads[key] = snapshot;
649
- }
650
- return reads;
651
- },
630
+ recordReads: (applied) =>
631
+ recordReads(
632
+ state,
633
+ applied.map((fileEdit) => ({
634
+ path: fileEdit.path,
635
+ snapshot: snapshotOf(fileEdit.newText),
636
+ })),
637
+ ),
652
638
  });
653
639
  registerLspInspectTools(pi, service);
654
640
  },
@@ -6,11 +6,15 @@
6
6
  * - 工具名集合:claude-code 是大写 Read/Edit/Write,opencode 是小写;
7
7
  * - 指纹来源:claude-code 手里已有内容,直接 snapshotOf(content);opencode 的
8
8
  * read 是流式分页读取,整文件指纹用 fileDigest(path)。
9
+ *
10
+ * 记账 key 的解析与读写由本模块独占(readStateKey / recordRead / require*),
11
+ * 调用点只给路径与快照,不自己拼 details 里的 map。
9
12
  */
10
13
 
11
14
  import { createHash } from "node:crypto";
12
15
  import { createReadStream } from "node:fs";
13
16
  import { realpath } from "node:fs/promises";
17
+ import { basename, dirname, join } from "node:path";
14
18
 
15
19
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
20
  import { type Static, Type } from "typebox";
@@ -72,32 +76,73 @@ export async function digestIfExists(filePath: string): Promise<string | undefin
72
76
  }
73
77
 
74
78
  /**
75
- * 记账 key:解析 symlink 后的真实路径。文件尚不存在(Write 新建 / Edit 空
76
- * old_string 创建)时 realpath 抛 ENOENT,回退到调用方给出的路径。
79
+ * 记账 key:解析 symlink 后的真实路径。文件尚未落盘(Write 新建 / Edit 空
80
+ * old_string 创建)时向上找到最深的已存在祖先、解析它再把剩余路径段拼回去——
81
+ * 结果与文件落盘后 realpath(filePath) 相同,因此调用点不必关心算 key 的时机
82
+ * (写入路径尤其容易在创建分支上算早)。
77
83
  */
78
84
  export async function readStateKey(filePath: string): Promise<string> {
79
- try {
80
- return await realpath(filePath);
81
- } catch (error) {
82
- if (isMissingPath(error)) {
85
+ const pending: string[] = [];
86
+ let current = filePath;
87
+ for (;;) {
88
+ try {
89
+ return join(await realpath(current), ...pending.toReversed());
90
+ } catch (error) {
91
+ if (!isMissingPath(error)) {
92
+ throw error;
93
+ }
94
+ }
95
+ const parent = dirname(current);
96
+ if (parent === current) {
97
+ // 一路到文件系统根仍不存在:无法解析,回退调用方给出的路径
83
98
  return filePath;
84
99
  }
85
- throw error;
100
+ pending.push(basename(current));
101
+ current = parent;
86
102
  }
87
103
  }
88
104
 
105
+ /**
106
+ * 记账「这些文件的内容即各自的 snapshot」:解析 key 写入 state,返回可直接放进
107
+ * 工具 details 的 reads 片段。
108
+ *
109
+ * 这一侧是对象而不是 Map:details 是 JSON(宿主按 `JsonValue` 写进 session
110
+ * JSONL),Map 序列化过去会变成 `{}`,记账就丢了;读取方向与之相对(
111
+ * `deserializeReads` 从同一个对象形状还原成 Map)。
112
+ */
113
+ export async function recordReads(
114
+ state: ReadsState,
115
+ entries: readonly { path: string; snapshot: FileSnapshot }[],
116
+ ): Promise<Record<string, FileSnapshot>> {
117
+ const reads: Record<string, FileSnapshot> = {};
118
+ for (const entry of entries) {
119
+ const key = await readStateKey(entry.path);
120
+ state.reads.set(key, entry.snapshot);
121
+ reads[key] = entry.snapshot;
122
+ }
123
+ return reads;
124
+ }
125
+
126
+ /** 单文件记账:`recordReads` 的单项形式。 */
127
+ export async function recordRead(
128
+ state: ReadsState,
129
+ filePath: string,
130
+ snapshot: FileSnapshot,
131
+ ): Promise<Record<string, FileSnapshot>> {
132
+ return await recordReads(state, [{ path: filePath, snapshot }]);
133
+ }
134
+
89
135
  function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
90
136
  return left.digest === right.digest;
91
137
  }
92
138
 
93
139
  /** 校验「已读且未变」:未读过、非文本、指纹不符都拒绝写入。 */
94
- export function requireCurrentRead(
140
+ export async function requireCurrentRead(
95
141
  state: ReadsState,
96
- key: string,
97
142
  filePath: string,
98
143
  currentContent: Uint8Array | string,
99
- ): void {
100
- const readSnapshot = state.reads.get(key);
144
+ ): Promise<void> {
145
+ const readSnapshot = state.reads.get(await readStateKey(filePath));
101
146
  if (!readSnapshot) {
102
147
  throw new Error("File has not been read yet. Read it first before writing to it.");
103
148
  }
@@ -113,21 +158,17 @@ export function requireCurrentRead(
113
158
 
114
159
  /**
115
160
  * 过期校验:该文件已有读取记录时,要求磁盘上的当前指纹与记录一致,否则拒绝
116
- * 写入;从未读过(没有记录)时直接放行。
161
+ * 写入;从未读过(没有记录)时直接放行,且不做整文件指纹计算。
117
162
  *
118
163
  * opencode 的 write 用这个:没读过的文件允许直接写,读过之后再被外部改动就必须
119
- * 重新 read。`currentDigest` 为 undefined(文件已不存在)同样算过期。
164
+ * 重新 read。文件已不存在(指纹 undefined)同样算过期。
120
165
  */
121
- export function requireUnchangedRead(
122
- state: ReadsState,
123
- key: string,
124
- currentDigest: string | undefined,
125
- ): void {
126
- const readSnapshot = state.reads.get(key);
166
+ export async function requireUnchangedRead(state: ReadsState, filePath: string): Promise<void> {
167
+ const readSnapshot = state.reads.get(await readStateKey(filePath));
127
168
  if (!readSnapshot) {
128
169
  return;
129
170
  }
130
- if (currentDigest === undefined || readSnapshot.digest !== currentDigest) {
171
+ if (readSnapshot.digest !== (await digestIfExists(filePath))) {
131
172
  throw new Error(
132
173
  "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.",
133
174
  );
@@ -282,21 +282,33 @@ export const clientDefaults = {
282
282
  maxOpenDocuments: 32,
283
283
  } as const;
284
284
 
285
- export interface CreateInput {
285
+ /**
286
+ * 客户端可调项的唯一定义:`create()` 的覆盖参数、`lsp.ts` 的 `ResolvedLspConfig`
287
+ * 与 `resolveConfig` 共用同一份字段集合,新增旋钮只需在这里声明一次即可被
288
+ * 配置解析与客户端构造同时要求。超时均为解析后的毫秒数,缺省值见 clientDefaults。
289
+ */
290
+ export interface LspClientOptions {
291
+ /** push 诊断去抖(ms)。 */
292
+ diagnosticsDebounceMs: number;
293
+ /** document 模式诊断等待上限(ms)。 */
294
+ diagnosticsDocumentWaitTimeoutMs: number;
295
+ /** 上一份诊断为空的文档的等待上限(ms);缺省见 clientDefaults。 */
296
+ diagnosticsSilentWaitTimeoutMs: number;
297
+ /** full 模式诊断等待上限(ms)。 */
298
+ diagnosticsFullWaitTimeoutMs: number;
299
+ /** 单次 pull 诊断请求超时(ms)。 */
300
+ diagnosticsRequestTimeoutMs: number;
301
+ /** 服务器 initialize 握手超时(ms)。 */
302
+ initializeTimeoutMs: number;
303
+ /** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
304
+ maxOpenDocuments: number;
305
+ }
306
+
307
+ export interface CreateInput extends Partial<LspClientOptions> {
286
308
  serverID: string;
287
309
  server: LspServerHandle;
288
310
  root: string;
289
311
  directory: string;
290
- /** 可覆盖的超时参数(缺省用 client 默认值,由全局/本地 lsp.json 配置注入)。 */
291
- diagnosticsDebounceMs?: number;
292
- diagnosticsDocumentWaitTimeoutMs?: number;
293
- /** 上一份诊断为空的文档的等待上限;缺省见 clientDefaults。 */
294
- diagnosticsSilentWaitTimeoutMs?: number;
295
- diagnosticsFullWaitTimeoutMs?: number;
296
- diagnosticsRequestTimeoutMs?: number;
297
- initializeTimeoutMs?: number;
298
- /** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
299
- maxOpenDocuments?: number;
300
312
  /** 覆盖 rename 覆盖校验的轮询节奏(缺省见 DEFAULT_RENAME_VERIFICATION_TIMING)。 */
301
313
  renameVerificationTiming?: Partial<RenameVerificationTiming>;
302
314
  }
@@ -37,6 +37,7 @@ import {
37
37
  type Diagnostic,
38
38
  type Info as LspClient,
39
39
  type InspectLocation,
40
+ type LspClientOptions,
40
41
  LspMethodNotSupportedError,
41
42
  RenameNotPossibleError,
42
43
  WATCH_KIND_CHANGE,
@@ -68,7 +69,7 @@ const watchConfigSchema = Type.Object({
68
69
  ignore: Type.Optional(Type.Array(Type.String())),
69
70
  });
70
71
 
71
- /** lsp.json 的配置项(全局与本地同构;只描述用户可写的原始形态,缺省见 configDefaults)。 */
72
+ /** lsp.json 的配置项(全局与本地同构;只描述用户可写的原始形态,缺省见 watchDefaults / clientDefaults)。 */
72
73
  const lspConfigSchema = Type.Object({
73
74
  /** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝。 */
74
75
  version: Type.Optional(Type.Number()),
@@ -78,7 +79,7 @@ const lspConfigSchema = Type.Object({
78
79
  enabled: Type.Optional(Type.Array(Type.String())),
79
80
  /** 从启用集中排除的服务器 id(缺省 = 无)。 */
80
81
  disabled: Type.Optional(Type.Array(Type.String())),
81
- /** 工作区文件监听配置(缺省全部字段见 configDefaults.watch)。 */
82
+ /** 工作区文件监听配置(缺省全部字段见 watchDefaults)。 */
82
83
  watch: Type.Optional(watchConfigSchema),
83
84
  /** 驻留文档上限(LRU 容量),超过时淘汰最久未使用并 didClose。 */
84
85
  maxOpenDocuments: Type.Optional(Type.Number({ minimum: 1 })),
@@ -99,18 +100,12 @@ const lspConfigSchema = Type.Object({
99
100
  /** 配置值(单文件解析结果);超时字段为原始写法(number 或字符串),换算在 resolveConfig。 */
100
101
  export type LspConfig = Static<typeof lspConfigSchema>;
101
102
 
102
- /**
103
- * 解析期缺省(单一来源)。超时/LRU 与 client.create 共用 clientDefaults;
104
- * watch 无 client 对应项,数值在此集中。
105
- */
106
- const configDefaults = {
107
- watch: {
108
- enabled: true,
109
- debounceMs: 300,
110
- flushMs: 1_000,
111
- maxBatch: 500,
112
- },
113
- maxOpenDocuments: clientDefaults.maxOpenDocuments,
103
+ /** 解析期缺省:watch 段(无 client 对应项,数值在此集中);客户端参数缺省见 clientDefaults。 */
104
+ const watchDefaults = {
105
+ enabled: true,
106
+ debounceMs: 300,
107
+ flushMs: 1_000,
108
+ maxBatch: 500,
114
109
  } as const;
115
110
 
116
111
  /** 服务器启动失败后自动重试的冷却(ms);冷却内跳过,之后下次触碰自动重试。 */
@@ -127,8 +122,12 @@ interface EffectiveWatchConfig {
127
122
  ignore: string[];
128
123
  }
129
124
 
130
- /** 全局 + 本地合并并解析后的生效配置:所有字段为确定值,无"未配置"歧义。 */
131
- export interface ResolvedLspConfig {
125
+ /**
126
+ * 全局 + 本地合并并解析后的生效配置:所有字段为确定值,无"未配置"歧义。
127
+ * 客户端参数的整体形状复用 `LspClientOptions`(定义在 client.ts,与
128
+ * `create()` 的覆盖参数同源),因此这里不再逐字段重声明。
129
+ */
130
+ export interface ResolvedLspConfig extends LspClientOptions {
132
131
  /** 合并后的服务器定义(未配置任何服务器时为空表)。 */
133
132
  servers: Record<string, ServerConfig>;
134
133
  /** enabled 白名单(undefined = 全部启用)。 */
@@ -137,15 +136,6 @@ export interface ResolvedLspConfig {
137
136
  disabled: Set<string> | undefined;
138
137
  /** 工作区监听配置(缺省值已应用)。 */
139
138
  watch: EffectiveWatchConfig;
140
- /** 驻留文档 LRU 容量。 */
141
- maxOpenDocuments: number;
142
- /** 以下超时均为换算后的毫秒数(缺省见 configDefaults / clientDefaults)。 */
143
- diagnosticsDebounceMs: number;
144
- diagnosticsDocumentWaitTimeoutMs: number;
145
- diagnosticsSilentWaitTimeoutMs: number;
146
- diagnosticsFullWaitTimeoutMs: number;
147
- diagnosticsRequestTimeoutMs: number;
148
- initializeTimeoutMs: number;
149
139
  }
150
140
 
151
141
  /** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由调用方兜底缺省)。 */
@@ -177,7 +167,7 @@ function toMs(value: number | string | undefined): number | undefined {
177
167
  }
178
168
 
179
169
  /**
180
- * 把合并后的原始配置解析为生效配置:应用 configDefaults 缺省、字符串时长换算、
170
+ * 把合并后的原始配置解析为生效配置:应用 watchDefaults / clientDefaults 缺省、字符串时长换算、
181
171
  * 白名单转 Set;per-server 的 workingDir / rootMarkers 互斥校验在此完成
182
172
  * (配置解析期抛错,与 enabled 白名单校验同层)。
183
173
  */
@@ -195,13 +185,13 @@ export function resolveConfig(raw: LspConfig): ResolvedLspConfig {
195
185
  enabled: raw.enabled === undefined ? undefined : new Set(raw.enabled),
196
186
  disabled: raw.disabled === undefined ? undefined : new Set(raw.disabled),
197
187
  watch: {
198
- enabled: raw.watch?.enabled ?? configDefaults.watch.enabled,
199
- debounceMs: toMs(raw.watch?.debounceMs) ?? configDefaults.watch.debounceMs,
200
- flushMs: configDefaults.watch.flushMs,
201
- maxBatch: raw.watch?.maxBatch ?? configDefaults.watch.maxBatch,
188
+ enabled: raw.watch?.enabled ?? watchDefaults.enabled,
189
+ debounceMs: toMs(raw.watch?.debounceMs) ?? watchDefaults.debounceMs,
190
+ flushMs: watchDefaults.flushMs,
191
+ maxBatch: raw.watch?.maxBatch ?? watchDefaults.maxBatch,
202
192
  ignore: raw.watch?.ignore ?? [],
203
193
  },
204
- maxOpenDocuments: raw.maxOpenDocuments ?? configDefaults.maxOpenDocuments,
194
+ maxOpenDocuments: raw.maxOpenDocuments ?? clientDefaults.maxOpenDocuments,
205
195
  diagnosticsDebounceMs: toMs(raw.diagnosticsDebounceMs) ?? clientDefaults.diagnosticsDebounceMs,
206
196
  diagnosticsDocumentWaitTimeoutMs:
207
197
  toMs(raw.diagnosticsDocumentWaitTimeoutMs) ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs,
@@ -848,19 +838,22 @@ export function createLspService(
848
838
  reportStartupFailure(key, adapter.id, root, "binary not found", notify);
849
839
  return;
850
840
  }
841
+ const overrides: Partial<LspClientOptions> = {};
842
+ if (adapter.diagnosticsWaitMs !== undefined) {
843
+ overrides.diagnosticsDocumentWaitTimeoutMs = adapter.diagnosticsWaitMs;
844
+ }
845
+ if (adapter.startupTimeoutMs !== undefined) {
846
+ overrides.initializeTimeoutMs = adapter.startupTimeoutMs;
847
+ }
851
848
  const client = await create({
852
849
  serverID: adapter.id,
853
850
  server: handle,
854
851
  root,
855
852
  directory: cwd,
856
- diagnosticsDebounceMs: config.diagnosticsDebounceMs,
857
- diagnosticsDocumentWaitTimeoutMs:
858
- adapter.diagnosticsWaitMs ?? config.diagnosticsDocumentWaitTimeoutMs,
859
- diagnosticsSilentWaitTimeoutMs: config.diagnosticsSilentWaitTimeoutMs,
860
- diagnosticsFullWaitTimeoutMs: config.diagnosticsFullWaitTimeoutMs,
861
- diagnosticsRequestTimeoutMs: config.diagnosticsRequestTimeoutMs,
862
- initializeTimeoutMs: adapter.startupTimeoutMs ?? config.initializeTimeoutMs,
863
- maxOpenDocuments: config.maxOpenDocuments,
853
+ // 客户端参数整体取自生效配置(ResolvedLspConfig extends LspClientOptions),
854
+ // 不做逐字段搬运:新增旋钮时漏接线会静默失效,这里靠展开消除该位置。
855
+ ...config,
856
+ ...overrides,
864
857
  });
865
858
  if (state.closing || state.disabled) {
866
859
  await client.shutdown();
@@ -38,11 +38,11 @@ import { Type } from "typebox";
38
38
 
39
39
  import {
40
40
  createReadsState,
41
- digestIfExists,
42
41
  fileDigest,
43
42
  type FileSnapshot,
44
43
  type ReadsState,
45
- readStateKey,
44
+ recordRead,
45
+ recordReads,
46
46
  requireCurrentRead,
47
47
  requireUnchangedRead,
48
48
  restoreReads,
@@ -500,13 +500,11 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService, state:
500
500
  { type: "text", text: "Image read successfully" },
501
501
  { type: "image", data: base64, mimeType },
502
502
  ];
503
- const key = await readStateKey(absolutePath);
504
- const snapshot = snapshotOf(buffer, false);
505
- state.reads.set(key, snapshot);
503
+ const reads = await recordRead(state, absolutePath, snapshotOf(buffer, false));
506
504
  return {
507
505
  content,
508
506
  details: {
509
- reads: { [key]: snapshot },
507
+ reads,
510
508
  pendant: { subtitle: formatSubtitlePath(ctx.cwd, absolutePath) },
511
509
  },
512
510
  };
@@ -521,7 +519,6 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService, state:
521
519
 
522
520
  // 记账指纹先于分页读取:文件若在读取期间被改动,指纹对应的是改动前的
523
521
  // 内容,后续 edit 会要求重新 read(失败方向安全)。
524
- const key = await readStateKey(absolutePath);
525
522
  const snapshot: FileSnapshot = { digest: await fileDigest(absolutePath), textEditable: true };
526
523
 
527
524
  const effectiveOffset = offset || 1;
@@ -555,7 +552,7 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService, state:
555
552
  outputText = `${header}${numbered}\n\n(End of file - total ${page.count} lines)${footer}`;
556
553
  }
557
554
 
558
- state.reads.set(key, snapshot);
555
+ const reads = await recordRead(state, absolutePath, snapshot);
559
556
 
560
557
  // opencode: 与 edit / write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
561
558
  const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
@@ -572,7 +569,7 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService, state:
572
569
  content,
573
570
  details: {
574
571
  ...details,
575
- reads: { [key]: snapshot },
572
+ reads,
576
573
  pendant: {
577
574
  subtitle: formatSubtitlePath(
578
575
  ctx.cwd,
@@ -667,16 +664,14 @@ function registerEditTool(
667
664
  await mkdir(dirname(absolutePath), { recursive: true });
668
665
  signal?.throwIfAborted();
669
666
  await writeFile(absolutePath, newString, "utf8");
670
- // 新建文件无需先 read(无处可读),写后记账;realpath 要等文件落盘
671
- const key = await readStateKey(absolutePath);
672
- const snapshot = snapshotOf(newString);
673
- state.reads.set(key, snapshot);
667
+ // 新建文件无需先 read(无处可读),写后记账
668
+ const reads = await recordRead(state, absolutePath, snapshotOf(newString));
674
669
  const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
675
670
  signal,
676
671
  });
677
672
  return [
678
673
  "Edit applied successfully.",
679
- { diff: "", patch: "", firstChangedLine: 0, reads: { [key]: snapshot } },
674
+ { diff: "", patch: "", firstChangedLine: 0, reads },
680
675
  diagnostics,
681
676
  ] as const;
682
677
  }
@@ -702,8 +697,7 @@ function registerEditTool(
702
697
 
703
698
  // 指纹校验必须发生在写入之前:未 read 或外部已改动都拒绝编辑
704
699
  const rawContent = await readFile(absolutePath, "utf8");
705
- const key = await readStateKey(absolutePath);
706
- requireCurrentRead(state, key, absolutePath, rawContent);
700
+ await requireCurrentRead(state, absolutePath, rawContent);
707
701
 
708
702
  const applied = applyEdit(rawContent, oldString, newString, replaceAll);
709
703
  signal?.throwIfAborted();
@@ -715,8 +709,7 @@ function registerEditTool(
715
709
  signal,
716
710
  });
717
711
  await writeFile(absolutePath, applied.finalContent, "utf8");
718
- const snapshot = snapshotOf(applied.finalContent);
719
- state.reads.set(key, snapshot);
712
+ const reads = await recordRead(state, absolutePath, snapshotOf(applied.finalContent));
720
713
 
721
714
  const diffOld = normalizeToLF(applied.contentOld);
722
715
  const diffNew = normalizeToLF(applied.contentNew);
@@ -731,7 +724,7 @@ function registerEditTool(
731
724
  diff: diffResult.diff,
732
725
  patch,
733
726
  firstChangedLine: diffResult.firstChangedLine,
734
- reads: { [key]: snapshot },
727
+ reads,
735
728
  },
736
729
  diagnostics,
737
730
  ] as const;
@@ -811,12 +804,9 @@ function registerWriteTool(
811
804
  async () => {
812
805
  signal?.throwIfAborted();
813
806
 
814
- const key = await readStateKey(absolutePath);
815
807
  // write 不要求先读过;但若已有读取记录,磁盘内容必须仍是读取时的样子,
816
808
  // 否则先重新 read(外部改动过的文件不让盲写覆盖)
817
- if (state.reads.has(key)) {
818
- requireUnchangedRead(state, key, await digestIfExists(absolutePath));
819
- }
809
+ await requireUnchangedRead(state, absolutePath);
820
810
 
821
811
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
822
812
  // 否则用新内容自带的 BOM。旧内容整读一次,既是 BOM 的来源,也是审批预览
@@ -842,14 +832,13 @@ function registerWriteTool(
842
832
  await mkdir(dir, { recursive: true });
843
833
  signal?.throwIfAborted();
844
834
  await writeFile(absolutePath, finalContent, "utf8");
845
- // 写后记账(key 在写入前已算过):紧接着的 edit 不该再要求重新 read
846
- const snapshot = snapshotOf(finalContent);
847
- state.reads.set(key, snapshot);
835
+ // 写后记账:紧接着的 edit 不该再要求重新 read
836
+ const reads = await recordRead(state, absolutePath, snapshotOf(finalContent));
848
837
  const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
849
838
  signal,
850
839
  });
851
840
 
852
- return ["Wrote file successfully.", { reads: { [key]: snapshot } }, diagnostics] as const;
841
+ return ["Wrote file successfully.", { reads }, diagnostics] as const;
853
842
  },
854
843
  );
855
844
 
@@ -906,16 +895,14 @@ export default function opencodeFileTools(
906
895
  onEnabled: (pi, service) => {
907
896
  registerLspRenameTool(pi, service, {
908
897
  policy,
909
- recordReads: async (applied) => {
910
- const reads: Record<string, FileSnapshot> = {};
911
- for (const fileEdit of applied) {
912
- const key = await readStateKey(fileEdit.path);
913
- const snapshot = snapshotOf(fileEdit.newText);
914
- state.reads.set(key, snapshot);
915
- reads[key] = snapshot;
916
- }
917
- return reads;
918
- },
898
+ recordReads: (applied) =>
899
+ recordReads(
900
+ state,
901
+ applied.map((fileEdit) => ({
902
+ path: fileEdit.path,
903
+ snapshot: snapshotOf(fileEdit.newText),
904
+ })),
905
+ ),
919
906
  });
920
907
  registerLspInspectTools(pi, service);
921
908
  },