@oh-my-pi/pi-coding-agent 3.5.1337 → 3.8.1337

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.
@@ -4,6 +4,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { globSync } from "glob";
6
6
  import { ensureTool } from "../../utils/tools-manager";
7
+ import { untilAborted } from "../utils";
7
8
  import { resolveToCwd } from "./path-utils";
8
9
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate";
9
10
 
@@ -67,191 +68,171 @@ export function createFindTool(cwd: string): AgentTool<typeof findSchema> {
67
68
  },
68
69
  signal?: AbortSignal,
69
70
  ) => {
70
- return new Promise((resolve, reject) => {
71
- if (signal?.aborted) {
72
- reject(new Error("Operation aborted"));
73
- return;
71
+ return untilAborted(signal, async () => {
72
+ // Ensure fd is available
73
+ const fdPath = await ensureTool("fd", true);
74
+ if (!fdPath) {
75
+ throw new Error("fd is not available and could not be downloaded");
74
76
  }
75
77
 
76
- const onAbort = () => reject(new Error("Operation aborted"));
77
- signal?.addEventListener("abort", onAbort, { once: true });
78
+ const searchPath = resolveToCwd(searchDir || ".", cwd);
79
+ const effectiveLimit = limit ?? DEFAULT_LIMIT;
80
+ const effectiveType = type ?? "all";
81
+ const includeHidden = hidden ?? false;
82
+ const shouldSortByMtime = sortByMtime ?? false;
83
+
84
+ // Build fd arguments
85
+ const args: string[] = [
86
+ "--glob", // Use glob pattern
87
+ "--color=never", // No ANSI colors
88
+ "--max-results",
89
+ String(effectiveLimit),
90
+ ];
91
+
92
+ if (includeHidden) {
93
+ args.push("--hidden");
94
+ }
78
95
 
79
- (async () => {
80
- try {
81
- // Ensure fd is available
82
- const fdPath = await ensureTool("fd", true);
83
- if (!fdPath) {
84
- reject(new Error("fd is not available and could not be downloaded"));
85
- return;
86
- }
96
+ // Add type filter
97
+ if (effectiveType === "file") {
98
+ args.push("--type", "f");
99
+ } else if (effectiveType === "dir") {
100
+ args.push("--type", "d");
101
+ }
87
102
 
88
- const searchPath = resolveToCwd(searchDir || ".", cwd);
89
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
90
- const effectiveType = type ?? "all";
91
- const includeHidden = hidden ?? false;
92
- const shouldSortByMtime = sortByMtime ?? false;
93
-
94
- // Build fd arguments
95
- const args: string[] = [
96
- "--glob", // Use glob pattern
97
- "--color=never", // No ANSI colors
98
- "--max-results",
99
- String(effectiveLimit),
100
- ];
101
-
102
- if (includeHidden) {
103
- args.push("--hidden");
104
- }
103
+ // Include .gitignore files (root + nested) so fd respects them even outside git repos
104
+ const gitignoreFiles = new Set<string>();
105
+ const rootGitignore = path.join(searchPath, ".gitignore");
106
+ if (existsSync(rootGitignore)) {
107
+ gitignoreFiles.add(rootGitignore);
108
+ }
105
109
 
106
- // Add type filter
107
- if (effectiveType === "file") {
108
- args.push("--type", "f");
109
- } else if (effectiveType === "dir") {
110
- args.push("--type", "d");
111
- }
110
+ try {
111
+ const nestedGitignores = globSync("**/.gitignore", {
112
+ cwd: searchPath,
113
+ dot: true,
114
+ absolute: true,
115
+ ignore: ["**/node_modules/**", "**/.git/**"],
116
+ });
117
+ for (const file of nestedGitignores) {
118
+ gitignoreFiles.add(file);
119
+ }
120
+ } catch {
121
+ // Ignore glob errors
122
+ }
112
123
 
113
- // Include .gitignore files (root + nested) so fd respects them even outside git repos
114
- const gitignoreFiles = new Set<string>();
115
- const rootGitignore = path.join(searchPath, ".gitignore");
116
- if (existsSync(rootGitignore)) {
117
- gitignoreFiles.add(rootGitignore);
118
- }
124
+ for (const gitignorePath of gitignoreFiles) {
125
+ args.push("--ignore-file", gitignorePath);
126
+ }
119
127
 
120
- try {
121
- const nestedGitignores = globSync("**/.gitignore", {
122
- cwd: searchPath,
123
- dot: true,
124
- absolute: true,
125
- ignore: ["**/node_modules/**", "**/.git/**"],
126
- });
127
- for (const file of nestedGitignores) {
128
- gitignoreFiles.add(file);
129
- }
130
- } catch {
131
- // Ignore glob errors
132
- }
128
+ // Pattern and path
129
+ args.push(pattern, searchPath);
133
130
 
134
- for (const gitignorePath of gitignoreFiles) {
135
- args.push("--ignore-file", gitignorePath);
136
- }
131
+ // Run fd
132
+ const result = Bun.spawnSync([fdPath, ...args], {
133
+ stdin: "ignore",
134
+ stdout: "pipe",
135
+ stderr: "pipe",
136
+ });
137
137
 
138
- // Pattern and path
139
- args.push(pattern, searchPath);
138
+ const output = result.stdout.toString().trim();
140
139
 
141
- // Run fd
142
- const result = Bun.spawnSync([fdPath, ...args], {
143
- stdin: "ignore",
144
- stdout: "pipe",
145
- stderr: "pipe",
146
- });
140
+ if (result.exitCode !== 0) {
141
+ const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`;
142
+ // fd returns non-zero for some errors but may still have partial output
143
+ if (!output) {
144
+ throw new Error(errorMsg);
145
+ }
146
+ }
147
147
 
148
- signal?.removeEventListener("abort", onAbort);
148
+ if (!output) {
149
+ return {
150
+ content: [{ type: "text", text: "No files found matching pattern" }],
151
+ details: { fileCount: 0, files: [], truncated: false },
152
+ };
153
+ }
149
154
 
150
- const output = result.stdout.toString().trim();
155
+ const lines = output.split("\n");
156
+ const relativized: string[] = [];
157
+ const mtimes: number[] = [];
151
158
 
152
- if (result.exitCode !== 0) {
153
- const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`;
154
- // fd returns non-zero for some errors but may still have partial output
155
- if (!output) {
156
- reject(new Error(errorMsg));
157
- return;
158
- }
159
- }
159
+ for (const rawLine of lines) {
160
+ const line = rawLine.replace(/\r$/, "").trim();
161
+ if (!line) {
162
+ continue;
163
+ }
160
164
 
161
- if (!output) {
162
- resolve({
163
- content: [{ type: "text", text: "No files found matching pattern" }],
164
- details: { fileCount: 0, files: [], truncated: false },
165
- });
166
- return;
167
- }
165
+ const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
166
+ let relativePath = line;
167
+ if (line.startsWith(searchPath)) {
168
+ relativePath = line.slice(searchPath.length + 1); // +1 for the /
169
+ } else {
170
+ relativePath = path.relative(searchPath, line);
171
+ }
168
172
 
169
- const lines = output.split("\n");
170
- const relativized: string[] = [];
171
- const mtimes: number[] = [];
172
-
173
- for (const rawLine of lines) {
174
- const line = rawLine.replace(/\r$/, "").trim();
175
- if (!line) {
176
- continue;
177
- }
178
-
179
- const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
180
- let relativePath = line;
181
- if (line.startsWith(searchPath)) {
182
- relativePath = line.slice(searchPath.length + 1); // +1 for the /
183
- } else {
184
- relativePath = path.relative(searchPath, line);
185
- }
186
-
187
- if (hadTrailingSlash && !relativePath.endsWith("/")) {
188
- relativePath += "/";
189
- }
190
-
191
- relativized.push(relativePath);
192
-
193
- // Collect mtime if sorting is requested
194
- if (shouldSortByMtime) {
195
- try {
196
- const fullPath = path.join(searchPath, relativePath);
197
- const stat: Stats = statSync(fullPath);
198
- mtimes.push(stat.mtimeMs);
199
- } catch {
200
- mtimes.push(0);
201
- }
202
- }
203
- }
173
+ if (hadTrailingSlash && !relativePath.endsWith("/")) {
174
+ relativePath += "/";
175
+ }
176
+
177
+ relativized.push(relativePath);
204
178
 
205
- // Sort by mtime if requested (most recent first)
206
- if (shouldSortByMtime && relativized.length > 0) {
207
- const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] || 0 }));
208
- indexed.sort((a, b) => b.mtime - a.mtime);
209
- relativized.length = 0;
210
- relativized.push(...indexed.map((item) => item.path));
179
+ // Collect mtime if sorting is requested
180
+ if (shouldSortByMtime) {
181
+ try {
182
+ const fullPath = path.join(searchPath, relativePath);
183
+ const stat: Stats = statSync(fullPath);
184
+ mtimes.push(stat.mtimeMs);
185
+ } catch {
186
+ mtimes.push(0);
211
187
  }
188
+ }
189
+ }
212
190
 
213
- // Check if we hit the result limit
214
- const resultLimitReached = relativized.length >= effectiveLimit;
191
+ // Sort by mtime if requested (most recent first)
192
+ if (shouldSortByMtime && relativized.length > 0) {
193
+ const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] || 0 }));
194
+ indexed.sort((a, b) => b.mtime - a.mtime);
195
+ relativized.length = 0;
196
+ relativized.push(...indexed.map((item) => item.path));
197
+ }
215
198
 
216
- // Apply byte truncation (no line limit since we already have result limit)
217
- const rawOutput = relativized.join("\n");
218
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
199
+ // Check if we hit the result limit
200
+ const resultLimitReached = relativized.length >= effectiveLimit;
219
201
 
220
- let resultOutput = truncation.content;
221
- const details: FindToolDetails = {
222
- fileCount: relativized.length,
223
- files: relativized.slice(0, 50),
224
- truncated: resultLimitReached || truncation.truncated,
225
- };
202
+ // Apply byte truncation (no line limit since we already have result limit)
203
+ const rawOutput = relativized.join("\n");
204
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
226
205
 
227
- // Build notices
228
- const notices: string[] = [];
206
+ let resultOutput = truncation.content;
207
+ const details: FindToolDetails = {
208
+ fileCount: relativized.length,
209
+ files: relativized.slice(0, 50),
210
+ truncated: resultLimitReached || truncation.truncated,
211
+ };
229
212
 
230
- if (resultLimitReached) {
231
- notices.push(
232
- `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
233
- );
234
- details.resultLimitReached = effectiveLimit;
235
- }
213
+ // Build notices
214
+ const notices: string[] = [];
236
215
 
237
- if (truncation.truncated) {
238
- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
239
- details.truncation = truncation;
240
- }
216
+ if (resultLimitReached) {
217
+ notices.push(
218
+ `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
219
+ );
220
+ details.resultLimitReached = effectiveLimit;
221
+ }
241
222
 
242
- if (notices.length > 0) {
243
- resultOutput += `\n\n[${notices.join(". ")}]`;
244
- }
223
+ if (truncation.truncated) {
224
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
225
+ details.truncation = truncation;
226
+ }
245
227
 
246
- resolve({
247
- content: [{ type: "text", text: resultOutput }],
248
- details: Object.keys(details).length > 0 ? details : undefined,
249
- });
250
- } catch (e: any) {
251
- signal?.removeEventListener("abort", onAbort);
252
- reject(e);
253
- }
254
- })();
228
+ if (notices.length > 0) {
229
+ resultOutput += `\n\n[${notices.join(". ")}]`;
230
+ }
231
+
232
+ return {
233
+ content: [{ type: "text", text: resultOutput }],
234
+ details: Object.keys(details).length > 0 ? details : undefined,
235
+ };
255
236
  });
256
237
  },
257
238
  };
@@ -11,8 +11,6 @@ export {
11
11
  createLspTool,
12
12
  type FileDiagnosticsResult,
13
13
  type FileFormatResult,
14
- formatFile,
15
- getDiagnosticsForFile,
16
14
  getLspStatus,
17
15
  type LspServerStatus,
18
16
  type LspToolDetails,
@@ -61,7 +59,7 @@ import { createEditTool, editTool } from "./edit";
61
59
  import { createFindTool, findTool } from "./find";
62
60
  import { createGrepTool, grepTool } from "./grep";
63
61
  import { createLsTool, lsTool } from "./ls";
64
- import { createLspTool, formatFile, getDiagnosticsForFile, lspTool } from "./lsp/index";
62
+ import { createLspTool, createLspWritethrough, lspTool } from "./lsp/index";
65
63
  import { createNotebookTool, notebookTool } from "./notebook";
66
64
  import { createOutputTool, outputTool } from "./output";
67
65
  import { createReadTool, readTool } from "./read";
@@ -105,10 +103,12 @@ const toolDefs: Record<string, { tool: Tool; create: ToolFactory }> = {
105
103
  tool: editTool,
106
104
  create: (cwd, _ctx, options) => {
107
105
  const enableDiagnostics = options?.lspDiagnosticsOnEdit ?? false;
108
- return createEditTool(cwd, {
109
- fuzzyMatch: options?.editFuzzyMatch ?? true,
110
- getDiagnostics: enableDiagnostics ? (absolutePath) => getDiagnosticsForFile(absolutePath, cwd) : undefined,
106
+ const enableFormat = options?.lspFormatOnWrite ?? true;
107
+ const writethrough = createLspWritethrough(cwd, {
108
+ enableFormat,
109
+ enableDiagnostics,
111
110
  });
111
+ return createEditTool(cwd, { fuzzyMatch: options?.editFuzzyMatch ?? true, writethrough });
112
112
  },
113
113
  },
114
114
  write: {
@@ -116,10 +116,11 @@ const toolDefs: Record<string, { tool: Tool; create: ToolFactory }> = {
116
116
  create: (cwd, _ctx, options) => {
117
117
  const enableFormat = options?.lspFormatOnWrite ?? true;
118
118
  const enableDiagnostics = options?.lspDiagnosticsOnWrite ?? true;
119
- return createWriteTool(cwd, {
120
- formatOnWrite: enableFormat ? (absolutePath) => formatFile(absolutePath, cwd) : undefined,
121
- getDiagnostics: enableDiagnostics ? (absolutePath) => getDiagnosticsForFile(absolutePath, cwd) : undefined,
119
+ const writethrough = createLspWritethrough(cwd, {
120
+ enableFormat,
121
+ enableDiagnostics,
122
122
  });
123
+ return createWriteTool(cwd, { writethrough });
123
124
  },
124
125
  },
125
126
  grep: { tool: grepTool, create: createGrepTool },
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import nodePath from "node:path";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
4
  import { Type } from "@sinclair/typebox";
5
+ import { untilAborted } from "../utils";
5
6
  import { resolveToCwd } from "./path-utils";
6
7
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate";
7
8
 
@@ -28,109 +29,90 @@ export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
28
29
  { path, limit }: { path?: string; limit?: number },
29
30
  signal?: AbortSignal,
30
31
  ) => {
31
- return new Promise((resolve, reject) => {
32
- if (signal?.aborted) {
33
- reject(new Error("Operation aborted"));
34
- return;
32
+ return untilAborted(signal, async () => {
33
+ const dirPath = resolveToCwd(path || ".", cwd);
34
+ const effectiveLimit = limit ?? DEFAULT_LIMIT;
35
+
36
+ // Check if path exists
37
+ if (!existsSync(dirPath)) {
38
+ throw new Error(`Path not found: ${dirPath}`);
35
39
  }
36
40
 
37
- const onAbort = () => reject(new Error("Operation aborted"));
38
- signal?.addEventListener("abort", onAbort, { once: true });
41
+ // Check if path is a directory
42
+ const stat = statSync(dirPath);
43
+ if (!stat.isDirectory()) {
44
+ throw new Error(`Not a directory: ${dirPath}`);
45
+ }
39
46
 
47
+ // Read directory entries
48
+ let entries: string[];
40
49
  try {
41
- const dirPath = resolveToCwd(path || ".", cwd);
42
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
50
+ entries = readdirSync(dirPath);
51
+ } catch (e: any) {
52
+ throw new Error(`Cannot read directory: ${e.message}`);
53
+ }
43
54
 
44
- // Check if path exists
45
- if (!existsSync(dirPath)) {
46
- reject(new Error(`Path not found: ${dirPath}`));
47
- return;
48
- }
55
+ // Sort alphabetically (case-insensitive)
56
+ entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
49
57
 
50
- // Check if path is a directory
51
- const stat = statSync(dirPath);
52
- if (!stat.isDirectory()) {
53
- reject(new Error(`Not a directory: ${dirPath}`));
54
- return;
55
- }
58
+ // Format entries with directory indicators
59
+ const results: string[] = [];
60
+ let entryLimitReached = false;
56
61
 
57
- // Read directory entries
58
- let entries: string[];
59
- try {
60
- entries = readdirSync(dirPath);
61
- } catch (e: any) {
62
- reject(new Error(`Cannot read directory: ${e.message}`));
63
- return;
62
+ for (const entry of entries) {
63
+ if (results.length >= effectiveLimit) {
64
+ entryLimitReached = true;
65
+ break;
64
66
  }
65
67
 
66
- // Sort alphabetically (case-insensitive)
67
- entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
68
-
69
- // Format entries with directory indicators
70
- const results: string[] = [];
71
- let entryLimitReached = false;
72
-
73
- for (const entry of entries) {
74
- if (results.length >= effectiveLimit) {
75
- entryLimitReached = true;
76
- break;
77
- }
68
+ const fullPath = nodePath.join(dirPath, entry);
69
+ let suffix = "";
78
70
 
79
- const fullPath = nodePath.join(dirPath, entry);
80
- let suffix = "";
81
-
82
- try {
83
- const entryStat = statSync(fullPath);
84
- if (entryStat.isDirectory()) {
85
- suffix = "/";
86
- }
87
- } catch {
88
- // Skip entries we can't stat
89
- continue;
71
+ try {
72
+ const entryStat = statSync(fullPath);
73
+ if (entryStat.isDirectory()) {
74
+ suffix = "/";
90
75
  }
91
-
92
- results.push(entry + suffix);
76
+ } catch {
77
+ // Skip entries we can't stat
78
+ continue;
93
79
  }
94
80
 
95
- signal?.removeEventListener("abort", onAbort);
96
-
97
- if (results.length === 0) {
98
- resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined });
99
- return;
100
- }
81
+ results.push(entry + suffix);
82
+ }
101
83
 
102
- // Apply byte truncation (no line limit since we already have entry limit)
103
- const rawOutput = results.join("\n");
104
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
84
+ if (results.length === 0) {
85
+ return { content: [{ type: "text", text: "(empty directory)" }], details: undefined };
86
+ }
105
87
 
106
- let output = truncation.content;
107
- const details: LsToolDetails = {};
88
+ // Apply byte truncation (no line limit since we already have entry limit)
89
+ const rawOutput = results.join("\n");
90
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
108
91
 
109
- // Build notices
110
- const notices: string[] = [];
92
+ let output = truncation.content;
93
+ const details: LsToolDetails = {};
111
94
 
112
- if (entryLimitReached) {
113
- notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
114
- details.entryLimitReached = effectiveLimit;
115
- }
95
+ // Build notices
96
+ const notices: string[] = [];
116
97
 
117
- if (truncation.truncated) {
118
- notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
119
- details.truncation = truncation;
120
- }
98
+ if (entryLimitReached) {
99
+ notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
100
+ details.entryLimitReached = effectiveLimit;
101
+ }
121
102
 
122
- if (notices.length > 0) {
123
- output += `\n\n[${notices.join(". ")}]`;
124
- }
103
+ if (truncation.truncated) {
104
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
105
+ details.truncation = truncation;
106
+ }
125
107
 
126
- resolve({
127
- content: [{ type: "text", text: output }],
128
- details: Object.keys(details).length > 0 ? details : undefined,
129
- });
130
- } catch (e: any) {
131
- signal?.removeEventListener("abort", onAbort);
132
- reject(e);
108
+ if (notices.length > 0) {
109
+ output += `\n\n[${notices.join(". ")}]`;
133
110
  }
111
+
112
+ return {
113
+ content: [{ type: "text", text: output }],
114
+ details: Object.keys(details).length > 0 ? details : undefined,
115
+ };
134
116
  });
135
117
  },
136
118
  };
@@ -512,6 +512,69 @@ export async function ensureFileOpen(client: LspClient, filePath: string): Promi
512
512
  }
513
513
  }
514
514
 
515
+ /**
516
+ * Sync in-memory content to the LSP client without reading from disk.
517
+ * Use this to provide instant feedback during edits before the file is saved.
518
+ */
519
+ export async function syncContent(client: LspClient, filePath: string, content: string): Promise<void> {
520
+ const uri = fileToUri(filePath);
521
+ const lockKey = `${client.name}:${uri}`;
522
+
523
+ const existingLock = fileOperationLocks.get(lockKey);
524
+ if (existingLock) {
525
+ await existingLock;
526
+ }
527
+
528
+ const syncPromise = (async () => {
529
+ const info = client.openFiles.get(uri);
530
+
531
+ if (!info) {
532
+ // Open file with provided content instead of reading from disk
533
+ const languageId = detectLanguageId(filePath);
534
+ await sendNotification(client, "textDocument/didOpen", {
535
+ textDocument: {
536
+ uri,
537
+ languageId,
538
+ version: 1,
539
+ text: content,
540
+ },
541
+ });
542
+ client.openFiles.set(uri, { version: 1, languageId });
543
+ client.lastActivity = Date.now();
544
+ return;
545
+ }
546
+
547
+ const version = ++info.version;
548
+ await sendNotification(client, "textDocument/didChange", {
549
+ textDocument: { uri, version },
550
+ contentChanges: [{ text: content }],
551
+ });
552
+ client.lastActivity = Date.now();
553
+ })();
554
+
555
+ fileOperationLocks.set(lockKey, syncPromise);
556
+ try {
557
+ await syncPromise;
558
+ } finally {
559
+ fileOperationLocks.delete(lockKey);
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Notify LSP that a file was saved.
565
+ * Assumes content was already synced via syncContent - just sends didSave.
566
+ */
567
+ export async function notifySaved(client: LspClient, filePath: string): Promise<void> {
568
+ const uri = fileToUri(filePath);
569
+ const info = client.openFiles.get(uri);
570
+ if (!info) return; // File not open, nothing to notify
571
+
572
+ await sendNotification(client, "textDocument/didSave", {
573
+ textDocument: { uri },
574
+ });
575
+ client.lastActivity = Date.now();
576
+ }
577
+
515
578
  /**
516
579
  * Refresh a file in the LSP client.
517
580
  * Increments version, sends didChange and didSave notifications.