@dreki-gg/pi-lsp 0.2.1 → 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.
@@ -1,21 +1,29 @@
1
1
  /**
2
2
  * LSP server configuration loader.
3
3
  *
4
- * Purely config-driven — no built-in servers. Users define all servers
5
- * in their config files:
4
+ * Purely config-driven — no built-in servers and no language enabled by
5
+ * default. Users define every server in their config files:
6
6
  *
7
7
  * ~/.pi/agent/extensions/lsp/config.json (global defaults)
8
8
  * .pi/lsp.json (project overrides)
9
9
  *
10
10
  * Project config merges on top of global. `disabled: true` disables a server.
11
11
  * `lsp: false` disables all LSP functionality.
12
+ *
13
+ * IO and command resolution are expressed as Effect programs against the
14
+ * FileSystem / CommandResolver services. The `*Effect` functions are the real
15
+ * implementations; the Promise-returning wrappers provide the live services and
16
+ * exist for the imperative call sites (extension entry, commands, tests).
12
17
  */
13
18
 
14
- import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
15
- import { execSync } from 'node:child_process';
16
- import { dirname, join } from 'node:path';
19
+ import { Effect } from 'effect';
20
+ import { join } from 'node:path';
17
21
  import { homedir } from 'node:os';
18
22
 
23
+ import { CommandResolver } from './effects/command';
24
+ import { FileSystem } from './effects/filesystem';
25
+ import { makeRuntimeLayer } from './effects/runtime';
26
+ import type { ConfigWriteError } from './errors';
19
27
  import type { LspConfigFile, LspServerUserConfig, ResolvedServerConfig } from './types';
20
28
 
21
29
  // ── Paths ───────────────────────────────────────────────────────────────────
@@ -29,148 +37,162 @@ function projectConfigPath(cwd: string): string {
29
37
  return join(cwd, '.pi', 'lsp.json');
30
38
  }
31
39
 
32
- async function fileExists(path: string): Promise<boolean> {
33
- try {
34
- await access(path);
35
- return true;
36
- } catch {
37
- return false;
38
- }
39
- }
40
-
40
+ /**
41
+ * Starter template scaffolded on first run. Every server is `disabled` so the
42
+ * extension never auto-starts a language server the user did not opt into —
43
+ * TypeScript is just one example among several, not a default.
44
+ */
41
45
  const STARTER_CONFIG = `{
42
46
  "lsp": {
43
47
  "typescript": {
44
48
  "command": ["typescript-language-server", "--stdio"],
45
- "extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]
49
+ "extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
50
+ "disabled": true
51
+ },
52
+ "pyright": {
53
+ "command": ["pyright-langserver", "--stdio"],
54
+ "extensions": [".py"],
55
+ "disabled": true
56
+ },
57
+ "rust": {
58
+ "command": ["rust-analyzer"],
59
+ "extensions": [".rs"],
60
+ "disabled": true
61
+ },
62
+ "gopls": {
63
+ "command": ["gopls"],
64
+ "extensions": [".go"],
65
+ "disabled": true
46
66
  }
47
67
  }
48
68
  }
49
69
  `;
50
70
 
71
+ // ── Effect programs ───────────────────────────────────────────────────────────
72
+
51
73
  /**
52
74
  * Scaffold a starter global config if neither global nor project config exists.
53
- * Returns true if a file was created.
75
+ * Succeeds with `true` when a file was created, `false` otherwise.
54
76
  */
55
- export async function scaffoldGlobalConfig(cwd: string): Promise<boolean> {
56
- const globalPath = globalConfigPath();
57
- const projectPath = projectConfigPath(cwd);
58
-
59
- if (await fileExists(globalPath)) return false;
60
- if (await fileExists(projectPath)) return false;
61
-
62
- await mkdir(dirname(globalPath), { recursive: true });
63
- await writeFile(globalPath, STARTER_CONFIG, 'utf8');
64
- return true;
65
- }
77
+ export function scaffoldGlobalConfigEffect(
78
+ cwd: string,
79
+ ): Effect.Effect<boolean, ConfigWriteError, FileSystem> {
80
+ return Effect.gen(function* () {
81
+ const fs = yield* FileSystem;
82
+ const globalPath = globalConfigPath();
66
83
 
67
- // ── Loading ─────────────────────────────────────────────────────────────────
84
+ if (yield* fs.fileExists(globalPath)) return false;
85
+ if (yield* fs.fileExists(projectConfigPath(cwd))) return false;
68
86
 
69
- async function loadJsonFile<T>(path: string): Promise<T | null> {
70
- try {
71
- const text = await readFile(path, 'utf8');
72
- return JSON.parse(text) as T;
73
- } catch {
74
- return null;
75
- }
87
+ yield* fs.writeTextFile(globalPath, STARTER_CONFIG);
88
+ return true;
89
+ });
76
90
  }
77
91
 
78
- function commandAvailableVia(command: string, cwd: string): 'global' | 'npx' | null {
79
- try {
80
- const whichCmd = process.platform === 'win32' ? 'where' : 'which';
81
- execSync(`${whichCmd} ${command}`, { stdio: 'pipe', timeout: 5_000 });
82
- return 'global';
83
- } catch {
84
- // not global
85
- }
86
- try {
87
- execSync(`npx --yes ${command} --version`, { stdio: 'pipe', cwd, timeout: 15_000 });
88
- return 'npx';
89
- } catch {
90
- return null;
91
- }
92
+ function readJsonFileEffect<T>(path: string): Effect.Effect<T | null, never, FileSystem> {
93
+ return Effect.gen(function* () {
94
+ const fs = yield* FileSystem;
95
+ const raw = yield* fs.readTextFile(path).pipe(Effect.either);
96
+ if (raw._tag === 'Left') return null;
97
+ try {
98
+ return JSON.parse(raw.right) as T;
99
+ } catch {
100
+ return null;
101
+ }
102
+ });
92
103
  }
93
104
 
94
- // ── Resolving ───────────────────────────────────────────────────────────────
95
-
96
- function resolveServer(
105
+ function resolveServerEffect(
97
106
  name: string,
98
107
  config: LspServerUserConfig,
99
108
  cwd: string,
100
- ): ResolvedServerConfig | null {
101
- if (config.disabled) return null;
102
- if (!config.command || config.command.length === 0) return null;
103
- if (!config.extensions || config.extensions.length === 0) return null;
104
-
105
- let finalCommand = config.command[0];
106
- let finalArgs = config.command.slice(1);
107
-
108
- const via = commandAvailableVia(finalCommand, cwd);
109
- if (!via) return null;
110
- if (via === 'npx') {
111
- finalArgs = ['--yes', finalCommand, ...finalArgs];
112
- finalCommand = 'npx';
113
- }
109
+ ): Effect.Effect<ResolvedServerConfig | null, never, CommandResolver> {
110
+ return Effect.gen(function* () {
111
+ if (config.disabled) return null;
112
+ if (!config.command || config.command.length === 0) return null;
113
+ if (!config.extensions || config.extensions.length === 0) return null;
114
+
115
+ let finalCommand = config.command[0];
116
+ let finalArgs = config.command.slice(1);
117
+
118
+ const resolver = yield* CommandResolver;
119
+ const via = yield* resolver.resolve(finalCommand, cwd);
120
+ if (!via) return null;
121
+ if (via === 'npx') {
122
+ finalArgs = ['--yes', finalCommand, ...finalArgs];
123
+ finalCommand = 'npx';
124
+ }
114
125
 
115
- return {
116
- name,
117
- command: finalCommand,
118
- args: finalArgs,
119
- extensions: config.extensions,
120
- env: config.env ?? {},
121
- initializationOptions: config.initialization ?? {},
122
- };
126
+ return {
127
+ name,
128
+ command: finalCommand,
129
+ args: finalArgs,
130
+ extensions: config.extensions,
131
+ env: config.env ?? {},
132
+ initializationOptions: config.initialization ?? {},
133
+ } satisfies ResolvedServerConfig;
134
+ });
123
135
  }
124
136
 
125
- // ── Public API ──────────────────────────────────────────────────────────────
126
-
127
137
  export interface LoadedConfig {
128
138
  servers: ResolvedServerConfig[];
129
139
  globalDisabled: boolean;
130
140
  errors: string[];
131
141
  }
132
142
 
133
- export async function loadConfig(cwd: string): Promise<LoadedConfig> {
134
- const errors: string[] = [];
143
+ export function loadConfigEffect(
144
+ cwd: string,
145
+ ): Effect.Effect<LoadedConfig, never, FileSystem | CommandResolver> {
146
+ return Effect.gen(function* () {
147
+ const errors: string[] = [];
135
148
 
136
- const globalConfig = await loadJsonFile<LspConfigFile>(globalConfigPath());
137
- const projectConfig = await loadJsonFile<LspConfigFile>(projectConfigPath(cwd));
149
+ const globalConfig = yield* readJsonFileEffect<LspConfigFile>(globalConfigPath());
150
+ const projectConfig = yield* readJsonFileEffect<LspConfigFile>(projectConfigPath(cwd));
138
151
 
139
- // Check if globally disabled
140
- if (globalConfig?.lsp === false || projectConfig?.lsp === false) {
141
- return { servers: [], globalDisabled: true, errors };
142
- }
143
-
144
- const globalServers = (typeof globalConfig?.lsp === 'object' ? globalConfig.lsp : {}) as Record<
145
- string,
146
- LspServerUserConfig
147
- >;
148
- const projectServers = (
149
- typeof projectConfig?.lsp === 'object' ? projectConfig.lsp : {}
150
- ) as Record<string, LspServerUserConfig>;
151
-
152
- // Merge: project overrides global
153
- const allNames = new Set([...Object.keys(globalServers), ...Object.keys(projectServers)]);
154
- const servers: ResolvedServerConfig[] = [];
155
-
156
- for (const name of allNames) {
157
- const userConfig: LspServerUserConfig = {
158
- ...globalServers[name],
159
- ...projectServers[name],
160
- };
161
-
162
- // Merge env maps properly
163
- if (globalServers[name]?.env || projectServers[name]?.env) {
164
- userConfig.env = { ...globalServers[name]?.env, ...projectServers[name]?.env };
152
+ if (globalConfig?.lsp === false || projectConfig?.lsp === false) {
153
+ return { servers: [], globalDisabled: true, errors };
165
154
  }
166
155
 
167
- const resolved = resolveServer(name, userConfig, cwd);
168
- if (resolved) {
169
- servers.push(resolved);
156
+ const globalServers = (typeof globalConfig?.lsp === 'object' ? globalConfig.lsp : {}) as Record<
157
+ string,
158
+ LspServerUserConfig
159
+ >;
160
+ const projectServers = (
161
+ typeof projectConfig?.lsp === 'object' ? projectConfig.lsp : {}
162
+ ) as Record<string, LspServerUserConfig>;
163
+
164
+ const allNames = new Set([...Object.keys(globalServers), ...Object.keys(projectServers)]);
165
+ const servers: ResolvedServerConfig[] = [];
166
+
167
+ for (const name of allNames) {
168
+ const userConfig: LspServerUserConfig = {
169
+ ...globalServers[name],
170
+ ...projectServers[name],
171
+ };
172
+
173
+ // Merge env maps properly (project on top of global).
174
+ if (globalServers[name]?.env || projectServers[name]?.env) {
175
+ userConfig.env = { ...globalServers[name]?.env, ...projectServers[name]?.env };
176
+ }
177
+
178
+ const resolved = yield* resolveServerEffect(name, userConfig, cwd);
179
+ if (resolved) servers.push(resolved);
170
180
  }
171
- }
172
181
 
173
- return { servers, globalDisabled: false, errors };
182
+ return { servers, globalDisabled: false, errors };
183
+ });
184
+ }
185
+
186
+ // ── Promise wrappers (live services provided) ────────────────────────────────
187
+
188
+ export function scaffoldGlobalConfig(cwd: string): Promise<boolean> {
189
+ return Effect.runPromise(
190
+ scaffoldGlobalConfigEffect(cwd).pipe(Effect.provide(makeRuntimeLayer())),
191
+ );
192
+ }
193
+
194
+ export function loadConfig(cwd: string): Promise<LoadedConfig> {
195
+ return Effect.runPromise(loadConfigEffect(cwd).pipe(Effect.provide(makeRuntimeLayer())));
174
196
  }
175
197
 
176
198
  /** Find all servers that handle a given file extension. */
@@ -0,0 +1,42 @@
1
+ /**
2
+ * CommandResolver service — decides how a configured LSP server command can be
3
+ * launched: directly from PATH (`global`), via `npx` as a fallback, or not at
4
+ * all (`null`).
5
+ *
6
+ * Isolated behind an Effect service so config resolution stays pure and tests
7
+ * can inject deterministic availability without shelling out.
8
+ */
9
+
10
+ import { Context, Effect } from 'effect';
11
+ import { execSync } from 'node:child_process';
12
+
13
+ export type CommandAvailability = 'global' | 'npx' | null;
14
+
15
+ export interface CommandResolverService {
16
+ readonly resolve: (command: string, cwd: string) => Effect.Effect<CommandAvailability>;
17
+ }
18
+
19
+ export class CommandResolver extends Context.Tag('Lsp/CommandResolver')<
20
+ CommandResolver,
21
+ CommandResolverService
22
+ >() {}
23
+
24
+ function commandAvailableVia(command: string, cwd: string): CommandAvailability {
25
+ try {
26
+ const whichCmd = process.platform === 'win32' ? 'where' : 'which';
27
+ execSync(`${whichCmd} ${command}`, { stdio: 'pipe', timeout: 5_000 });
28
+ return 'global';
29
+ } catch {
30
+ // not on PATH
31
+ }
32
+ try {
33
+ execSync(`npx --yes ${command} --version`, { stdio: 'pipe', cwd, timeout: 15_000 });
34
+ return 'npx';
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export const nodeCommandResolverService: CommandResolverService = {
41
+ resolve: (command, cwd) => Effect.sync(() => commandAvailableVia(command, cwd)),
42
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * FileSystem service — the only place the LSP extension touches disk.
3
+ *
4
+ * Wrapping Node's `fs/promises` behind an Effect service keeps config loading
5
+ * pure and injectable: tests can swap in an in-memory implementation, and
6
+ * failures surface as typed `ConfigReadError` / `ConfigWriteError` values.
7
+ */
8
+
9
+ import { Context, Effect } from 'effect';
10
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { dirname } from 'node:path';
12
+
13
+ import { ConfigReadError, ConfigWriteError } from '../errors';
14
+
15
+ export interface FileSystemService {
16
+ /** Read a UTF-8 file, failing with ConfigReadError when unreadable/missing. */
17
+ readonly readTextFile: (path: string) => Effect.Effect<string, ConfigReadError>;
18
+ /** Whether a path exists. Never fails. */
19
+ readonly fileExists: (path: string) => Effect.Effect<boolean>;
20
+ /** Write a UTF-8 file, creating parent directories first. */
21
+ readonly writeTextFile: (path: string, content: string) => Effect.Effect<void, ConfigWriteError>;
22
+ }
23
+
24
+ export class FileSystem extends Context.Tag('Lsp/FileSystem')<FileSystem, FileSystemService>() {}
25
+
26
+ export const nodeFileSystemService: FileSystemService = {
27
+ readTextFile: (path) =>
28
+ Effect.tryPromise({
29
+ try: () => readFile(path, 'utf8'),
30
+ catch: (cause) => new ConfigReadError({ path, cause }),
31
+ }),
32
+
33
+ fileExists: (path) =>
34
+ Effect.tryPromise({
35
+ try: () => access(path),
36
+ catch: (cause) => cause,
37
+ }).pipe(
38
+ Effect.as(true),
39
+ Effect.catchAll(() => Effect.succeed(false)),
40
+ ),
41
+
42
+ writeTextFile: (path, content) =>
43
+ Effect.tryPromise({
44
+ try: async () => {
45
+ await mkdir(dirname(path), { recursive: true });
46
+ await writeFile(path, content, 'utf8');
47
+ },
48
+ catch: (cause) => new ConfigWriteError({ path, cause }),
49
+ }),
50
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Live Effect layer for the LSP extension.
3
+ *
4
+ * Merges the disk-facing FileSystem service and the command-availability
5
+ * CommandResolver service. Build it once per extension activation and reuse it
6
+ * for every config/scaffold program.
7
+ */
8
+
9
+ import { Layer } from 'effect';
10
+
11
+ import { CommandResolver, nodeCommandResolverService } from './command';
12
+ import { FileSystem, nodeFileSystemService } from './filesystem';
13
+
14
+ export function makeRuntimeLayer() {
15
+ return Layer.mergeAll(
16
+ Layer.succeed(FileSystem, nodeFileSystemService),
17
+ Layer.succeed(CommandResolver, nodeCommandResolverService),
18
+ );
19
+ }
20
+
21
+ export type LspServices = FileSystem | CommandResolver;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Tagged error types for the LSP extension.
3
+ *
4
+ * Modeled with Effect's `Data.TaggedError` so failures are typed, pattern
5
+ * matchable, and carry structured context. Helpers at the bottom convert these
6
+ * into human-readable messages, tool `details`, and native `Error`s when an
7
+ * Effect needs to cross back into Promise-land.
8
+ */
9
+
10
+ import { Data } from 'effect';
11
+
12
+ export class ConfigReadError extends Data.TaggedError('ConfigReadError')<{
13
+ readonly path: string;
14
+ readonly cause: unknown;
15
+ }> {
16
+ get message(): string {
17
+ return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
18
+ }
19
+ }
20
+
21
+ export class ConfigWriteError extends Data.TaggedError('ConfigWriteError')<{
22
+ readonly path: string;
23
+ readonly cause: unknown;
24
+ }> {
25
+ get message(): string {
26
+ return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
27
+ }
28
+ }
29
+
30
+ export class LspValidationError extends Data.TaggedError('LspValidationError')<{
31
+ readonly reason: string;
32
+ }> {
33
+ get message(): string {
34
+ return this.reason;
35
+ }
36
+ }
37
+
38
+ export class NoCapableServerError extends Data.TaggedError('NoCapableServerError')<{
39
+ readonly operation: string;
40
+ readonly filePath: string;
41
+ }> {
42
+ get message(): string {
43
+ return `No LSP server with '${this.operation}' capability found for ${this.filePath}. Check /lsp status.`;
44
+ }
45
+ }
46
+
47
+ export class NoServerAvailableError extends Data.TaggedError('NoServerAvailableError')<{
48
+ readonly operation: string;
49
+ }> {
50
+ get message(): string {
51
+ return `No LSP server available for ${this.operation}.`;
52
+ }
53
+ }
54
+
55
+ export class LspOperationError extends Data.TaggedError('LspOperationError')<{
56
+ readonly operation: string;
57
+ readonly server?: string;
58
+ readonly cause: unknown;
59
+ }> {
60
+ get message(): string {
61
+ const server = this.server ? ` (${this.server})` : '';
62
+ return `LSP ${this.operation}${server} failed: ${causeMessage(this.cause)}`;
63
+ }
64
+ }
65
+
66
+ export type LspExtensionError =
67
+ | ConfigReadError
68
+ | ConfigWriteError
69
+ | LspValidationError
70
+ | NoCapableServerError
71
+ | NoServerAvailableError
72
+ | LspOperationError;
73
+
74
+ // ── Helpers ───────────────────────────────────────────────────────────────
75
+
76
+ export function causeMessage(cause: unknown): string {
77
+ if (cause instanceof Error) return cause.message;
78
+ return String(cause);
79
+ }
80
+
81
+ export function errorMessage(error: unknown): string {
82
+ if (error instanceof Error) return error.message;
83
+ if (typeof error === 'object' && error !== null && 'message' in error) {
84
+ const message = (error as { message?: unknown }).message;
85
+ if (typeof message === 'string') return message;
86
+ }
87
+ return String(error);
88
+ }
89
+
90
+ export function errorDetails(error: unknown): Record<string, unknown> {
91
+ if (typeof error === 'object' && error !== null && '_tag' in error) {
92
+ const tagged = error as { _tag: string } & Record<string, unknown>;
93
+ const details: Record<string, unknown> = { error: tagged._tag };
94
+ for (const [key, value] of Object.entries(tagged)) {
95
+ if (key === '_tag' || key === 'cause') continue;
96
+ details[key] = value;
97
+ }
98
+ details.message = errorMessage(error);
99
+ return details;
100
+ }
101
+
102
+ return { error: 'lsp_error', message: errorMessage(error) };
103
+ }
104
+
105
+ /** Convert a tagged/unknown error into a native Error for Promise rejection. */
106
+ export function toNativeError(error: unknown): Error {
107
+ if (error instanceof Error) return error;
108
+ const native = new Error(errorMessage(error));
109
+ if (typeof error === 'object' && error !== null && '_tag' in error) {
110
+ native.name = String((error as { _tag: unknown })._tag);
111
+ }
112
+ return native;
113
+ }
@@ -186,8 +186,9 @@ function formatDocSymbolTree(symbols: DocumentSymbol[], indent: number): string[
186
186
  for (const sym of symbols) {
187
187
  const kind = symbolKindLabel(sym.kind);
188
188
  const line = sym.selectionRange.start.line + 1;
189
+ const col = sym.selectionRange.start.character + 1;
189
190
  const detail = sym.detail ? ` — ${sym.detail}` : '';
190
- lines.push(`${prefix}${sym.name} (${kind}) line ${line}${detail}`);
191
+ lines.push(`${prefix}${sym.name} (${kind}) line ${line}:${col}${detail}`);
191
192
  if (sym.children?.length) {
192
193
  lines.push(...formatDocSymbolTree(sym.children, indent + 1));
193
194
  }
@@ -212,8 +213,9 @@ export function formatDocumentSymbols(
212
213
  const kind = symbolKindLabel(sym.kind);
213
214
  const p = relativePath(sym.location.uri, rootPath);
214
215
  const line = sym.location.range.start.line + 1;
216
+ const col = sym.location.range.start.character + 1;
215
217
  const container = sym.containerName ? ` in ${sym.containerName}` : '';
216
- return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}${container}`;
218
+ return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}:${col}${container}`;
217
219
  });
218
220
 
219
221
  return `Symbols in ${filePath} (${symbols.length}):\n\n${formatted.join('\n')}`;
@@ -232,8 +234,9 @@ export function formatWorkspaceSymbols(
232
234
  const kind = symbolKindLabel(sym.kind);
233
235
  const p = relativePath(sym.location.uri, rootPath);
234
236
  const line = sym.location.range.start.line + 1;
237
+ const col = sym.location.range.start.character + 1;
235
238
  const container = sym.containerName ? ` in ${sym.containerName}` : '';
236
- return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}${container}`;
239
+ return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}:${col}${container}`;
237
240
  });
238
241
 
239
242
  const truncated = symbols.length > 50 ? `\n\n(showing 50 of ${symbols.length})` : '';
@@ -10,16 +10,29 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
13
+ import { Effect } from 'effect';
13
14
 
14
15
  import { LspClient } from './client';
15
- import { loadConfig, scaffoldGlobalConfig, serversForExtension, type LoadedConfig } from './config';
16
- import { registerLspTool, type ServerManager } from './tools';
16
+ import {
17
+ loadConfigEffect,
18
+ scaffoldGlobalConfigEffect,
19
+ serversForExtension,
20
+ type LoadedConfig,
21
+ } from './config';
22
+ import { makeRuntimeLayer, type LspServices } from './effects/runtime';
23
+ import { errorMessage } from './errors';
24
+ import { registerLspTool, type ServerManagerService } from './tools';
17
25
  import type { ResolvedServerConfig } from './types';
18
26
 
19
27
  export default function lspExtension(pi: ExtensionAPI) {
20
28
  let rootPath = '';
21
29
  let config: LoadedConfig | null = null;
22
30
  const clients = new Map<string, LspClient>();
31
+ const runtimeLayer = makeRuntimeLayer();
32
+
33
+ function runLsp<A, E>(program: Effect.Effect<A, E, LspServices>): Promise<A> {
34
+ return Effect.runPromise(program.pipe(Effect.provide(runtimeLayer)));
35
+ }
23
36
 
24
37
  // ── Client management ───────────────────────────────────────────────
25
38
 
@@ -68,7 +81,7 @@ export default function lspExtension(pi: ExtensionAPI) {
68
81
 
69
82
  // ── Server manager (passed to tool) ───────────────────────────────────
70
83
 
71
- const serverManager: ServerManager = {
84
+ const serverManager: ServerManagerService = {
72
85
  clientsForFile(filePath: string): LspClient[] {
73
86
  if (!config) return [];
74
87
  const matching = serversForExtension(config.servers, filePath);
@@ -111,15 +124,18 @@ export default function lspExtension(pi: ExtensionAPI) {
111
124
  pi.on('session_start', async (_event, ctx) => {
112
125
  rootPath = ctx.cwd;
113
126
 
114
- const scaffolded = await scaffoldGlobalConfig(rootPath);
127
+ const scaffolded = await runLsp(scaffoldGlobalConfigEffect(rootPath)).catch((err) => {
128
+ ctx.ui.notify(`LSP: could not scaffold config: ${errorMessage(err)}`, 'warning');
129
+ return false;
130
+ });
115
131
  if (scaffolded) {
116
132
  ctx.ui.notify(
117
- 'LSP: created starter config at ~/.pi/agent/extensions/lsp/config.json — edit it to add your servers.',
133
+ 'LSP: created starter config at ~/.pi/agent/extensions/lsp/config.json — every server is disabled, enable the ones you need.',
118
134
  'info',
119
135
  );
120
136
  }
121
137
 
122
- config = await loadConfig(rootPath);
138
+ config = await runLsp(loadConfigEffect(rootPath));
123
139
  refreshStatus(ctx.ui, config);
124
140
  });
125
141
 
@@ -139,7 +155,7 @@ export default function lspExtension(pi: ExtensionAPI) {
139
155
  description: 'Show LSP server status',
140
156
  handler: async (_args, ctx) => {
141
157
  rootPath = ctx.cwd;
142
- const cfg = await loadConfig(ctx.cwd);
158
+ const cfg = await runLsp(loadConfigEffect(ctx.cwd));
143
159
  config = cfg;
144
160
  refreshStatus(ctx.ui, cfg);
145
161
  const lines: string[] = ['LSP Status:'];
@@ -173,7 +189,7 @@ export default function lspExtension(pi: ExtensionAPI) {
173
189
  await shutdownAll();
174
190
  config = null;
175
191
  rootPath = ctx.cwd;
176
- config = await loadConfig(ctx.cwd);
192
+ config = await runLsp(loadConfigEffect(ctx.cwd));
177
193
  refreshStatus(ctx.ui, config);
178
194
  ctx.ui.notify('LSP servers stopped. Will reinitialize on next tool use.', 'info');
179
195
  },