pi-shorthand 0.3.2 → 0.4.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
@@ -30,4 +30,12 @@ sudo apt install bubblewrap
30
30
 
31
31
  pi-shorthand gives Pi a programming environment instead of a patch format. This makes multi-file and structural edits possible in one call, but does not guarantee that Pi will find it easier or more reliable than its built-in edit tool. Which works better depends on the model and the task.
32
32
 
33
- Programs edit an isolated snapshot, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back files involved in failed or interrupted edits; changes are applied only if their destination files have not changed. Progress is reported while a program runs, but no run history is written to disk.
33
+ Programs edit an isolated snapshot, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back files involved in failed or interrupted edits; changes are applied only if their destination files have not changed.
34
+
35
+ ## Diagnosing slow calls
36
+
37
+ Calls taking longer than their configured program timeout (two seconds by default) show a muted timing footer. The timeout limits the editing program, not the entire transaction. Live progress also names the active setup or cleanup step.
38
+
39
+ The footer separates parent-observed startup/IPC, runner work, and response/exit overhead. Snapshot detail includes inventory, copy, verification, attempt count, entry count, and logical file bytes. Nested measurements overlap and must not be added to their containing phase totals; logical bytes are not measured disk I/O. Formatting and edit execution include separate subprocess-wait and cleanup measurements.
40
+
41
+ Infrastructure failures retain completed measurements and identify the failed phase. If the runner exits before reporting completion, its execution interval is marked as observed/incomplete. Structured diagnostics are attached to tool result details; source contents and individual filenames are not recorded in diagnostic events.
package/api.d.ts CHANGED
@@ -8,6 +8,7 @@ declare global {
8
8
  const grep: ShorthandGlobals["grep"];
9
9
  const sg: ShorthandGlobals["sg"];
10
10
  const grit: ShorthandGlobals["grit"];
11
+ const ts: ShorthandGlobals["ts"];
11
12
  }
12
13
 
13
14
  export type { RewriteResult } from "./prelude.ts";
package/diagnostics.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export interface DiagnosticSpan {
4
+ name: string;
5
+ startMs: number;
6
+ durationMs?: number;
7
+ failed?: boolean;
8
+ }
9
+
10
+ export interface Diagnostics {
11
+ spans: DiagnosticSpan[];
12
+ counters: Record<string, number>;
13
+ phase?: string;
14
+ failurePhase?: string;
15
+ runnerMs?: number;
16
+ startupMs?: number;
17
+ responseMs?: number;
18
+ wallMs?: number;
19
+ incomplete?: boolean;
20
+ }
21
+
22
+ const context = new AsyncLocalStorage<{
23
+ startedAt: number;
24
+ diagnostics: Diagnostics;
25
+ publish: (diagnostics: Diagnostics) => void;
26
+ phaseSpan?: DiagnosticSpan;
27
+ }>();
28
+
29
+ /** Run-local, bounded metadata only: never record source text, filenames or command output. */
30
+ export async function withDiagnostics<T>(
31
+ operation: () => Promise<T>,
32
+ publish: (diagnostics: Diagnostics) => void,
33
+ ): Promise<{ value: T; diagnostics: Diagnostics }> {
34
+ const state = {
35
+ startedAt: performance.now(),
36
+ diagnostics: { spans: [], counters: {} } as Diagnostics,
37
+ publish: (diagnostics: Diagnostics) => {
38
+ try {
39
+ publish(diagnostics);
40
+ } catch {
41
+ /* Diagnostics must not change the operation's outcome. */
42
+ }
43
+ },
44
+ };
45
+ return context.run(state, async () => {
46
+ state.publish(state.diagnostics);
47
+ try {
48
+ return { value: await operation(), diagnostics: state.diagnostics };
49
+ } catch (error) {
50
+ diagnosticFailure();
51
+ throw error;
52
+ } finally {
53
+ const active = context.getStore()!;
54
+ if (active.phaseSpan)
55
+ active.phaseSpan.durationMs = Math.round(performance.now() - state.startedAt - active.phaseSpan.startMs);
56
+ state.diagnostics.runnerMs = Math.round(performance.now() - state.startedAt);
57
+ state.publish(state.diagnostics);
58
+ }
59
+ });
60
+ }
61
+
62
+ export async function measure<T>(name: string, operation: () => Promise<T>): Promise<T> {
63
+ const state = context.getStore();
64
+ if (!state) return operation();
65
+ const start = performance.now();
66
+ const span: DiagnosticSpan = { name, startMs: Math.round(start - state.startedAt) };
67
+ state.diagnostics.spans.push(span);
68
+ state.publish(state.diagnostics);
69
+ try {
70
+ return await operation();
71
+ } catch (error) {
72
+ span.failed = true;
73
+ throw error;
74
+ } finally {
75
+ span.durationMs = Math.round(performance.now() - start);
76
+ state.publish(state.diagnostics);
77
+ }
78
+ }
79
+
80
+ export function diagnosticCounter(name: string, value: number, add = false) {
81
+ const state = context.getStore();
82
+ if (!state) return;
83
+ state.diagnostics.counters[name] = value + (add ? (state.diagnostics.counters[name] ?? 0) : 0);
84
+ state.publish(state.diagnostics);
85
+ }
86
+
87
+ export function diagnosticPhase(phase: string) {
88
+ const state = context.getStore();
89
+ if (!state) return;
90
+ const now = Math.round(performance.now() - state.startedAt);
91
+ if (state.phaseSpan) state.phaseSpan.durationMs = now - state.phaseSpan.startMs;
92
+ state.phaseSpan = { name: phase, startMs: now };
93
+ state.diagnostics.spans.push(state.phaseSpan);
94
+ state.diagnostics.phase = phase;
95
+ state.publish(state.diagnostics);
96
+ }
97
+
98
+ export function diagnosticFailure() {
99
+ const state = context.getStore();
100
+ if (!state || state.diagnostics.failurePhase) return;
101
+ state.diagnostics.failurePhase = state.diagnostics.phase;
102
+ if (state.phaseSpan) state.phaseSpan.failed = true;
103
+ }
104
+
105
+ /** Spans are nested within runner phases; these figures must not be added to the phase total. */
106
+ const duration = (ms: number) => `${ms}ms`;
107
+
108
+ /** Parent-observed startup includes event delivery; a missing final event means execution is incomplete. */
109
+ export function completeDiagnostics(diagnostics: Diagnostics, wallMs: number, startupMs: number): Diagnostics {
110
+ diagnostics.wallMs = Math.round(wallMs);
111
+ diagnostics.startupMs = Math.round(startupMs);
112
+ if (diagnostics.runnerMs === undefined) {
113
+ diagnostics.incomplete = true;
114
+ diagnostics.runnerMs = Math.max(0, diagnostics.wallMs - diagnostics.startupMs);
115
+ }
116
+ diagnostics.responseMs = Math.max(0, diagnostics.wallMs - diagnostics.startupMs - diagnostics.runnerMs);
117
+ return diagnostics;
118
+ }
119
+
120
+ export function diagnosticLines(diagnostics: Diagnostics): string[] {
121
+ const lines: string[] = [];
122
+ if (diagnostics.wallMs !== undefined) {
123
+ lines.push(
124
+ `wall ${duration(diagnostics.wallMs)} · runner startup/IPC ${duration(diagnostics.startupMs ?? 0)} · runner${diagnostics.incomplete ? " (observed, incomplete)" : ""} ${duration(diagnostics.runnerMs ?? 0)} · response/exit ${duration(diagnostics.responseMs ?? 0)}`,
125
+ );
126
+ }
127
+ if (diagnostics.spans.length) {
128
+ lines.push("phase detail (nested measurements overlap):");
129
+ lines.push(
130
+ ...diagnostics.spans
131
+ .filter((span) => span.durationMs !== 0)
132
+ .map(
133
+ (span) =>
134
+ ` ${span.name}: ${span.durationMs === undefined ? "incomplete" : duration(span.durationMs)}${span.failed ? " (failed)" : ""}`,
135
+ ),
136
+ );
137
+ }
138
+ const counters = Object.entries(diagnostics.counters);
139
+ if (counters.length) lines.push(counters.map(([name, value]) => `${name}: ${value}`).join(" · "));
140
+ if (diagnostics.failurePhase) lines.push(`failed during: ${diagnostics.failurePhase}`);
141
+ return lines;
142
+ }
package/display.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { getLanguageFromPath, highlightCode, keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
11
- import type { FileChange, RunResult } from "./runner.ts";
11
+ import type { FileChange, RunResult, RunTimings } from "./runner.ts";
12
+ import { diagnosticLines } from "./diagnostics.ts";
12
13
 
13
14
  type ToolBackground = "toolSuccessBg" | "toolErrorBg";
14
15
 
@@ -21,7 +22,7 @@ const LISTED_FILES = 8; // …showing this many, then "and N more files"
21
22
  const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
22
23
 
23
24
  export function callLine(args: { title?: string; timeout?: number; rollback?: string }, theme: Theme): string {
24
- const settings = [args.rollback === "all" && "rollback all", args.timeout && `timeout ${args.timeout}s`];
25
+ const settings = [args.rollback === "all" && "rollback all", args.timeout && `program timeout ${args.timeout}s`];
25
26
  const suffix = settings.filter(Boolean).join(", ");
26
27
  return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
27
28
  }
@@ -83,14 +84,51 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
83
84
  if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
84
85
  if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
85
86
  for (const section of sections) lines.push("", ...section);
87
+ const timing = timingBreakdown(run);
88
+ if (timing) lines.push("", theme.fg("muted", `timing: ${timing}`));
89
+ if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run.timeoutMs || run.exitCode !== 0)) {
90
+ lines.push(...diagnosticLines(run.diagnostics).map((line) => theme.fg("muted", line)));
91
+ }
86
92
  return lines;
87
93
  }
88
94
 
95
+ const TIMING_LABELS: Record<keyof RunTimings, string> = {
96
+ resolveRepositoryMs: "repository",
97
+ waitForLockMs: "lock wait",
98
+ workspaceSetupMs: "workspace setup",
99
+ programMs: "program",
100
+ scanChangesMs: "change scan",
101
+ formatMs: "formatter",
102
+ workspaceCloseMs: "workspace cleanup",
103
+ checkConflictsMs: "conflict check",
104
+ applyMs: "apply",
105
+ renderDiffMs: "diff",
106
+ unattributedMs: "other",
107
+ };
108
+
109
+ /** Explain calls exceeding the configured program budget, even when no individual phase does. */
110
+ export function timingBreakdown(run: RunResult): string | undefined {
111
+ if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < run.timeoutMs) return undefined;
112
+ const significant = Object.entries(run.timings)
113
+ .map(([phase, milliseconds]) => ({
114
+ label: TIMING_LABELS[phase as keyof RunTimings],
115
+ milliseconds,
116
+ }))
117
+ .filter(({ milliseconds }) => milliseconds > 0)
118
+ .toSorted((a, b) => b.milliseconds - a.milliseconds);
119
+ if (significant.length === 0) return undefined;
120
+ return significant.map(({ label, milliseconds }) => `${label} ${formatDuration(milliseconds)}`).join(" · ");
121
+ }
122
+
123
+ function formatDuration(milliseconds: number): string {
124
+ return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
125
+ }
126
+
89
127
  /** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
90
128
  function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
91
129
  const muted = (text: string) => theme.fg("muted", text);
92
130
  const took = muted(` · ${(run.durationMs / 1000).toFixed(1)}s`);
93
- const failure = run.timedOut ? `Timed out after ${run.timeoutMs / 1000}s` : "Failed";
131
+ const failure = run.timedOut ? `Program timed out after ${run.timeoutMs / 1000}s` : "Failed";
94
132
  const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
95
133
 
96
134
  if (run.conflicts.length > 0) {
package/format.ts CHANGED
@@ -4,6 +4,14 @@ import { dirname, extname, join, relative, resolve } from "node:path";
4
4
 
5
5
  type Command = { name: string; executable: string; args: string[]; cwd: string };
6
6
 
7
+ /** A conservative filter: configuration and executable discovery still happen inside the sandbox. */
8
+ export function supportsFormatting(file: string): boolean {
9
+ return (
10
+ /\.(?:[cm]?[jt]sx?|jsonc?|css|scss|less|html|vue|svelte|mdx?|ya?ml|graphql)$/i.test(file) ||
11
+ [".py", ".pyi", ".go", ".rs"].includes(extname(file))
12
+ );
13
+ }
14
+
7
15
  function text(file: string): string {
8
16
  try {
9
17
  return readFileSync(file, "utf8");
package/index.ts CHANGED
@@ -12,8 +12,25 @@ import { StringEnum } from "@earendil-works/pi-ai";
12
12
  import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
13
13
  import { Text } from "@earendil-works/pi-tui";
14
14
  import { Type } from "typebox";
15
- import { callLine, countLines, fileMetadataSummary, resultLines, unstructuredResultText } from "./display.ts";
15
+ import {
16
+ callLine,
17
+ countLines,
18
+ fileMetadataSummary,
19
+ resultLines,
20
+ timingBreakdown,
21
+ unstructuredResultText,
22
+ } from "./display.ts";
16
23
  import type { FileChange, RunOptions, RunResult } from "./runner.ts";
24
+ import { type Diagnostics, completeDiagnostics, diagnosticLines } from "./diagnostics.ts";
25
+
26
+ class RunnerError extends Error {
27
+ constructor(
28
+ message: string,
29
+ readonly diagnostics: Diagnostics,
30
+ ) {
31
+ super(message);
32
+ }
33
+ }
17
34
 
18
35
  // Runs typically take well under a second. Longer transformations can request more time.
19
36
  const DEFAULT_TIMEOUT_SECONDS = 2;
@@ -26,6 +43,8 @@ Common operations:
26
43
  - sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
27
44
  - sg.one(pattern, files?) selects exactly one match; sg.find returns an array. sg.rewrite also accepts a selected match or array without a file scope.
28
45
  - A rewrite callback receives a match and returns text, a native node.replace(text) edit, or null to skip. Return native edits to apply them. Pass selected arrays together for independent edits; select again after changing their file.
46
+ - await ts.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
47
+ - await ts.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
29
48
 
30
49
  See the shorthand skill for common writes. For extraction, complex rewrites or other languages, read its advanced-refactors.md guide. The default timeout is two seconds; request more for longer programs.`;
31
50
 
@@ -72,16 +91,28 @@ export default function (pi: ExtensionAPI) {
72
91
  });
73
92
  }, 500);
74
93
 
75
- const result = await runWithBun(
76
- {
77
- cwd: ctx.cwd,
78
- program: params.program,
79
- timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
80
- rollback: params.rollback ?? "file",
81
- },
82
- signal,
83
- (step) => (latest = step),
84
- ).finally(() => clearInterval(progress));
94
+ let result: RunResult;
95
+ try {
96
+ result = await runWithBun(
97
+ {
98
+ cwd: ctx.cwd,
99
+ program: params.program,
100
+ timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
101
+ rollback: params.rollback ?? "file",
102
+ },
103
+ signal,
104
+ (step) => (latest = step),
105
+ );
106
+ } catch (error) {
107
+ if (!(error instanceof RunnerError)) throw error;
108
+ return {
109
+ isError: true,
110
+ content: [{ type: "text" as const, text: [error.message, ...diagnosticLines(error.diagnostics)].join("\n") }],
111
+ details: { infrastructureError: error.message, diagnostics: error.diagnostics },
112
+ };
113
+ } finally {
114
+ clearInterval(progress);
115
+ }
85
116
  return {
86
117
  content: [{ type: "text", text: textForModel(result, toolCallId) }],
87
118
  details: result,
@@ -107,6 +138,18 @@ export function renderCodeResult(
107
138
  const progress = (result.details as { progress?: string } | undefined)?.progress;
108
139
  return new Text(theme.fg("muted", progress ? `running… ${progress}` : "running…"), 0, 0);
109
140
  }
141
+ if (result.details && typeof result.details === "object" && "infrastructureError" in result.details) {
142
+ const failure = result.details as { infrastructureError: string; diagnostics: Diagnostics };
143
+ return new Text(
144
+ [
145
+ theme.fg("error", failure.infrastructureError),
146
+ "",
147
+ ...diagnosticLines(failure.diagnostics).map((line) => theme.fg("muted", line)),
148
+ ].join("\n"),
149
+ 0,
150
+ 0,
151
+ );
152
+ }
110
153
  const run = result.details as RunResult | undefined;
111
154
  if (!run) return new Text(theme.fg("error", unstructuredResultText(result.content)), 0, 0);
112
155
  return new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
@@ -122,6 +165,13 @@ export function runWithBun(
122
165
  signal?: AbortSignal,
123
166
  onProgress?: (step: string) => void,
124
167
  ): Promise<RunResult> {
168
+ const startedAt = performance.now();
169
+ let firstEventAt: number | undefined;
170
+ let diagnostics: Diagnostics = { spans: [], counters: {} };
171
+ const finishDiagnostics = () => {
172
+ const now = performance.now();
173
+ return completeDiagnostics(diagnostics, now - startedAt, (firstEventAt ?? now) - startedAt);
174
+ };
125
175
  return new Promise((resolve, reject) => {
126
176
  if (signal?.aborted) return reject(new Error("Aborted"));
127
177
  const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], {
@@ -146,14 +196,23 @@ export function runWithBun(
146
196
  progressBuffer = lines.pop() ?? "";
147
197
  for (const line of lines) {
148
198
  try {
149
- const event = JSON.parse(line) as { step?: unknown };
199
+ const event = JSON.parse(line) as { step?: unknown; diagnostics?: Diagnostics };
200
+ firstEventAt ??= performance.now();
201
+ if (event.diagnostics) {
202
+ diagnostics = event.diagnostics;
203
+ const active = diagnostics.spans.findLast((span) => span.durationMs === undefined);
204
+ if (active) onProgress?.(active.name);
205
+ }
150
206
  if (typeof event.step === "string") onProgress?.(event.step);
151
207
  } catch {
152
208
  // Progress is advisory; malformed events do not affect the run.
153
209
  }
154
210
  }
155
211
  });
156
- runner.on("error", reject);
212
+ runner.on("error", (error) => {
213
+ signal?.removeEventListener("abort", stop);
214
+ reject(new RunnerError(error.message, finishDiagnostics()));
215
+ });
157
216
  runner.on("close", (code) => {
158
217
  signal?.removeEventListener("abort", stop);
159
218
  try {
@@ -162,10 +221,24 @@ export function runWithBun(
162
221
  // nothing left
163
222
  }
164
223
  if (code === 0) {
165
- const result: RunResult = JSON.parse(stdout);
224
+ let result: RunResult;
225
+ try {
226
+ result = JSON.parse(stdout);
227
+ } catch {
228
+ reject(new RunnerError("Runner returned invalid JSON", finishDiagnostics()));
229
+ return;
230
+ }
231
+ diagnostics = result.diagnostics ?? diagnostics;
232
+ result.diagnostics = finishDiagnostics();
166
233
  if (!signal?.aborted || result.applied.length > 0 || result.cleanupWarnings.length > 0) resolve(result);
167
234
  else reject(new Error("Aborted"));
168
- } else reject(new Error(stderr.trim() || (signal?.aborted ? "Aborted" : `runner exited with ${code}`)));
235
+ } else
236
+ reject(
237
+ new RunnerError(
238
+ stderr.trim() || (signal?.aborted ? "Aborted" : `runner exited with ${code}`),
239
+ finishDiagnostics(),
240
+ ),
241
+ );
169
242
  });
170
243
 
171
244
  runner.stdin.end(JSON.stringify(options));
@@ -205,6 +278,8 @@ function fileLine(change: FileChange): string {
205
278
 
206
279
  function textForModel(run: RunResult, toolCallId: string): string {
207
280
  const lines = [summaryLine(run)];
281
+ const timing = timingBreakdown(run);
282
+ if (timing) lines.push(`Timing: ${timing}`);
208
283
 
209
284
  if (run.applied.length === 0 && run.changes.length > 0) {
210
285
  lines.push("The real workspace is unchanged. Below is the candidate diff.");
@@ -232,6 +307,8 @@ function textForModel(run: RunResult, toolCallId: string): string {
232
307
  // On failure the error goes last, where it's easiest to find; on success, the diff does.
233
308
  if (run.exitCode === 0 && run.conflicts.length === 0) lines.push(...output, ...diff);
234
309
  else lines.push(...diff, ...output);
310
+ if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run.timeoutMs || run.exitCode !== 0))
311
+ lines.push("", ...diagnosticLines(run.diagnostics));
235
312
  return lines.join("\n");
236
313
  }
237
314
 
package/lsp-client.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { readdirSync, realpathSync, statSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, resolve as resolvePath } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import {
7
+ createMessageConnection,
8
+ StreamMessageReader,
9
+ StreamMessageWriter,
10
+ type MessageConnection,
11
+ } from "vscode-jsonrpc/node";
12
+
13
+ interface TypeScriptServer {
14
+ root: string;
15
+ process: ChildProcessWithoutNullStreams;
16
+ connection: MessageConnection;
17
+ files: Map<string, string>;
18
+ }
19
+
20
+ let current: TypeScriptServer | undefined;
21
+ let operations = Promise.resolve();
22
+ process.once("exit", disposeCurrent);
23
+
24
+ export async function withTypeScriptServer<T>(
25
+ root: string,
26
+ use: (connection: MessageConnection) => Promise<T>,
27
+ ): Promise<T> {
28
+ const result = operations.then(async () => {
29
+ const projectRoot = realpathSync(root);
30
+ if (current?.root === projectRoot) {
31
+ if (!sameFiles(current.files, projectFiles(projectRoot))) disposeCurrent();
32
+ } else {
33
+ disposeCurrent();
34
+ }
35
+ if (!current) {
36
+ current = await startTypeScriptServer(projectRoot);
37
+ }
38
+ const server = current;
39
+ setReferenced(server, true);
40
+ try {
41
+ return await use(server.connection);
42
+ } finally {
43
+ if (current === server) setReferenced(server, false);
44
+ }
45
+ });
46
+ operations = result.then(
47
+ () => undefined,
48
+ () => undefined,
49
+ );
50
+ return result;
51
+ }
52
+
53
+ /** Keep a reused server current; a failed notification only forfeits reuse of that server. */
54
+ export async function notifyTypeScriptServer(
55
+ connection: MessageConnection,
56
+ method: string,
57
+ params: unknown,
58
+ ): Promise<void> {
59
+ try {
60
+ await connection.sendNotification(method, params);
61
+ } catch {
62
+ if (current?.connection === connection) disposeCurrent();
63
+ }
64
+ }
65
+
66
+ export function recordTypeScriptFiles(connection: MessageConnection, files: string[]): void {
67
+ if (current?.connection !== connection) return;
68
+ for (const file of files) {
69
+ const value = fingerprint(file);
70
+ if (value) current.files.set(file, value);
71
+ else current.files.delete(file);
72
+ }
73
+ }
74
+
75
+ async function startTypeScriptServer(root: string): Promise<TypeScriptServer> {
76
+ const server = spawn(typeScriptExecutable(), ["--lsp", "--stdio"], { cwd: root, env: process.env });
77
+ await new Promise<void>((ready, reject) => {
78
+ server.once("spawn", ready);
79
+ server.once("error", reject);
80
+ });
81
+ const connection = createMessageConnection(
82
+ new StreamMessageReader(server.stdout),
83
+ new StreamMessageWriter(server.stdin),
84
+ );
85
+ connection.listen();
86
+ try {
87
+ await connection.sendRequest("initialize", {
88
+ processId: process.pid,
89
+ rootUri: pathToFileURL(root).href,
90
+ capabilities: {
91
+ workspace: {
92
+ workspaceEdit: { documentChanges: true },
93
+ fileOperations: { didRename: true, willRename: true },
94
+ },
95
+ textDocument: { rename: { prepareSupport: true } },
96
+ },
97
+ });
98
+ await connection.sendNotification("initialized", {});
99
+ } catch (error) {
100
+ connection.dispose();
101
+ if (server.exitCode === null) server.kill("SIGKILL");
102
+ throw error;
103
+ }
104
+ server.stderr.resume();
105
+ const started = { root, process: server, connection, files: projectFiles(root) };
106
+ server.once("exit", () => {
107
+ if (current?.process !== server) return;
108
+ connection.dispose();
109
+ current = undefined;
110
+ });
111
+ setReferenced(started, false);
112
+ return started;
113
+ }
114
+
115
+ function sameFiles(left: Map<string, string>, right: Map<string, string>): boolean {
116
+ return left.size === right.size && [...left].every(([file, signature]) => right.get(file) === signature);
117
+ }
118
+
119
+ function projectFiles(root: string): Map<string, string> {
120
+ // The runner only applies Git-visible files, so ignored build output cannot make reuse stale.
121
+ const listed = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
122
+ cwd: root,
123
+ encoding: "utf8",
124
+ });
125
+ if (listed.status === 0) {
126
+ return new Map(
127
+ listed.stdout
128
+ .split("\0")
129
+ .filter((file) => file && /\.(?:[cm]?[jt]sx?|json)$/.test(file))
130
+ .flatMap((file) => {
131
+ const absolute = resolvePath(root, file);
132
+ const value = fingerprint(absolute);
133
+ return value ? [[absolute, value] as const] : [];
134
+ }),
135
+ );
136
+ }
137
+
138
+ const files = new Map<string, string>();
139
+ const visit = (directory: string) => {
140
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
141
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
142
+ const file = resolvePath(directory, entry.name);
143
+ if (entry.isDirectory()) visit(file);
144
+ else if (entry.isFile() && /\.(?:[cm]?[jt]sx?|json)$/.test(entry.name)) {
145
+ files.set(file, fingerprint(file)!);
146
+ }
147
+ }
148
+ };
149
+ visit(root);
150
+ return files;
151
+ }
152
+
153
+ function fingerprint(file: string): string | undefined {
154
+ const stats = statSync(file, { bigint: true, throwIfNoEntry: false });
155
+ return stats?.isFile() ? `${stats.mtimeNs}:${stats.size}` : undefined;
156
+ }
157
+
158
+ function setReferenced(server: TypeScriptServer, referenced: boolean): void {
159
+ const method = referenced ? "ref" : "unref";
160
+ server.process[method]();
161
+ for (const stream of [server.process.stdin, server.process.stdout, server.process.stderr])
162
+ (stream as unknown as Record<typeof method, () => void>)[method]?.();
163
+ process.removeListener("beforeExit", disposeCurrent);
164
+ if (!referenced) process.once("beforeExit", disposeCurrent);
165
+ }
166
+
167
+ function disposeCurrent(): void {
168
+ if (!current) return;
169
+ const { connection, process: server } = current;
170
+ current = undefined;
171
+ connection.dispose();
172
+ if (server.exitCode === null) server.kill("SIGKILL");
173
+ server.unref();
174
+ }
175
+
176
+ function typeScriptExecutable(): string {
177
+ const require = createRequire(import.meta.url);
178
+ const typescriptPackage = require.resolve("typescript/package.json");
179
+ const requireTypeScriptDependency = createRequire(typescriptPackage);
180
+ const nativePackage = `@typescript/typescript-${process.platform}-${process.arch}/package.json`;
181
+ const packageFile = requireTypeScriptDependency.resolve(nativePackage);
182
+ return resolvePath(dirname(packageFile), "lib", process.platform === "win32" ? "tsc.exe" : "tsc");
183
+ }