@oh-my-pi/pi-coding-agent 3.6.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.
@@ -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.
@@ -8,11 +8,10 @@ import { uriToFile } from "./utils";
8
8
  // =============================================================================
9
9
 
10
10
  /**
11
- * Apply text edits to a file.
11
+ * Apply text edits to a string in-memory.
12
12
  * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices.
13
13
  */
14
- export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promise<void> {
15
- const content = await Bun.file(filePath).text();
14
+ export function applyTextEditsToString(content: string, edits: TextEdit[]): string {
16
15
  const lines = content.split("\n");
17
16
 
18
17
  // Sort edits in reverse order (bottom-to-top, right-to-left)
@@ -39,7 +38,17 @@ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promi
39
38
  }
40
39
  }
41
40
 
42
- await Bun.write(filePath, lines.join("\n"));
41
+ return lines.join("\n");
42
+ }
43
+
44
+ /**
45
+ * Apply text edits to a file.
46
+ * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices.
47
+ */
48
+ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promise<void> {
49
+ const content = await Bun.file(filePath).text();
50
+ const result = applyTextEditsToString(content, edits);
51
+ await Bun.write(filePath, result);
43
52
  }
44
53
 
45
54
  // =============================================================================