@ian-pascoe/pi-lsp 0.1.1 → 0.2.0
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 +3 -2
- package/package.json +5 -1
- package/skills/pi-lsp/SKILL.md +16 -0
- package/src/lsp-position-encoding.ts +2 -1
- package/src/lsp-post-edit-diagnostics-rendering.ts +2 -1
- package/src/lsp-post-edit-diagnostics.ts +6 -33
- package/src/lsp-server-client.ts +67 -85
- package/src/lsp-server-manager.ts +13 -14
- package/src/lsp-session-files.ts +1 -23
- package/src/lsp-tool-contract.ts +7 -11
- package/src/lsp-tool-output.ts +22 -10
- package/src/lsp-tool-rendering.ts +1 -4
- package/src/lsp-tool.ts +44 -66
- package/src/lsp-workspace-edit.ts +22 -79
- package/src/pi-lsp-extension.ts +15 -22
- package/src/pi-lsp-settings.ts +0 -2
package/README.md
CHANGED
|
@@ -102,8 +102,9 @@ UTF-32 encoding. `selection_ranges` accepts a `positions` array. `find_reference
|
|
|
102
102
|
`workspace_symbols` requires `query` and a root-anchor `file_path`. `workspace_diagnostics`,
|
|
103
103
|
`capabilities`, and `restart` require `server_id` and a root-anchor `file_path`. Other reads query
|
|
104
104
|
every matching capable server unless narrowed by `server_id`; successful responses remain visible
|
|
105
|
-
when another server fails.
|
|
106
|
-
server
|
|
105
|
+
when another server fails. Automatic reads omit matching incapable servers and fail once if none
|
|
106
|
+
are capable; explicitly selecting an incapable server reports that the operation is unsupported. A
|
|
107
|
+
mutation may omit `server_id` only when exactly one matching capable server exists.
|
|
107
108
|
|
|
108
109
|
Formatting requires `tab_size` and `insert_spaces`. It optionally accepts
|
|
109
110
|
`trim_trailing_whitespace`, `insert_final_newline`, and `trim_final_newlines`. Range formatting also
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-lsp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Configured language-server tools and post-edit diagnostics for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"src",
|
|
25
|
+
"skills",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
@@ -48,6 +49,9 @@
|
|
|
48
49
|
"pi": {
|
|
49
50
|
"extensions": [
|
|
50
51
|
"./src/index.ts"
|
|
52
|
+
],
|
|
53
|
+
"skills": [
|
|
54
|
+
"./skills"
|
|
51
55
|
]
|
|
52
56
|
},
|
|
53
57
|
"scripts": {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-lsp
|
|
3
|
+
description: Configure or diagnose Pi LSP when a Server Definition fails, a file does not route, diagnostics are missing, or lsp settings need changing.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Pi LSP
|
|
8
|
+
|
|
9
|
+
1. Read [`../../README.md`](../../README.md)'s Settings and `lsp` tool sections, then identify the effective settings scope.
|
|
10
|
+
2. Call `lsp` with `{"operation":"status"}`.
|
|
11
|
+
3. Test a representative file with `capabilities`, then `diagnostics`, supplying `server_id` when needed.
|
|
12
|
+
4. Classify the result as settings, routing, process, capability, or Post-edit Diagnostics behavior.
|
|
13
|
+
5. If settings changed, reload Pi; if settings are unchanged and the Server Instance is unavailable, use `restart`.
|
|
14
|
+
6. Repeat status, capabilities, and diagnostics. Finish when the representative file reaches the intended Server Instance and operation, or an exact unsupported capability is evidenced.
|
|
15
|
+
|
|
16
|
+
`configured` has not routed a file yet. `unavailable` is sticky and retains stderr. Keep diagnosis read-only; stop before applying a Workspace Edit Preview.
|
|
@@ -24,7 +24,8 @@ export function normalizeLspPositionEncoding(encoding: string | undefined): LspP
|
|
|
24
24
|
return "utf-16";
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** Split document text into lines on CRLF, LF, or CR separators. */
|
|
28
|
+
export function documentLines(documentText: string): readonly string[] {
|
|
28
29
|
return documentText.split(/\r\n|[\n\r]/u);
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -68,7 +68,8 @@ function severityColor(severity: DiagnosticSeverity): ThemeColor {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** Render one count with a pluralized noun, e.g. `3 files` or `1 file`. */
|
|
72
|
+
export function pluralizedCount(count: number, singular: string, plural = `${singular}s`): string {
|
|
72
73
|
return `${count} ${count === 1 ? singular : plural}`;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -98,28 +98,9 @@ export const PostEditDiagnosticOutcomeSchema = Type.Union([
|
|
|
98
98
|
export type PostEditDiagnosticOutcome = Static<typeof PostEditDiagnosticOutcomeSchema>;
|
|
99
99
|
|
|
100
100
|
/** Runs fresh Post-edit Diagnostics for changed paths after a Supported Mutation Tool result. */
|
|
101
|
-
export
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
paths: readonly PostEditDiagnosticPath[],
|
|
105
|
-
): Promise<readonly PostEditDiagnosticOutcome[]>;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Minimal Tool Result shape accepted by the structural post-edit adapters. */
|
|
109
|
-
export interface PostEditToolResult {
|
|
110
|
-
/** Tool name supplied by Pi's central tool-result event. */
|
|
111
|
-
readonly toolName: string;
|
|
112
|
-
/** Original tool arguments supplied to the central event. */
|
|
113
|
-
readonly input: ToolResultEvent["input"];
|
|
114
|
-
/** Tool-result details owned by the mutation implementation. */
|
|
115
|
-
readonly details: ToolResultEvent["details"];
|
|
116
|
-
/** Existing Pi content, retained verbatim before the appended LSP section. */
|
|
117
|
-
readonly content: ToolResultEvent["content"];
|
|
118
|
-
/** Existing tool failure state, which diagnostics must not change. */
|
|
119
|
-
readonly isError: boolean;
|
|
120
|
-
/** Existing usage accounting, which diagnostics must not change. */
|
|
121
|
-
readonly usage?: ToolResultEvent["usage"];
|
|
122
|
-
}
|
|
101
|
+
export type PostEditDiagnosticsRunner = (
|
|
102
|
+
paths: readonly PostEditDiagnosticPath[],
|
|
103
|
+
) => Promise<readonly PostEditDiagnosticOutcome[]>;
|
|
123
104
|
|
|
124
105
|
/** Tool-result fields returned by Post-edit Diagnostics middleware without changing mutation state. */
|
|
125
106
|
export interface PostEditDiagnosticsResultPatch {
|
|
@@ -174,7 +155,7 @@ function pathsAfterMutation(result: ReturnType<typeof mutationResult>): PostEdit
|
|
|
174
155
|
|
|
175
156
|
/** Extract exact changed destination paths from one Supported Mutation Tool result. */
|
|
176
157
|
export function extractPostEditDiagnosticPaths(
|
|
177
|
-
event: Pick<
|
|
158
|
+
event: Pick<ToolResultEvent, "toolName" | "input" | "details" | "isError">,
|
|
178
159
|
): ExtractedMutation | undefined {
|
|
179
160
|
if (event.toolName === "edit" || event.toolName === "write") {
|
|
180
161
|
if (event.isError || !Value.Check(NativeMutationInputSchema, event.input)) return undefined;
|
|
@@ -261,7 +242,7 @@ export function formatPostEditDiagnostics(outcomes: readonly PostEditDiagnosticO
|
|
|
261
242
|
|
|
262
243
|
/** Append fresh Post-edit Diagnostics while preserving every mutation-result field Pi already owns. */
|
|
263
244
|
export async function appendPostEditDiagnostics(
|
|
264
|
-
event:
|
|
245
|
+
event: ToolResultEvent,
|
|
265
246
|
diagnostics: PostEditDiagnosticsRunner,
|
|
266
247
|
): Promise<PostEditDiagnosticsResultPatch | undefined> {
|
|
267
248
|
const extracted = extractPostEditDiagnosticPaths(event);
|
|
@@ -271,7 +252,7 @@ export async function appendPostEditDiagnostics(
|
|
|
271
252
|
kind: "warning",
|
|
272
253
|
message,
|
|
273
254
|
})),
|
|
274
|
-
...(await diagnostics
|
|
255
|
+
...(await diagnostics(extracted.paths)),
|
|
275
256
|
];
|
|
276
257
|
if (outcomes.length === 0) return undefined;
|
|
277
258
|
const patch: PostEditDiagnosticsResultPatch = {
|
|
@@ -282,11 +263,3 @@ export async function appendPostEditDiagnostics(
|
|
|
282
263
|
};
|
|
283
264
|
return event.usage === undefined ? patch : { ...patch, usage: event.usage };
|
|
284
265
|
}
|
|
285
|
-
|
|
286
|
-
/** Adapt Pi's central ToolResultEvent shape to Post-edit Diagnostics middleware. */
|
|
287
|
-
export async function appendPiPostEditDiagnostics(
|
|
288
|
-
event: ToolResultEvent,
|
|
289
|
-
diagnostics: PostEditDiagnosticsRunner,
|
|
290
|
-
): Promise<PostEditDiagnosticsResultPatch | undefined> {
|
|
291
|
-
return appendPostEditDiagnostics(event, diagnostics);
|
|
292
|
-
}
|
package/src/lsp-server-client.ts
CHANGED
|
@@ -48,10 +48,12 @@ import {
|
|
|
48
48
|
type WorkspaceEdit,
|
|
49
49
|
} from "vscode-languageserver-protocol/node";
|
|
50
50
|
import {
|
|
51
|
+
documentLines,
|
|
51
52
|
measureLspPositionCharacters,
|
|
52
53
|
normalizeLspPositionEncoding,
|
|
53
54
|
type LspPositionEncoding,
|
|
54
55
|
} from "./lsp-position-encoding.js";
|
|
56
|
+
import type { LspTimeouts } from "./pi-lsp-settings.js";
|
|
55
57
|
|
|
56
58
|
const MAX_OPEN_DOCUMENTS = 100;
|
|
57
59
|
const MAX_STDERR_BYTES = 1024 * 1024;
|
|
@@ -79,16 +81,7 @@ const PrepareRenameProviderSchema = Type.Object(
|
|
|
79
81
|
);
|
|
80
82
|
|
|
81
83
|
/** Time budgets, in milliseconds, for one language-server process. */
|
|
82
|
-
export
|
|
83
|
-
/** Initialize request budget. */
|
|
84
|
-
readonly initializeMs: number;
|
|
85
|
-
/** Ordinary request budget. */
|
|
86
|
-
readonly requestMs: number;
|
|
87
|
-
/** Fresh diagnostics budget. */
|
|
88
|
-
readonly diagnosticsMs: number;
|
|
89
|
-
/** Graceful shutdown budget before process termination. */
|
|
90
|
-
readonly shutdownMs: number;
|
|
91
|
-
}
|
|
84
|
+
export type LspServerClientTimeouts = LspTimeouts;
|
|
92
85
|
|
|
93
86
|
/** Launch and protocol configuration for one language-server process. */
|
|
94
87
|
export interface LspServerClientOptions {
|
|
@@ -118,8 +111,6 @@ export interface LspServerClientOptions {
|
|
|
118
111
|
|
|
119
112
|
/** A valid UTF-8 document synchronized with one server instance. */
|
|
120
113
|
export interface LspSynchronizedDocument {
|
|
121
|
-
/** Absolute file path. */
|
|
122
|
-
readonly filePath: string;
|
|
123
114
|
/** File URI sent to the server. */
|
|
124
115
|
readonly uri: string;
|
|
125
116
|
/** Monotonic document version local to this server instance. */
|
|
@@ -148,8 +139,6 @@ export type LspWorkspaceDiagnosticResult =
|
|
|
148
139
|
|
|
149
140
|
/** Classified process, protocol, timeout, cancellation, and UTF-8 client failure. */
|
|
150
141
|
export class LspServerClientError extends Error {
|
|
151
|
-
readonly _tag = "LspServerClientError" as const;
|
|
152
|
-
|
|
153
142
|
/** Construct a stable Pi LSP client failure that always names the stderr capture. */
|
|
154
143
|
constructor(
|
|
155
144
|
readonly kind:
|
|
@@ -218,7 +207,7 @@ function serverWantsSave(capabilities: ServerCapabilities): boolean {
|
|
|
218
207
|
}
|
|
219
208
|
|
|
220
209
|
function protocolLineEndPosition(text: string, encoding: LspPositionEncoding): Position {
|
|
221
|
-
const lines = text
|
|
210
|
+
const lines = documentLines(text);
|
|
222
211
|
const lineText = lines.at(-1) ?? "";
|
|
223
212
|
return {
|
|
224
213
|
line: lines.length - 1,
|
|
@@ -268,13 +257,12 @@ export class LspServerClient {
|
|
|
268
257
|
private positionEncodingValue: LspPositionEncoding = "utf-16";
|
|
269
258
|
private textDocumentSyncKind: TextDocumentSyncKind = TextDocumentSyncKind.None;
|
|
270
259
|
private readonly openDocuments = new Map<string, OpenDocumentState>();
|
|
260
|
+
private readonly documentSynchronizations = new Map<string, Promise<LspSynchronizedDocument>>();
|
|
271
261
|
private readonly pushDiagnostics = new Map<string, PushDiagnosticsState>();
|
|
272
262
|
private readonly pullDiagnostics = new Map<string, PullDiagnosticsState>();
|
|
273
263
|
private readonly dynamicRegistrations = new Map<string, Registration>();
|
|
274
264
|
private readonly diagnosticWaiters = new Map<string, Set<() => void>>();
|
|
275
|
-
private readonly protocolMessages: string[] = [];
|
|
276
265
|
private diagnosticsRevision = 0;
|
|
277
|
-
private diagnosticsRefreshRevision = 0;
|
|
278
266
|
private stderrTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
279
267
|
private stderrWrite = Promise.resolve();
|
|
280
268
|
private closing = false;
|
|
@@ -381,11 +369,6 @@ export class LspServerClient {
|
|
|
381
369
|
return this.options.stderrPath;
|
|
382
370
|
}
|
|
383
371
|
|
|
384
|
-
/** Most recent bounded protocol log/show messages, oldest first. */
|
|
385
|
-
get recentProtocolMessages(): readonly string[] {
|
|
386
|
-
return this.protocolMessages;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
372
|
/** Whether a static or dynamically registered LSP method is available. */
|
|
390
373
|
hasCapability(method: string): boolean {
|
|
391
374
|
if (
|
|
@@ -525,8 +508,33 @@ export class LspServerClient {
|
|
|
525
508
|
filePath: string,
|
|
526
509
|
languageId: string,
|
|
527
510
|
): Promise<LspSynchronizedDocument> {
|
|
528
|
-
this.throwIfUnavailable();
|
|
529
511
|
const absolutePath = resolve(filePath);
|
|
512
|
+
const uri = pathToFileURL(absolutePath).href;
|
|
513
|
+
const activeSynchronization = this.documentSynchronizations.get(uri);
|
|
514
|
+
const waitForActiveSynchronization =
|
|
515
|
+
activeSynchronization?.then(
|
|
516
|
+
() => undefined,
|
|
517
|
+
() => undefined,
|
|
518
|
+
) ?? Promise.resolve();
|
|
519
|
+
const synchronization = waitForActiveSynchronization.then(() =>
|
|
520
|
+
this.synchronizeDocumentOnce(absolutePath, uri, languageId),
|
|
521
|
+
);
|
|
522
|
+
this.documentSynchronizations.set(uri, synchronization);
|
|
523
|
+
try {
|
|
524
|
+
return await synchronization;
|
|
525
|
+
} finally {
|
|
526
|
+
if (this.documentSynchronizations.get(uri) === synchronization) {
|
|
527
|
+
this.documentSynchronizations.delete(uri);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private async synchronizeDocumentOnce(
|
|
533
|
+
absolutePath: string,
|
|
534
|
+
uri: string,
|
|
535
|
+
languageId: string,
|
|
536
|
+
): Promise<LspSynchronizedDocument> {
|
|
537
|
+
this.throwIfUnavailable();
|
|
530
538
|
let text: string;
|
|
531
539
|
try {
|
|
532
540
|
const bytes = await readFile(absolutePath);
|
|
@@ -544,10 +552,13 @@ export class LspServerClient {
|
|
|
544
552
|
throw cause;
|
|
545
553
|
}
|
|
546
554
|
|
|
547
|
-
const uri = pathToFileURL(absolutePath).href;
|
|
548
555
|
const existing = this.openDocuments.get(uri);
|
|
556
|
+
if (existing?.text === text && existing.languageId === languageId) {
|
|
557
|
+
this.openDocuments.delete(uri);
|
|
558
|
+
this.openDocuments.set(uri, existing);
|
|
559
|
+
return existing;
|
|
560
|
+
}
|
|
549
561
|
const next: OpenDocumentState = {
|
|
550
|
-
filePath: absolutePath,
|
|
551
562
|
uri,
|
|
552
563
|
version: (existing?.version ?? 0) + 1,
|
|
553
564
|
text,
|
|
@@ -622,7 +633,12 @@ export class LspServerClient {
|
|
|
622
633
|
}
|
|
623
634
|
|
|
624
635
|
try {
|
|
625
|
-
return await this.
|
|
636
|
+
return await this.raceBudget(
|
|
637
|
+
Promise.any(candidates),
|
|
638
|
+
this.options.timeouts.diagnosticsMs,
|
|
639
|
+
"diagnostics",
|
|
640
|
+
signal,
|
|
641
|
+
);
|
|
626
642
|
} catch (cause) {
|
|
627
643
|
if (cause instanceof LspServerClientError) {
|
|
628
644
|
if (cause.kind === "cancelled") throw cause;
|
|
@@ -805,22 +821,13 @@ export class LspServerClient {
|
|
|
805
821
|
}
|
|
806
822
|
});
|
|
807
823
|
this.connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined);
|
|
808
|
-
this.connection.onRequest(DiagnosticRefreshRequest.type, () =>
|
|
809
|
-
this.diagnosticsRefreshRevision++;
|
|
810
|
-
});
|
|
824
|
+
this.connection.onRequest(DiagnosticRefreshRequest.type, () => undefined);
|
|
811
825
|
this.connection.onRequest(ApplyWorkspaceEditRequest.type, async (parameters) =>
|
|
812
826
|
this.rejectServerWorkspaceEdit(parameters),
|
|
813
827
|
);
|
|
814
|
-
this.connection.onRequest(ShowMessageRequest.type, (
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
});
|
|
818
|
-
this.connection.onNotification(LogMessageNotification.type, (parameters) => {
|
|
819
|
-
this.rememberProtocolMessage(parameters.message);
|
|
820
|
-
});
|
|
821
|
-
this.connection.onNotification(ShowMessageNotification.type, (parameters) => {
|
|
822
|
-
this.rememberProtocolMessage(parameters.message);
|
|
823
|
-
});
|
|
828
|
+
this.connection.onRequest(ShowMessageRequest.type, () => null);
|
|
829
|
+
this.connection.onNotification(LogMessageNotification.type, () => undefined);
|
|
830
|
+
this.connection.onNotification(ShowMessageNotification.type, () => undefined);
|
|
824
831
|
}
|
|
825
832
|
|
|
826
833
|
private async initialize(): Promise<void> {
|
|
@@ -923,30 +930,19 @@ export class LspServerClient {
|
|
|
923
930
|
if (signal?.aborted === true) throw abortError(this.options);
|
|
924
931
|
|
|
925
932
|
const cancellation = new CancellationTokenSource();
|
|
926
|
-
let timeout: NodeJS.Timeout | undefined;
|
|
927
|
-
let abortListener: (() => void) | undefined;
|
|
928
|
-
const request = this.connection.sendRequest<TResult>(method, parameters, cancellation.token);
|
|
929
|
-
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
930
|
-
timeout = setTimeout(() => {
|
|
931
|
-
cancellation.cancel();
|
|
932
|
-
reject(timeoutError(this.options, operation));
|
|
933
|
-
}, budgetMs);
|
|
934
|
-
timeout.unref();
|
|
935
|
-
});
|
|
936
|
-
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
937
|
-
if (signal === undefined) return;
|
|
938
|
-
abortListener = () => {
|
|
939
|
-
cancellation.cancel();
|
|
940
|
-
reject(abortError(this.options));
|
|
941
|
-
};
|
|
942
|
-
signal.addEventListener("abort", abortListener, { once: true });
|
|
943
|
-
});
|
|
944
|
-
const terminalPromise = this.terminalErrorPromise.then((error) => {
|
|
945
|
-
throw error;
|
|
946
|
-
});
|
|
947
|
-
|
|
948
933
|
try {
|
|
949
|
-
return await Promise.race([
|
|
934
|
+
return await Promise.race([
|
|
935
|
+
this.raceBudget(
|
|
936
|
+
this.connection.sendRequest<TResult>(method, parameters, cancellation.token),
|
|
937
|
+
budgetMs,
|
|
938
|
+
operation,
|
|
939
|
+
signal,
|
|
940
|
+
() => cancellation.cancel(),
|
|
941
|
+
),
|
|
942
|
+
this.terminalErrorPromise.then((error) => {
|
|
943
|
+
throw error;
|
|
944
|
+
}),
|
|
945
|
+
]);
|
|
950
946
|
} catch (cause) {
|
|
951
947
|
if (cause instanceof LspServerClientError) throw cause;
|
|
952
948
|
throw new LspServerClientError(
|
|
@@ -957,10 +953,6 @@ export class LspServerClient {
|
|
|
957
953
|
{ cause },
|
|
958
954
|
);
|
|
959
955
|
} finally {
|
|
960
|
-
if (timeout !== undefined) clearTimeout(timeout);
|
|
961
|
-
if (signal !== undefined && abortListener !== undefined) {
|
|
962
|
-
signal.removeEventListener("abort", abortListener);
|
|
963
|
-
}
|
|
964
956
|
cancellation.dispose();
|
|
965
957
|
}
|
|
966
958
|
}
|
|
@@ -1047,34 +1039,29 @@ export class LspServerClient {
|
|
|
1047
1039
|
return { status: "fresh", source: "document_pull", diagnostics: report.items };
|
|
1048
1040
|
}
|
|
1049
1041
|
|
|
1050
|
-
private async firstDiagnosticResult(
|
|
1051
|
-
candidates: readonly Promise<LspDocumentDiagnosticResult>[],
|
|
1052
|
-
signal?: AbortSignal,
|
|
1053
|
-
): Promise<LspDocumentDiagnosticResult> {
|
|
1054
|
-
return this.raceBudget(
|
|
1055
|
-
Promise.any(candidates),
|
|
1056
|
-
this.options.timeouts.diagnosticsMs,
|
|
1057
|
-
"diagnostics",
|
|
1058
|
-
signal,
|
|
1059
|
-
);
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
1042
|
private async raceBudget<TResult>(
|
|
1063
1043
|
value: Promise<TResult>,
|
|
1064
1044
|
budgetMs: number,
|
|
1065
1045
|
operation: string,
|
|
1066
1046
|
signal?: AbortSignal,
|
|
1047
|
+
onCancel?: () => void,
|
|
1067
1048
|
): Promise<TResult> {
|
|
1068
1049
|
if (signal?.aborted === true) throw abortError(this.options);
|
|
1069
1050
|
let timeout: NodeJS.Timeout | undefined;
|
|
1070
1051
|
let abortListener: (() => void) | undefined;
|
|
1071
1052
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
1072
|
-
timeout = setTimeout(() =>
|
|
1053
|
+
timeout = setTimeout(() => {
|
|
1054
|
+
onCancel?.();
|
|
1055
|
+
reject(timeoutError(this.options, operation));
|
|
1056
|
+
}, budgetMs);
|
|
1073
1057
|
timeout.unref();
|
|
1074
1058
|
});
|
|
1075
1059
|
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
1076
1060
|
if (signal === undefined) return;
|
|
1077
|
-
abortListener = () =>
|
|
1061
|
+
abortListener = () => {
|
|
1062
|
+
onCancel?.();
|
|
1063
|
+
reject(abortError(this.options));
|
|
1064
|
+
};
|
|
1078
1065
|
signal.addEventListener("abort", abortListener, { once: true });
|
|
1079
1066
|
});
|
|
1080
1067
|
try {
|
|
@@ -1167,11 +1154,6 @@ export class LspServerClient {
|
|
|
1167
1154
|
}
|
|
1168
1155
|
}
|
|
1169
1156
|
|
|
1170
|
-
private rememberProtocolMessage(message: string): void {
|
|
1171
|
-
this.protocolMessages.push(message);
|
|
1172
|
-
if (this.protocolMessages.length > 100) this.protocolMessages.shift();
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
1157
|
private async evictOldDocuments(): Promise<void> {
|
|
1176
1158
|
while (this.openDocuments.size > MAX_OPEN_DOCUMENTS) {
|
|
1177
1159
|
const oldestUri = this.openDocuments.keys().next().value;
|
|
@@ -82,8 +82,6 @@ export interface LspServerFailure {
|
|
|
82
82
|
readonly code: LspServerFailureCode;
|
|
83
83
|
/** Searchable caller-facing error prefixed with `Pi LSP:`. */
|
|
84
84
|
readonly message: string;
|
|
85
|
-
/** Selected workspace root when routing reached a concrete Server Instance. */
|
|
86
|
-
readonly rootPath?: string;
|
|
87
85
|
/** Configured server ID, or the requested missing ID. */
|
|
88
86
|
readonly serverId: string;
|
|
89
87
|
}
|
|
@@ -100,7 +98,7 @@ export interface LspServerSuccess<T> {
|
|
|
100
98
|
|
|
101
99
|
/** Keeps successful multi-server reads useful when independent servers fail. */
|
|
102
100
|
export interface LspServerReadResult<T> {
|
|
103
|
-
/** Labeled failures in deterministic route order. */
|
|
101
|
+
/** Labeled operational failures in deterministic route order. */
|
|
104
102
|
readonly failures: readonly LspServerFailure[];
|
|
105
103
|
/** Labeled successful values in deterministic route order. */
|
|
106
104
|
readonly successes: readonly LspServerSuccess<T>[];
|
|
@@ -135,8 +133,6 @@ export interface LspServerStatusEntry {
|
|
|
135
133
|
|
|
136
134
|
/** Reports configuration failures and session-scoped Server Instance states. */
|
|
137
135
|
export interface LspServerManagerStatus {
|
|
138
|
-
/** False when either authored `lsp` settings layer was malformed. */
|
|
139
|
-
readonly enabled: boolean;
|
|
140
136
|
/** Server entries ordered by ID and then root. */
|
|
141
137
|
readonly servers: readonly LspServerStatusEntry[];
|
|
142
138
|
/** Strict settings failures kept visible until Pi `/reload`. */
|
|
@@ -248,7 +244,6 @@ function unavailableFailure(route: LspServerRoute, error: string): LspServerFail
|
|
|
248
244
|
return {
|
|
249
245
|
code: "server-unavailable",
|
|
250
246
|
message: `Pi LSP: server ${route.serverId} is unavailable for ${route.rootPath}: ${error}`,
|
|
251
|
-
rootPath: route.rootPath,
|
|
252
247
|
serverId: route.serverId,
|
|
253
248
|
};
|
|
254
249
|
}
|
|
@@ -288,15 +283,12 @@ export class LspServerManager<TClient extends LspManagedServerClient = LspManage
|
|
|
288
283
|
}
|
|
289
284
|
}
|
|
290
285
|
return {
|
|
291
|
-
enabled: this.input.settings.enabled,
|
|
292
286
|
servers,
|
|
293
287
|
warnings: this.input.settings.warnings,
|
|
294
288
|
};
|
|
295
289
|
}
|
|
296
290
|
|
|
297
|
-
|
|
298
|
-
async routeFile(filePath: string): Promise<readonly LspServerRoute[]> {
|
|
299
|
-
if (!this.input.settings.enabled) return [];
|
|
291
|
+
private async routeFile(filePath: string): Promise<readonly LspServerRoute[]> {
|
|
300
292
|
const absolutePath = resolve(this.input.cwd, normalizeLspFilePath(filePath));
|
|
301
293
|
const ancestors = await readLspAncestorDirectories(absolutePath);
|
|
302
294
|
const definitions = [...this.input.settings.servers.values()].map((definition) => ({
|
|
@@ -316,7 +308,7 @@ export class LspServerManager<TClient extends LspManagedServerClient = LspManage
|
|
|
316
308
|
);
|
|
317
309
|
}
|
|
318
310
|
|
|
319
|
-
/** Query
|
|
311
|
+
/** Query matching capable instances while retaining independent operational failures. */
|
|
320
312
|
async runRead<T>(
|
|
321
313
|
filePath: string,
|
|
322
314
|
serverId: string | undefined,
|
|
@@ -332,14 +324,14 @@ export class LspServerManager<TClient extends LspManagedServerClient = LspManage
|
|
|
332
324
|
}
|
|
333
325
|
|
|
334
326
|
const outcomes = await Promise.all(
|
|
335
|
-
routes.map(async (route): Promise<LspServerSuccess<T> | LspServerFailure> => {
|
|
327
|
+
routes.map(async (route): Promise<LspServerSuccess<T> | LspServerFailure | undefined> => {
|
|
336
328
|
const resolution = await this.ensureClient(route);
|
|
337
329
|
if (resolution.kind === "failure") return resolution.failure;
|
|
338
330
|
if (!isCapable(resolution.instance.client)) {
|
|
331
|
+
if (serverId === undefined) return undefined;
|
|
339
332
|
return {
|
|
340
333
|
code: "no-capable-server",
|
|
341
334
|
message: `Pi LSP: server ${route.serverId} does not support the requested operation`,
|
|
342
|
-
rootPath: route.rootPath,
|
|
343
335
|
serverId: route.serverId,
|
|
344
336
|
};
|
|
345
337
|
}
|
|
@@ -353,7 +345,6 @@ export class LspServerManager<TClient extends LspManagedServerClient = LspManage
|
|
|
353
345
|
return {
|
|
354
346
|
code: "request-failed",
|
|
355
347
|
message: `Pi LSP: server ${route.serverId} request failed: ${describeLspError(error)}`,
|
|
356
|
-
rootPath: route.rootPath,
|
|
357
348
|
serverId: route.serverId,
|
|
358
349
|
};
|
|
359
350
|
}
|
|
@@ -363,9 +354,17 @@ export class LspServerManager<TClient extends LspManagedServerClient = LspManage
|
|
|
363
354
|
const failures: LspServerFailure[] = [];
|
|
364
355
|
const successes: LspServerSuccess<T>[] = [];
|
|
365
356
|
for (const outcome of outcomes) {
|
|
357
|
+
if (outcome === undefined) continue;
|
|
366
358
|
if ("code" in outcome) failures.push(outcome);
|
|
367
359
|
else successes.push(outcome);
|
|
368
360
|
}
|
|
361
|
+
if (failures.length === 0 && successes.length === 0) {
|
|
362
|
+
failures.push({
|
|
363
|
+
code: "no-capable-server",
|
|
364
|
+
message: "Pi LSP: no matching server supports the requested read operation",
|
|
365
|
+
serverId: "*",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
369
368
|
return { failures, successes };
|
|
370
369
|
}
|
|
371
370
|
|
package/src/lsp-session-files.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
|
-
/** The maximum retained byte length for one language server's stderr log. */
|
|
5
|
-
export const MAX_SERVER_STDERR_BYTES = 1024 * 1024;
|
|
6
|
-
|
|
7
4
|
/** Owns private Result Spill and bounded stderr files for one Pi session. */
|
|
8
5
|
export interface LspSessionFiles {
|
|
9
6
|
/** Private directory removed when the Pi session shuts down. */
|
|
@@ -12,15 +9,12 @@ export interface LspSessionFiles {
|
|
|
12
9
|
writeResultSpill(output: string): Promise<string>;
|
|
13
10
|
/** Create or return the bounded stderr file path for one language server. */
|
|
14
11
|
getServerStderrPath(serverId: string): Promise<string>;
|
|
15
|
-
/** Retain the latest one megabyte of one language server's stderr stream. */
|
|
16
|
-
appendServerStderr(serverId: string, chunk: Uint8Array): Promise<string>;
|
|
17
12
|
/** Remove all session files after queued writes finish. */
|
|
18
13
|
close(): Promise<void>;
|
|
19
14
|
}
|
|
20
15
|
|
|
21
16
|
interface ServerStderrFile {
|
|
22
17
|
readonly path: string;
|
|
23
|
-
content: Buffer;
|
|
24
18
|
}
|
|
25
19
|
|
|
26
20
|
class LspSessionFileStore implements LspSessionFiles {
|
|
@@ -45,22 +39,7 @@ class LspSessionFileStore implements LspSessionFiles {
|
|
|
45
39
|
getServerStderrPath(serverId: string): Promise<string> {
|
|
46
40
|
const stderrFile = this.serverStderrFile(serverId);
|
|
47
41
|
return this.enqueueSessionFileWrite(async () => {
|
|
48
|
-
await writeFile(stderrFile.path,
|
|
49
|
-
await chmod(stderrFile.path, 0o600);
|
|
50
|
-
return stderrFile.path;
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
appendServerStderr(serverId: string, chunk: Uint8Array): Promise<string> {
|
|
55
|
-
const stderrFile = this.serverStderrFile(serverId);
|
|
56
|
-
const copiedChunk = Buffer.from(chunk);
|
|
57
|
-
return this.enqueueSessionFileWrite(async () => {
|
|
58
|
-
const combined = Buffer.concat([stderrFile.content, copiedChunk]);
|
|
59
|
-
stderrFile.content =
|
|
60
|
-
combined.length <= MAX_SERVER_STDERR_BYTES
|
|
61
|
-
? combined
|
|
62
|
-
: Buffer.from(combined.subarray(combined.length - MAX_SERVER_STDERR_BYTES));
|
|
63
|
-
await writeFile(stderrFile.path, stderrFile.content, { mode: 0o600 });
|
|
42
|
+
await writeFile(stderrFile.path, "", { mode: 0o600 });
|
|
64
43
|
await chmod(stderrFile.path, 0o600);
|
|
65
44
|
return stderrFile.path;
|
|
66
45
|
});
|
|
@@ -81,7 +60,6 @@ class LspSessionFileStore implements LspSessionFiles {
|
|
|
81
60
|
if (existing !== undefined) return existing;
|
|
82
61
|
const stderrFile = {
|
|
83
62
|
path: join(this.directoryPath, `server-stderr-${this.nextFileIndex++}.log`),
|
|
84
|
-
content: Buffer.alloc(0),
|
|
85
63
|
};
|
|
86
64
|
this.stderrFiles.set(serverId, stderrFile);
|
|
87
65
|
return stderrFile;
|
package/src/lsp-tool-contract.ts
CHANGED
|
@@ -332,7 +332,6 @@ const RegularFileSnapshotSchema = Type.Object(
|
|
|
332
332
|
{
|
|
333
333
|
kind: Type.Literal("file"),
|
|
334
334
|
content_base64: Type.String(),
|
|
335
|
-
hash: Type.String({ minLength: 64, maxLength: 64 }),
|
|
336
335
|
mode: Type.Integer({ minimum: 0 }),
|
|
337
336
|
},
|
|
338
337
|
{ additionalProperties: false },
|
|
@@ -345,13 +344,17 @@ const SymlinkSnapshotSchema = Type.Object(
|
|
|
345
344
|
},
|
|
346
345
|
{ additionalProperties: false },
|
|
347
346
|
);
|
|
348
|
-
|
|
347
|
+
/** Canonical pre-mutation snapshot of one filesystem path used by guarded rollback. */
|
|
348
|
+
export const FileSnapshotSchema = Type.Union([
|
|
349
349
|
MissingFileSnapshotSchema,
|
|
350
350
|
RegularFileSnapshotSchema,
|
|
351
351
|
SymlinkSnapshotSchema,
|
|
352
352
|
]);
|
|
353
|
-
const ExistingFileSnapshotSchema = Type.Union([
|
|
354
|
-
|
|
353
|
+
export const ExistingFileSnapshotSchema = Type.Union([
|
|
354
|
+
RegularFileSnapshotSchema,
|
|
355
|
+
SymlinkSnapshotSchema,
|
|
356
|
+
]);
|
|
357
|
+
export const WorkspaceEditOperationSchema = Type.Union([
|
|
355
358
|
Type.Object(
|
|
356
359
|
{
|
|
357
360
|
kind: Type.Literal("modify"),
|
|
@@ -440,7 +443,6 @@ const WorkspaceEditApplyDetailsSchema = Type.Object(
|
|
|
440
443
|
changed_paths: Type.Array(AbsolutePathSchema),
|
|
441
444
|
preview_records: Type.Optional(Type.Array(LspWorkspaceEditPreviewRecordSchema)),
|
|
442
445
|
state: Type.Union([Type.Literal("applied"), Type.Literal("partial_failure")]),
|
|
443
|
-
recovery_failure_paths: Type.Optional(Type.Array(AbsolutePathSchema)),
|
|
444
446
|
},
|
|
445
447
|
{ additionalProperties: false },
|
|
446
448
|
);
|
|
@@ -460,9 +462,3 @@ export type LspWorkspaceEditPreviewRecord = Static<typeof LspWorkspaceEditPrevie
|
|
|
460
462
|
|
|
461
463
|
/** A normalized per-server outcome used when rendering an LSP read operation. */
|
|
462
464
|
export type ServerOperationOutcome = Static<typeof ServerOperationOutcomeSchema>;
|
|
463
|
-
|
|
464
|
-
/** A persisted Workspace Edit Preview that can be rebuilt from the active session branch. */
|
|
465
|
-
export type WorkspaceEditPreviewDetails = Static<typeof WorkspaceEditPreviewDetailsSchema>;
|
|
466
|
-
|
|
467
|
-
/** The result of applying one guarded Workspace Edit Preview. */
|
|
468
|
-
export type WorkspaceEditApplyDetails = Static<typeof WorkspaceEditApplyDetailsSchema>;
|
package/src/lsp-tool-output.ts
CHANGED
|
@@ -32,6 +32,24 @@ export function formatLspToolValue(value: LSPAny): string {
|
|
|
32
32
|
return text === undefined ? "null" : text;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Truncate model-visible text against Pi limits, spilling the complete text when truncated. */
|
|
36
|
+
export async function truncateLspOutputText(
|
|
37
|
+
text: string,
|
|
38
|
+
sessionFiles: LspSessionFiles,
|
|
39
|
+
subject: string,
|
|
40
|
+
): Promise<{ readonly text: string; readonly spillPath?: string }> {
|
|
41
|
+
const truncation = truncateHead(text, {
|
|
42
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
43
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
44
|
+
});
|
|
45
|
+
if (!truncation.truncated) return { text };
|
|
46
|
+
const spillPath = await sessionFiles.writeResultSpill(text);
|
|
47
|
+
return {
|
|
48
|
+
text: `${truncation.content}\n\n[Pi LSP: ${subject} truncated; complete Result Spill: ${spillPath}]`,
|
|
49
|
+
spillPath,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
/** Validate normalized details, truncate model-visible text, and spill every complete oversized result. */
|
|
36
54
|
export async function createLspToolOutput(
|
|
37
55
|
text: string,
|
|
@@ -39,26 +57,20 @@ export async function createLspToolOutput(
|
|
|
39
57
|
sessionFiles: LspSessionFiles,
|
|
40
58
|
): Promise<AgentToolResult<LspToolResultDetails>> {
|
|
41
59
|
const normalizedDetails = Value.Parse(LspToolResultDetailsSchema, details);
|
|
42
|
-
const truncated =
|
|
43
|
-
|
|
44
|
-
maxLines: DEFAULT_MAX_LINES,
|
|
45
|
-
});
|
|
46
|
-
if (!truncated.truncated) {
|
|
60
|
+
const truncated = await truncateLspOutputText(text, sessionFiles, "output");
|
|
61
|
+
if (truncated.spillPath === undefined) {
|
|
47
62
|
return { content: [{ type: "text", text }], details: normalizedDetails };
|
|
48
63
|
}
|
|
49
64
|
|
|
50
|
-
const spillPath = await sessionFiles.writeResultSpill(text);
|
|
51
|
-
const notice = `\n\n[Pi LSP: output truncated; complete Result Spill: ${spillPath}]`;
|
|
52
|
-
const visibleText = `${truncated.content}${notice}`;
|
|
53
65
|
const detailsWithSpill =
|
|
54
66
|
normalizedDetails.kind === "operation"
|
|
55
67
|
? Value.Parse(LspToolResultDetailsSchema, {
|
|
56
68
|
...normalizedDetails,
|
|
57
|
-
spill_path: spillPath,
|
|
69
|
+
spill_path: truncated.spillPath,
|
|
58
70
|
})
|
|
59
71
|
: normalizedDetails;
|
|
60
72
|
return {
|
|
61
|
-
content: [{ type: "text", text:
|
|
73
|
+
content: [{ type: "text", text: truncated.text }],
|
|
62
74
|
details: detailsWithSpill,
|
|
63
75
|
};
|
|
64
76
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type LspToolResultDetails,
|
|
17
17
|
type ServerOperationOutcome,
|
|
18
18
|
} from "./lsp-tool-contract.js";
|
|
19
|
+
import { pluralizedCount } from "./lsp-post-edit-diagnostics-rendering.js";
|
|
19
20
|
|
|
20
21
|
/** Theme operations used by Pi LSP tool transcript rendering. */
|
|
21
22
|
export type LspRenderTheme = Pick<Theme, "bold" | "fg">;
|
|
@@ -139,10 +140,6 @@ function semanticLspOperationMetric(
|
|
|
139
140
|
return pluralizedCount(count, noun);
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
function pluralizedCount(count: number, noun: string): string {
|
|
143
|
-
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
143
|
function outcomeColor(outcome: ServerOperationOutcome["outcome"]): ThemeColor {
|
|
147
144
|
switch (outcome) {
|
|
148
145
|
case "success":
|
package/src/lsp-tool.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AgentToolResult,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
ToolDefinition,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
9
|
import { type Static, Type } from "typebox";
|
|
6
10
|
import { Value } from "typebox/value";
|
|
7
11
|
import {
|
|
@@ -227,6 +231,23 @@ async function createLspToolOutput(
|
|
|
227
231
|
);
|
|
228
232
|
}
|
|
229
233
|
|
|
234
|
+
async function readOutput(
|
|
235
|
+
operation: LspToolParameters["operation"],
|
|
236
|
+
result: Promise<LspServerReadResult<LSPAny>>,
|
|
237
|
+
dependencies: LspToolDependencies,
|
|
238
|
+
) {
|
|
239
|
+
const resolved = await result;
|
|
240
|
+
requireReadSuccess(resolved);
|
|
241
|
+
return createLspToolOutput(
|
|
242
|
+
formatLspToolValue({
|
|
243
|
+
results: readOperationValue(resolved),
|
|
244
|
+
warnings: resolved.failures.map(({ message }) => message),
|
|
245
|
+
}),
|
|
246
|
+
operationDetails(operation, readOperationOutcomes(resolved)),
|
|
247
|
+
dependencies,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
230
251
|
function parseLspToolParameters(input: LSPAny): LspToolParameters {
|
|
231
252
|
try {
|
|
232
253
|
return Value.Parse(LspToolParametersSchema, input);
|
|
@@ -440,15 +461,6 @@ async function normalizeProtocolResult(
|
|
|
440
461
|
return Object.fromEntries(entries);
|
|
441
462
|
}
|
|
442
463
|
|
|
443
|
-
async function requestDocumentMethod(
|
|
444
|
-
prepared: PreparedDocument,
|
|
445
|
-
method: string,
|
|
446
|
-
parameters: LSPAny,
|
|
447
|
-
signal: AbortSignal | undefined,
|
|
448
|
-
): Promise<LSPAny> {
|
|
449
|
-
return prepared.client.request<LSPAny>(method, parameters, signal);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
464
|
function supportsResolveProvider(value: LSPAny): boolean {
|
|
453
465
|
return protocolRecord(value)?.resolveProvider === true;
|
|
454
466
|
}
|
|
@@ -545,9 +557,7 @@ async function workspacePreviewOutput(
|
|
|
545
557
|
serverId: string,
|
|
546
558
|
edit: WorkspaceEdit,
|
|
547
559
|
positionEncoding: PositionEncodingKind,
|
|
548
|
-
): Promise<
|
|
549
|
-
ReturnType<typeof createLspToolOutput> extends Promise<infer TResult> ? TResult : never
|
|
550
|
-
> {
|
|
560
|
+
): Promise<AgentToolResult<LspToolResultDetails>> {
|
|
551
561
|
const preview = await dependencies.workspaceEdits.createPreview({
|
|
552
562
|
edit,
|
|
553
563
|
serverId,
|
|
@@ -617,8 +627,7 @@ async function executePositionRead(
|
|
|
617
627
|
context: { includeDeclaration: parameters.include_declaration ?? true },
|
|
618
628
|
};
|
|
619
629
|
}
|
|
620
|
-
let value = await
|
|
621
|
-
prepared,
|
|
630
|
+
let value = await prepared.client.request<LSPAny>(
|
|
622
631
|
capabilityMethod,
|
|
623
632
|
requestParameters,
|
|
624
633
|
signal,
|
|
@@ -1015,7 +1024,6 @@ async function executeApplyPreview(
|
|
|
1015
1024
|
preview_id: parameters.preview_id,
|
|
1016
1025
|
mutation_manifest: canonicalManifest,
|
|
1017
1026
|
changed_paths: [...cause.recoveryFailures].sort((left, right) => left.localeCompare(right)),
|
|
1018
|
-
recovery_failure_paths: [...cause.recoveryFailures],
|
|
1019
1027
|
state: "partial_failure",
|
|
1020
1028
|
},
|
|
1021
1029
|
dependencies,
|
|
@@ -1127,72 +1135,42 @@ export function createLspToolDefinition(
|
|
|
1127
1135
|
case "type_hierarchy":
|
|
1128
1136
|
case "supertypes":
|
|
1129
1137
|
case "subtypes":
|
|
1130
|
-
case "prepare_rename":
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
formatLspToolValue({
|
|
1135
|
-
results: readOperationValue(result),
|
|
1136
|
-
warnings: result.failures.map(({ message }) => message),
|
|
1137
|
-
}),
|
|
1138
|
-
operationDetails(parameters.operation, readOperationOutcomes(result)),
|
|
1138
|
+
case "prepare_rename":
|
|
1139
|
+
return readOutput(
|
|
1140
|
+
parameters.operation,
|
|
1141
|
+
executePositionRead(dependencies, parameters, context, signal),
|
|
1139
1142
|
dependencies,
|
|
1140
1143
|
);
|
|
1141
|
-
}
|
|
1142
1144
|
case "diagnostics":
|
|
1143
1145
|
case "document_symbols":
|
|
1144
1146
|
case "document_links":
|
|
1145
1147
|
case "folding_ranges":
|
|
1146
1148
|
case "code_lenses":
|
|
1147
|
-
case "document_colors":
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
formatLspToolValue({
|
|
1152
|
-
results: readOperationValue(result),
|
|
1153
|
-
warnings: result.failures.map(({ message }) => message),
|
|
1154
|
-
}),
|
|
1155
|
-
operationDetails(parameters.operation, readOperationOutcomes(result)),
|
|
1149
|
+
case "document_colors":
|
|
1150
|
+
return readOutput(
|
|
1151
|
+
parameters.operation,
|
|
1152
|
+
executeFileRead(dependencies, parameters, context, signal),
|
|
1156
1153
|
dependencies,
|
|
1157
1154
|
);
|
|
1158
|
-
}
|
|
1159
1155
|
case "workspace_diagnostics":
|
|
1160
|
-
case "workspace_symbols":
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
formatLspToolValue({
|
|
1165
|
-
results: readOperationValue(result),
|
|
1166
|
-
warnings: result.failures.map(({ message }) => message),
|
|
1167
|
-
}),
|
|
1168
|
-
operationDetails(parameters.operation, readOperationOutcomes(result)),
|
|
1156
|
+
case "workspace_symbols":
|
|
1157
|
+
return readOutput(
|
|
1158
|
+
parameters.operation,
|
|
1159
|
+
executeWorkspaceRead(dependencies, parameters, context, signal),
|
|
1169
1160
|
dependencies,
|
|
1170
1161
|
);
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
return createLspToolOutput(
|
|
1176
|
-
formatLspToolValue({
|
|
1177
|
-
results: readOperationValue(result),
|
|
1178
|
-
warnings: result.failures.map(({ message }) => message),
|
|
1179
|
-
}),
|
|
1180
|
-
operationDetails(parameters.operation, readOperationOutcomes(result)),
|
|
1162
|
+
case "selection_ranges":
|
|
1163
|
+
return readOutput(
|
|
1164
|
+
parameters.operation,
|
|
1165
|
+
executeSelectionRanges(dependencies, parameters, context, signal),
|
|
1181
1166
|
dependencies,
|
|
1182
1167
|
);
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
return createLspToolOutput(
|
|
1188
|
-
formatLspToolValue({
|
|
1189
|
-
results: readOperationValue(result),
|
|
1190
|
-
warnings: result.failures.map(({ message }) => message),
|
|
1191
|
-
}),
|
|
1192
|
-
operationDetails(parameters.operation, readOperationOutcomes(result)),
|
|
1168
|
+
case "inlay_hints":
|
|
1169
|
+
return readOutput(
|
|
1170
|
+
parameters.operation,
|
|
1171
|
+
executeInlayHints(dependencies, parameters, context, signal),
|
|
1193
1172
|
dependencies,
|
|
1194
1173
|
);
|
|
1195
|
-
}
|
|
1196
1174
|
case "format_document":
|
|
1197
1175
|
case "format_range":
|
|
1198
1176
|
case "format_on_type":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
chmod,
|
|
4
4
|
lstat,
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
convertLspProtocolPosition,
|
|
28
28
|
normalizeLspPositionEncoding,
|
|
29
29
|
} from "./lsp-position-encoding.js";
|
|
30
|
+
import {
|
|
31
|
+
FileSnapshotSchema,
|
|
32
|
+
LspWorkspaceEditPreviewRecordSchema,
|
|
33
|
+
WorkspaceEditOperationSchema,
|
|
34
|
+
} from "./lsp-tool-contract.js";
|
|
35
|
+
import type { Static } from "typebox";
|
|
30
36
|
|
|
31
37
|
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
32
38
|
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
@@ -48,63 +54,19 @@ export interface LspMutationManifest {
|
|
|
48
54
|
readonly entries: readonly LspMutationManifestEntry[];
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
| {
|
|
54
|
-
readonly kind: "file";
|
|
55
|
-
readonly content_base64: string;
|
|
56
|
-
readonly hash: string;
|
|
57
|
-
readonly mode: number;
|
|
58
|
-
}
|
|
59
|
-
| { readonly kind: "symlink"; readonly link_target: string; readonly mode: number };
|
|
60
|
-
|
|
61
|
-
type ModifyOperation = {
|
|
62
|
-
readonly kind: "modify";
|
|
63
|
-
readonly named_path: string;
|
|
64
|
-
readonly path: string;
|
|
65
|
-
readonly named_before: FileSnapshot;
|
|
66
|
-
readonly before: FileSnapshot;
|
|
67
|
-
after_base64: string;
|
|
68
|
-
readonly mode: number;
|
|
69
|
-
};
|
|
57
|
+
/** Canonical pre-mutation snapshot of one filesystem path used by guarded rollback. */
|
|
58
|
+
type FileSnapshot = Static<typeof FileSnapshotSchema>;
|
|
70
59
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
readonly mode: number;
|
|
77
|
-
};
|
|
60
|
+
/** Schema-derived operation with a writable `after_base64` for incremental document edits. */
|
|
61
|
+
type Writable<T> = { -readonly [K in keyof T]: T[K] };
|
|
62
|
+
type EditableOperation = Writable<
|
|
63
|
+
Extract<Static<typeof WorkspaceEditOperationSchema>, { kind: "modify" | "create" }>
|
|
64
|
+
>;
|
|
78
65
|
|
|
79
|
-
type
|
|
80
|
-
readonly kind: "delete";
|
|
81
|
-
readonly named_path: string;
|
|
82
|
-
readonly before: Exclude<FileSnapshot, { readonly kind: "missing" }>;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
type RenameOperation = {
|
|
86
|
-
readonly kind: "rename";
|
|
87
|
-
readonly named_path: string;
|
|
88
|
-
readonly destination_path: string;
|
|
89
|
-
readonly before: Exclude<FileSnapshot, { readonly kind: "missing" }>;
|
|
90
|
-
readonly destination_before: FileSnapshot;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
type NormalizedWorkspaceOperation =
|
|
94
|
-
| ModifyOperation
|
|
95
|
-
| CreateOperation
|
|
96
|
-
| DeleteOperation
|
|
97
|
-
| RenameOperation;
|
|
66
|
+
type NormalizedWorkspaceOperation = Static<typeof WorkspaceEditOperationSchema>;
|
|
98
67
|
|
|
99
68
|
/** Schema-friendly preview record persisted in LSP tool result details. */
|
|
100
|
-
|
|
101
|
-
readonly kind: "workspace_edit_preview";
|
|
102
|
-
readonly preview_id: string;
|
|
103
|
-
readonly server_id: string;
|
|
104
|
-
readonly summary: string;
|
|
105
|
-
readonly state: "available" | "applied";
|
|
106
|
-
readonly operations: readonly NormalizedWorkspaceOperation[];
|
|
107
|
-
}
|
|
69
|
+
type LspWorkspaceEditPreview = Static<typeof LspWorkspaceEditPreviewRecordSchema>;
|
|
108
70
|
|
|
109
71
|
/** Result of one guarded Workspace Edit application. */
|
|
110
72
|
export interface LspWorkspaceEditApplyResult {
|
|
@@ -164,8 +126,6 @@ type WorkspaceEditErrorCode =
|
|
|
164
126
|
|
|
165
127
|
/** Expected preview normalization, validation, apply, or rollback failure. */
|
|
166
128
|
export class LspWorkspaceEditError extends Error {
|
|
167
|
-
readonly _tag = "LspWorkspaceEditError" as const;
|
|
168
|
-
|
|
169
129
|
/** Construct a stable Workspace Edit failure with optional unrecovered paths. */
|
|
170
130
|
constructor(
|
|
171
131
|
readonly code: WorkspaceEditErrorCode,
|
|
@@ -194,16 +154,6 @@ interface DecodedUtf8Document {
|
|
|
194
154
|
readonly text: string;
|
|
195
155
|
}
|
|
196
156
|
|
|
197
|
-
/** Counts accepted and rejected persisted Workspace Edit Preview records. */
|
|
198
|
-
export interface LspWorkspaceEditReplayResult {
|
|
199
|
-
readonly accepted: number;
|
|
200
|
-
readonly rejected: number;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function hashContents(contents: Buffer): string {
|
|
204
|
-
return createHash("sha256").update(contents).digest("hex");
|
|
205
|
-
}
|
|
206
|
-
|
|
207
157
|
function contentsFromSnapshot(snapshot: FileSnapshot): Buffer {
|
|
208
158
|
if (snapshot.kind !== "file") {
|
|
209
159
|
throw new LspWorkspaceEditError("invalid_destination", "expected a regular file");
|
|
@@ -236,7 +186,6 @@ async function snapshotNamedPath(path: string): Promise<FileSnapshot> {
|
|
|
236
186
|
return {
|
|
237
187
|
kind: "file",
|
|
238
188
|
content_base64: contents.toString("base64"),
|
|
239
|
-
hash: hashContents(contents),
|
|
240
189
|
mode,
|
|
241
190
|
};
|
|
242
191
|
}
|
|
@@ -424,10 +373,6 @@ async function restorePath(
|
|
|
424
373
|
await symlink(snapshot.link_target, path);
|
|
425
374
|
}
|
|
426
375
|
|
|
427
|
-
function mutablePreview(preview: LspWorkspaceEditPreview): LspWorkspaceEditPreview {
|
|
428
|
-
return structuredClone(preview);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
376
|
/** Own persisted Workspace Edit Preview state and guarded one-use application. */
|
|
432
377
|
export class LspWorkspaceEditStore {
|
|
433
378
|
private readonly previews = new Map<string, LspWorkspaceEditPreview>();
|
|
@@ -446,7 +391,7 @@ export class LspWorkspaceEditStore {
|
|
|
446
391
|
/** Normalize and persist one language-server Workspace Edit without mutating files. */
|
|
447
392
|
async createPreview(input: CreateWorkspaceEditPreviewInput): Promise<LspWorkspaceEditPreview> {
|
|
448
393
|
const operations: NormalizedWorkspaceOperation[] = [];
|
|
449
|
-
const editableOperations = new Map<string,
|
|
394
|
+
const editableOperations = new Map<string, EditableOperation>();
|
|
450
395
|
const resourceActions = new Map<string, string>();
|
|
451
396
|
const destinations = new Set<string>();
|
|
452
397
|
const encoding = input.positionEncoding ?? "utf-16";
|
|
@@ -500,7 +445,7 @@ export class LspWorkspaceEditStore {
|
|
|
500
445
|
const current = contentsFromSnapshot(before);
|
|
501
446
|
const decoded = decodeUtf8(current, targetPath);
|
|
502
447
|
const after = encodeUtf8(applyTextEdits(decoded.text, edits, encoding), decoded.bom);
|
|
503
|
-
const operation:
|
|
448
|
+
const operation: EditableOperation = {
|
|
504
449
|
kind: "modify",
|
|
505
450
|
named_path: namedPath,
|
|
506
451
|
path: targetPath,
|
|
@@ -540,7 +485,7 @@ export class LspWorkspaceEditStore {
|
|
|
540
485
|
`create destination exists: ${path}`,
|
|
541
486
|
);
|
|
542
487
|
}
|
|
543
|
-
const operation:
|
|
488
|
+
const operation: EditableOperation = {
|
|
544
489
|
kind: "create",
|
|
545
490
|
named_path: path,
|
|
546
491
|
before,
|
|
@@ -649,18 +594,16 @@ export class LspWorkspaceEditStore {
|
|
|
649
594
|
}
|
|
650
595
|
|
|
651
596
|
/** Rebuild branch-local available/applied preview state from persisted tool result records. */
|
|
652
|
-
replayPreviewRecords(records: readonly LSPAny[]):
|
|
653
|
-
let accepted = 0;
|
|
597
|
+
replayPreviewRecords(records: readonly LSPAny[]): number {
|
|
654
598
|
let rejected = 0;
|
|
655
599
|
for (const record of records) {
|
|
656
600
|
if (!isWorkspaceEditPreview(record)) {
|
|
657
601
|
rejected++;
|
|
658
602
|
continue;
|
|
659
603
|
}
|
|
660
|
-
this.previews.set(record.preview_id,
|
|
661
|
-
accepted++;
|
|
604
|
+
this.previews.set(record.preview_id, structuredClone(record));
|
|
662
605
|
}
|
|
663
|
-
return
|
|
606
|
+
return rejected;
|
|
664
607
|
}
|
|
665
608
|
|
|
666
609
|
/** Revalidate and apply one preview inside every sorted canonical mutation queue. */
|
package/src/pi-lsp-extension.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
DEFAULT_MAX_BYTES,
|
|
5
|
-
DEFAULT_MAX_LINES,
|
|
6
4
|
getAgentDir,
|
|
7
5
|
SettingsManager,
|
|
8
|
-
truncateHead,
|
|
9
6
|
type ExtensionAPI,
|
|
10
7
|
type ExtensionContext,
|
|
11
8
|
type ExtensionFactory,
|
|
@@ -20,11 +17,10 @@ import {
|
|
|
20
17
|
type Diagnostic,
|
|
21
18
|
} from "vscode-languageserver-protocol/node";
|
|
22
19
|
import {
|
|
23
|
-
|
|
20
|
+
appendPostEditDiagnostics,
|
|
24
21
|
type PostEditDiagnosticOutcome,
|
|
25
22
|
type PostEditDiagnosticPath,
|
|
26
23
|
type PostEditDiagnosticsResultPatch,
|
|
27
|
-
type PostEditDiagnosticsRunner,
|
|
28
24
|
} from "./lsp-post-edit-diagnostics.js";
|
|
29
25
|
import {
|
|
30
26
|
createPostEditDiagnosticsEntryData,
|
|
@@ -45,6 +41,7 @@ import {
|
|
|
45
41
|
type LspWorkspaceEditPreviewRecord,
|
|
46
42
|
} from "./lsp-tool-contract.js";
|
|
47
43
|
import { registerLspTool } from "./lsp-tool.js";
|
|
44
|
+
import { truncateLspOutputText } from "./lsp-tool-output.js";
|
|
48
45
|
import { LspWorkspaceEditStore } from "./lsp-workspace-edit.js";
|
|
49
46
|
import { resolveLspSettings } from "./pi-lsp-settings.js";
|
|
50
47
|
|
|
@@ -143,13 +140,13 @@ function failureDiagnosticOutcome(
|
|
|
143
140
|
return { kind: "unavailable_server", path, serverId: failure.serverId };
|
|
144
141
|
}
|
|
145
142
|
|
|
146
|
-
class ManagerPostEditDiagnosticsRunner
|
|
143
|
+
class ManagerPostEditDiagnosticsRunner {
|
|
147
144
|
constructor(
|
|
148
145
|
private readonly session: ActivePiLspSession,
|
|
149
146
|
private readonly signal: AbortSignal | undefined,
|
|
150
147
|
) {}
|
|
151
148
|
|
|
152
|
-
async
|
|
149
|
+
async run(
|
|
153
150
|
paths: readonly PostEditDiagnosticPath[],
|
|
154
151
|
): Promise<readonly PostEditDiagnosticOutcome[]> {
|
|
155
152
|
const outcomes: PostEditDiagnosticOutcome[] = [];
|
|
@@ -207,24 +204,20 @@ async function appendSessionPostEditDiagnostics(
|
|
|
207
204
|
session: ActivePiLspSession,
|
|
208
205
|
context: ExtensionContext,
|
|
209
206
|
): Promise<PostEditDiagnosticsResultPatch | undefined> {
|
|
210
|
-
const patch = await
|
|
211
|
-
|
|
212
|
-
new ManagerPostEditDiagnosticsRunner(session, context.signal),
|
|
207
|
+
const patch = await appendPostEditDiagnostics(event, (paths) =>
|
|
208
|
+
new ManagerPostEditDiagnosticsRunner(session, context.signal).run(paths),
|
|
213
209
|
);
|
|
214
210
|
if (patch === undefined) return undefined;
|
|
215
211
|
const appendedValue = patch.content.at(-1);
|
|
216
212
|
if (!Value.Check(AppendedTextContentSchema, appendedValue)) return undefined;
|
|
217
213
|
let appended: Static<typeof AppendedTextContentSchema> = appendedValue;
|
|
218
|
-
const truncation =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
appended = {
|
|
225
|
-
type: "text",
|
|
226
|
-
text: `${truncation.content}\n\n[Pi LSP: diagnostics truncated; complete Result Spill: ${spillPath}]`,
|
|
227
|
-
};
|
|
214
|
+
const truncation = await truncateLspOutputText(
|
|
215
|
+
appended.text,
|
|
216
|
+
session.sessionFiles,
|
|
217
|
+
"diagnostics",
|
|
218
|
+
);
|
|
219
|
+
if (truncation.spillPath !== undefined) {
|
|
220
|
+
appended = { type: "text", text: truncation.text };
|
|
228
221
|
}
|
|
229
222
|
const partialApplyFailure =
|
|
230
223
|
event.toolName === "lsp" &&
|
|
@@ -280,9 +273,9 @@ export class PiLspLifecycleController {
|
|
|
280
273
|
const replay = workspaceEdits.replayPreviewRecords(
|
|
281
274
|
branchLspToolResultDetails(context.sessionManager.getBranch()),
|
|
282
275
|
);
|
|
283
|
-
if (replay
|
|
276
|
+
if (replay > 0) {
|
|
284
277
|
context.ui.notify(
|
|
285
|
-
`Pi LSP ignored ${replay
|
|
278
|
+
`Pi LSP ignored ${replay} invalid Workspace Edit Preview record${replay === 1 ? "" : "s"} on the active session branch.`,
|
|
286
279
|
"warning",
|
|
287
280
|
);
|
|
288
281
|
}
|
package/src/pi-lsp-settings.ts
CHANGED
|
@@ -96,7 +96,6 @@ export interface LspTimeouts {
|
|
|
96
96
|
|
|
97
97
|
/** Reports resolved trusted configuration, retaining valid entries when other settings are invalid. */
|
|
98
98
|
export interface ResolvedLspSettings {
|
|
99
|
-
readonly enabled: boolean;
|
|
100
99
|
readonly servers: ReadonlyMap<string, LspServerDefinition>;
|
|
101
100
|
readonly timeouts: LspTimeouts;
|
|
102
101
|
readonly warnings: readonly string[];
|
|
@@ -337,7 +336,6 @@ export function resolveLspSettings(reader: LspSettingsReader): ResolvedLspSettin
|
|
|
337
336
|
const globalLayer = readLspLayer(reader.getGlobalSettings(), "global");
|
|
338
337
|
const projectLayer = readLspLayer(reader.getProjectSettings(), "project");
|
|
339
338
|
return {
|
|
340
|
-
enabled: true,
|
|
341
339
|
servers: mergeLspServers(globalLayer, projectLayer),
|
|
342
340
|
timeouts: mergeLspTimeouts(globalLayer, projectLayer),
|
|
343
341
|
warnings: [...globalLayer.warnings, ...projectLayer.warnings],
|