@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 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. A mutation may omit `server_id` only when exactly one matching capable
106
- server exists.
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.1.1",
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
- function documentLines(documentText: string): readonly string[] {
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
- function pluralizedCount(count: number, singular: string, plural = `${singular}s`): string {
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 interface PostEditDiagnosticsRunner {
102
- /** Return every fresh diagnostic and explicit non-diagnostic outcome for the supplied paths. */
103
- runPostEditDiagnostics(
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<PostEditToolResult, "toolName" | "input" | "details" | "isError">,
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: PostEditToolResult,
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.runPostEditDiagnostics(extracted.paths)),
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
- }
@@ -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 interface LspServerClientTimeouts {
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.split(/\r\n|\r|\n/);
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.firstDiagnosticResult(candidates, signal);
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, (parameters) => {
815
- this.rememberProtocolMessage(parameters.message);
816
- return null;
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([request, timeoutPromise, abortPromise, terminalPromise]);
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(() => reject(timeoutError(this.options, operation)), budgetMs);
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 = () => reject(abortError(this.options));
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
- /** Resolve all matching Server Definitions and nearest roots without starting clients. */
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 every matching capable instance while retaining independent successes and failures. */
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
 
@@ -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, stderrFile.content, { mode: 0o600 });
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;
@@ -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
- const FileSnapshotSchema = Type.Union([
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([RegularFileSnapshotSchema, SymlinkSnapshotSchema]);
354
- const WorkspaceEditOperationSchema = Type.Union([
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>;
@@ -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 = truncateHead(text, {
43
- maxBytes: DEFAULT_MAX_BYTES,
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: visibleText }],
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 { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
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 requestDocumentMethod(
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
- const result = await executePositionRead(dependencies, parameters, context, signal);
1132
- requireReadSuccess(result);
1133
- return createLspToolOutput(
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
- const result = await executeFileRead(dependencies, parameters, context, signal);
1149
- requireReadSuccess(result);
1150
- return createLspToolOutput(
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
- const result = await executeWorkspaceRead(dependencies, parameters, context, signal);
1162
- requireReadSuccess(result);
1163
- return createLspToolOutput(
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
- case "selection_ranges": {
1173
- const result = await executeSelectionRanges(dependencies, parameters, context, signal);
1174
- requireReadSuccess(result);
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
- case "inlay_hints": {
1185
- const result = await executeInlayHints(dependencies, parameters, context, signal);
1186
- requireReadSuccess(result);
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 { createHash, randomUUID } from "node:crypto";
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
- type FileSnapshot =
52
- | { readonly kind: "missing" }
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
- type CreateOperation = {
72
- readonly kind: "create";
73
- readonly named_path: string;
74
- readonly before: FileSnapshot;
75
- after_base64: string;
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 DeleteOperation = {
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
- export interface LspWorkspaceEditPreview {
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, ModifyOperation | CreateOperation>();
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: ModifyOperation = {
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: CreateOperation = {
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[]): LspWorkspaceEditReplayResult {
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, mutablePreview(record));
661
- accepted++;
604
+ this.previews.set(record.preview_id, structuredClone(record));
662
605
  }
663
- return { accepted, rejected };
606
+ return rejected;
664
607
  }
665
608
 
666
609
  /** Revalidate and apply one preview inside every sorted canonical mutation queue. */
@@ -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
- appendPiPostEditDiagnostics,
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 implements PostEditDiagnosticsRunner {
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 runPostEditDiagnostics(
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 appendPiPostEditDiagnostics(
211
- event,
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 = truncateHead(appended.text, {
219
- maxBytes: DEFAULT_MAX_BYTES,
220
- maxLines: DEFAULT_MAX_LINES,
221
- });
222
- if (truncation.truncated) {
223
- const spillPath = await session.sessionFiles.writeResultSpill(appended.text);
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.rejected > 0) {
276
+ if (replay > 0) {
284
277
  context.ui.notify(
285
- `Pi LSP ignored ${replay.rejected} invalid Workspace Edit Preview record${replay.rejected === 1 ? "" : "s"} on the active session branch.`,
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
  }
@@ -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],