pi-lsp-adapter 0.1.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.
@@ -0,0 +1,58 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function getManagedLspRoot(): string {
5
+ return join(homedir(), ".pi", "agent", "lsp");
6
+ }
7
+
8
+ export function getRegistryDir(): string {
9
+ return join(getManagedLspRoot(), "registry");
10
+ }
11
+
12
+ export function getPackagesDir(): string {
13
+ return join(getManagedLspRoot(), "packages");
14
+ }
15
+
16
+ export function getBinDir(): string {
17
+ return join(getManagedLspRoot(), "bin");
18
+ }
19
+
20
+ export function getCacheDir(): string {
21
+ return join(getManagedLspRoot(), "cache");
22
+ }
23
+
24
+ export function getLogsDir(): string {
25
+ return join(getManagedLspRoot(), "logs");
26
+ }
27
+
28
+ export function getLockfilePath(): string {
29
+ return join(getManagedLspRoot(), "lsp.lock.json");
30
+ }
31
+
32
+ export function getWorkspacesDir(): string {
33
+ return join(getManagedLspRoot(), "workspaces");
34
+ }
35
+
36
+ export function getProcessRegistryDir(): string {
37
+ return join(getManagedLspRoot(), "pids");
38
+ }
39
+
40
+ export function getProcessRegistryPath(ownerId: string): string {
41
+ return join(getProcessRegistryDir(), `${safePathSegment(ownerId)}.json`);
42
+ }
43
+
44
+ function safePathSegment(value: string): string {
45
+ return value.replace(/[^A-Za-z0-9._-]/gu, "_");
46
+ }
47
+
48
+ export function getTrustStorePath(): string {
49
+ return join(getManagedLspRoot(), "trust.json");
50
+ }
51
+
52
+ export function getUserConfigPath(): string {
53
+ return join(homedir(), ".pi", "agents", "lsp.json");
54
+ }
55
+
56
+ export function getProjectConfigPath(projectRoot: string): string {
57
+ return join(projectRoot, ".pi", "lsp.json");
58
+ }
@@ -0,0 +1,85 @@
1
+ import { mkdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { isNodeError } from "../util/helpers.js";
4
+ import { getTrustStorePath } from "./paths.js";
5
+
6
+ export interface TrustStore {
7
+ trustedProjects: string[];
8
+ }
9
+
10
+ export interface TrustStoreOptions {
11
+ trustStorePath?: string;
12
+ }
13
+
14
+ export async function loadTrustStore(options: TrustStoreOptions = {}): Promise<TrustStore> {
15
+ const trustStorePath = options.trustStorePath ?? getTrustStorePath();
16
+
17
+ try {
18
+ const raw = await readFile(trustStorePath, "utf8");
19
+ const parsed: unknown = JSON.parse(raw);
20
+ if (!isTrustStore(parsed)) {
21
+ return { trustedProjects: [] };
22
+ }
23
+
24
+ const trustedProjects = [...new Set(parsed.trustedProjects.filter((entry) => entry.length > 0))].sort();
25
+ return { trustedProjects };
26
+ } catch (error) {
27
+ if (isNodeError(error) && error.code === "ENOENT") {
28
+ return { trustedProjects: [] };
29
+ }
30
+ return { trustedProjects: [] };
31
+ }
32
+ }
33
+
34
+ export async function isProjectTrusted(rootDir: string, options: TrustStoreOptions = {}): Promise<boolean> {
35
+ const canonicalRoot = await canonicalizePath(rootDir);
36
+ const store = await loadTrustStore(options);
37
+ return store.trustedProjects.includes(canonicalRoot);
38
+ }
39
+
40
+ export async function trustProject(rootDir: string, options: TrustStoreOptions = {}): Promise<TrustStore> {
41
+ const canonicalRoot = await canonicalizePath(rootDir);
42
+ const store = await loadTrustStore(options);
43
+ if (!store.trustedProjects.includes(canonicalRoot)) {
44
+ store.trustedProjects.push(canonicalRoot);
45
+ store.trustedProjects.sort();
46
+ }
47
+
48
+ await writeTrustStore(store, options);
49
+ return store;
50
+ }
51
+
52
+ export async function untrustProject(rootDir: string, options: TrustStoreOptions = {}): Promise<TrustStore> {
53
+ const canonicalRoot = await canonicalizePath(rootDir);
54
+ const store = await loadTrustStore(options);
55
+ store.trustedProjects = store.trustedProjects.filter((entry) => entry !== canonicalRoot);
56
+ await writeTrustStore(store, options);
57
+ return store;
58
+ }
59
+
60
+ async function writeTrustStore(store: TrustStore, options: TrustStoreOptions = {}): Promise<void> {
61
+ const trustStorePath = options.trustStorePath ?? getTrustStorePath();
62
+ await mkdir(dirname(trustStorePath), { recursive: true });
63
+
64
+ const tempPath = `${trustStorePath}.${process.pid}.${Date.now()}.tmp`;
65
+ const content = `${JSON.stringify({ trustedProjects: [...new Set(store.trustedProjects)].sort() }, null, 2)}\n`;
66
+ await writeFile(tempPath, content, "utf8");
67
+ await rename(tempPath, trustStorePath);
68
+ }
69
+
70
+ async function canonicalizePath(path: string): Promise<string> {
71
+ try {
72
+ return await realpath(path);
73
+ } catch {
74
+ return resolve(path);
75
+ }
76
+ }
77
+
78
+ function isTrustStore(value: unknown): value is TrustStore {
79
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
80
+ return false;
81
+ }
82
+
83
+ const trustedProjects = (value as { trustedProjects?: unknown }).trustedProjects;
84
+ return Array.isArray(trustedProjects) && trustedProjects.every((entry) => typeof entry === "string");
85
+ }
@@ -0,0 +1,86 @@
1
+ import { BUILTIN_FILETYPE_RULES } from "../registry/builtin.js";
2
+ import type { FiletypeRules } from "../registry/schema.js";
3
+
4
+ export interface FiletypeOverrides extends FiletypeRules {
5
+ filenames?: Record<string, string>;
6
+ }
7
+
8
+ export interface DetectFiletypeInput {
9
+ path: string;
10
+ content?: string;
11
+ overrides?: FiletypeOverrides;
12
+ }
13
+
14
+ export function detectFiletype(input: DetectFiletypeInput): string | undefined {
15
+ const basenameKey = getBasename(normalizePath(input.path)).toLowerCase();
16
+ const extension = getExtension(basenameKey);
17
+
18
+ return (
19
+ detectFromOverrides(input.overrides, basenameKey, extension) ??
20
+ lookupCaseInsensitive(BUILTIN_FILETYPE_RULES.exactFilenames, basenameKey) ??
21
+ lookupCaseInsensitive(BUILTIN_FILETYPE_RULES.extensions, extension) ??
22
+ detectFromContent(input.content)
23
+ );
24
+ }
25
+
26
+ function detectFromOverrides(
27
+ overrides: FiletypeOverrides | undefined,
28
+ basenameKey: string,
29
+ extension: string,
30
+ ): string | undefined {
31
+ if (!overrides) return undefined;
32
+
33
+ const exactFilenames = { ...overrides.filenames, ...overrides.exactFilenames };
34
+ return lookupCaseInsensitive(exactFilenames, basenameKey) ?? lookupCaseInsensitive(overrides.extensions, extension);
35
+ }
36
+
37
+ function detectFromContent(content: string | undefined): string | undefined {
38
+ if (!content) return undefined;
39
+
40
+ const sample = content.trimStart();
41
+ if (!sample) return undefined;
42
+
43
+ if (sample.startsWith("{") || sample.startsWith("[")) {
44
+ return "json";
45
+ }
46
+
47
+ if (sample.startsWith("---\n") || sample.startsWith("---\r\n")) {
48
+ return "yaml";
49
+ }
50
+
51
+ if (sample.startsWith("#!/")) {
52
+ const firstLine = sample.split(/\r?\n/, 1)[0] ?? "";
53
+ if (/\bpython(?:\d+(?:\.\d+)*)?\b/.test(firstLine)) return "python";
54
+ if (/\bnode\b/.test(firstLine)) return "javascript";
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ function normalizePath(path: string): string {
61
+ const withoutAt = path.startsWith("@") ? path.slice(1) : path;
62
+ return withoutAt.replace(/\\/g, "/");
63
+ }
64
+
65
+ function getBasename(path: string): string {
66
+ const index = path.lastIndexOf("/");
67
+ return index === -1 ? path : path.slice(index + 1);
68
+ }
69
+
70
+ function getExtension(basename: string): string {
71
+ const index = basename.lastIndexOf(".");
72
+ if (index <= 0) return "";
73
+ return basename.slice(index);
74
+ }
75
+
76
+ function lookupCaseInsensitive(values: Record<string, string> | undefined, key: string): string | undefined {
77
+ if (!values) return undefined;
78
+
79
+ for (const [entryKey, filetype] of Object.entries(values)) {
80
+ if (entryKey.toLowerCase() === key) {
81
+ return filetype;
82
+ }
83
+ }
84
+
85
+ return undefined;
86
+ }
@@ -0,0 +1,32 @@
1
+ import { dirname, join, parse, resolve } from "node:path";
2
+ import { pathExists } from "../util/helpers.js";
3
+
4
+ export interface RootDetectionResult {
5
+ rootDir: string;
6
+ marker: string;
7
+ markerPath: string;
8
+ }
9
+
10
+ export async function detectRoot(filePath: string, rootMarkers: string[]): Promise<RootDetectionResult | undefined> {
11
+ let currentDir = dirname(resolve(filePath));
12
+ const filesystemRoot = parse(currentDir).root;
13
+
14
+ while (true) {
15
+ for (const marker of rootMarkers) {
16
+ const markerPath = join(currentDir, ...marker.split(/[\\/]+/));
17
+ if (await pathExists(markerPath)) {
18
+ return {
19
+ rootDir: currentDir,
20
+ marker,
21
+ markerPath,
22
+ };
23
+ }
24
+ }
25
+
26
+ if (currentDir === filesystemRoot) {
27
+ return undefined;
28
+ }
29
+
30
+ currentDir = dirname(currentDir);
31
+ }
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { messageFromError } from "./util/helpers.js";
4
+ import { loadLspConfig } from "./config/loadConfig.js";
5
+ import { LspInstallManager } from "./install/manager.js";
6
+ import { ensureManagedLspRoot } from "./install/lockfile.js";
7
+ import { LspProcessRegistry } from "./lsp/processRegistry.js";
8
+ import { LspRuntimeManager } from "./lsp/runtimeManager.js";
9
+ import { registerLspCommand } from "./commands/registerCommands.js";
10
+ import { registerLspTools } from "./tools/registerLspTools.js";
11
+ import { registerLspWarmup } from "./tools/registerLspWarmup.js";
12
+ import { LspResultCache } from "./tools/resultCache.js";
13
+ import { setLspStatusLine } from "./statusLine.js";
14
+ import type { LspExtensionState } from "./state.js";
15
+
16
+ export default function piAgentLspExtension(pi: ExtensionAPI): void {
17
+ let state: LspExtensionState | null = null;
18
+ let generation = 0;
19
+
20
+ registerLspCommand(pi, () => state);
21
+ registerLspTools(pi, () => state);
22
+ registerLspWarmup(pi, () => state);
23
+
24
+ pi.on("before_agent_start", (event) => {
25
+ if (!state) return;
26
+ return {
27
+ systemPrompt: `${event.systemPrompt}\n\nLSP integration: read-only LSP tools are available for diagnostics, hover/type info, definitions, references, document symbols, and workspace symbol search. Prefer these tools when semantic code understanding would reduce guesswork; they use 1-based line/column inputs and do not mutate files. For hover, definition, and references, place the column on the identifier token itself. When an LSP result says "More available" and includes a resultId, use lsp_more only if you need the next sequential page; re-run the original LSP query if the resultId is missing or expired.`,
28
+ };
29
+ });
30
+
31
+ pi.on("session_start", async (_event, ctx) => {
32
+ const currentGeneration = ++generation;
33
+ const previousState = state;
34
+ state = null;
35
+
36
+ await shutdownState(previousState, ctx, "session_restart");
37
+ if (currentGeneration !== generation) return;
38
+
39
+ const ownerId = createOwnerId();
40
+ try {
41
+ await ensureManagedLspRoot();
42
+ const config = await loadLspConfig({ cwd: ctx.cwd, projectRoot: ctx.cwd });
43
+ const processRegistry = new LspProcessRegistry({ ownerId });
44
+ const recovery = await processRegistry.cleanupStaleProcesses();
45
+ const installManager = new LspInstallManager({
46
+ catalog: config.catalog,
47
+ installMode: config.installMode,
48
+ confirmer: ctx.hasUI
49
+ ? async ({ server, command }) => ctx.ui.confirm(`Install ${server.displayName}?`, `Run ${command}?`)
50
+ : undefined,
51
+ });
52
+ const runtimeManager = new LspRuntimeManager({
53
+ cwd: ctx.cwd,
54
+ ownerId,
55
+ config,
56
+ installManager,
57
+ processRegistry,
58
+ });
59
+ const resultCache = new LspResultCache();
60
+
61
+ const nextState: LspExtensionState = {
62
+ ownerId,
63
+ cwd: ctx.cwd,
64
+ config,
65
+ installManager,
66
+ processRegistry,
67
+ runtimeManager,
68
+ resultCache,
69
+ lastRecovery: {
70
+ terminated: recovery.terminated.length,
71
+ removed: recovery.removed.length,
72
+ kept: recovery.kept.length,
73
+ },
74
+ };
75
+
76
+ if (currentGeneration !== generation) {
77
+ await shutdownState(nextState, ctx, "stale_session_start");
78
+ return;
79
+ }
80
+
81
+ state = nextState;
82
+ setLspStatusLine(ctx, nextState);
83
+ notifyStartup(ctx, nextState);
84
+ } catch (error) {
85
+ ctx.ui.setStatus("lsp", ctx.ui.theme.fg("error", "LSP: failed"));
86
+ if (ctx.hasUI) ctx.ui.notify(`LSP initialization failed: ${messageFromError(error)}`, "error");
87
+ }
88
+ });
89
+
90
+ pi.on("session_shutdown", async (_event, ctx) => {
91
+ ++generation;
92
+ const currentState = state;
93
+ state = null;
94
+ await shutdownState(currentState, ctx, "session_shutdown");
95
+ });
96
+ }
97
+
98
+ async function shutdownState(
99
+ state: LspExtensionState | null,
100
+ ctx: ExtensionContext,
101
+ _reason: "session_restart" | "session_shutdown" | "stale_session_start",
102
+ ): Promise<void> {
103
+ if (!state) return;
104
+
105
+ try {
106
+ state.resultCache.clear();
107
+ await state.runtimeManager.shutdown();
108
+ const result = await state.processRegistry.terminateOwnedProcesses();
109
+ if (ctx.hasUI && result.terminated.length > 0) {
110
+ ctx.ui.notify(`LSP: stopped ${result.terminated.length} process(es).`, "info");
111
+ }
112
+ } catch (error) {
113
+ if (ctx.hasUI) ctx.ui.notify(`LSP shutdown cleanup failed: ${messageFromError(error)}`, "error");
114
+ } finally {
115
+ ctx.ui.setStatus("lsp", undefined);
116
+ }
117
+ }
118
+
119
+ function notifyStartup(ctx: ExtensionContext, state: LspExtensionState): void {
120
+ if (!ctx.hasUI) return;
121
+ const recovery = state.lastRecovery;
122
+ if (recovery && recovery.terminated + recovery.removed > 0) {
123
+ ctx.ui.notify(
124
+ `LSP recovered ${recovery.terminated + recovery.removed} stale pid entr${recovery.terminated + recovery.removed === 1 ? "y" : "ies"}.`,
125
+ "info",
126
+ );
127
+ }
128
+ }
129
+
130
+ function createOwnerId(): string {
131
+ return `pi-lsp-${process.pid}-${randomUUID()}`;
132
+ }