@akira-tl/forgerelay 0.3.7 → 0.4.1

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,313 @@
1
+ import { access, readFile, realpath } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { z } from "zod";
6
+ export class LanguageServerConfigurationError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = "LanguageServerConfigurationError";
12
+ }
13
+ }
14
+ const definitionSchema = z.object({
15
+ enabled: z.boolean().optional(),
16
+ command: z.string().min(1).optional(),
17
+ args: z.array(z.string()).optional(),
18
+ env: z.record(z.string(), z.string()).optional(),
19
+ languages: z.array(z.string().min(1)).min(1).optional(),
20
+ extensions: z.array(z.string().regex(/^\./)).min(1).optional(),
21
+ languageIdByExtension: z.record(z.string().regex(/^\./), z.string().min(1)).optional(),
22
+ projectMarkers: z.array(z.string().min(1)).optional(),
23
+ }).strict();
24
+ const configSchema = z.record(z.string().min(1), definitionSchema);
25
+ const BUILTIN_DEFINITIONS = {
26
+ typescript: {
27
+ executableCandidates: ["typescript-language-server"],
28
+ args: ["--stdio"],
29
+ languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"],
30
+ extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
31
+ languageIdByExtension: {
32
+ ".ts": "typescript",
33
+ ".tsx": "typescriptreact",
34
+ ".js": "javascript",
35
+ ".jsx": "javascriptreact",
36
+ ".mjs": "javascript",
37
+ ".cjs": "javascript",
38
+ },
39
+ projectMarkers: ["tsconfig.json", "jsconfig.json", "package.json"],
40
+ },
41
+ pyright: {
42
+ executableCandidates: ["pyright-langserver"],
43
+ args: ["--stdio"],
44
+ languages: ["python"],
45
+ extensions: [".py", ".pyi"],
46
+ languageIdByExtension: { ".py": "python", ".pyi": "python" },
47
+ projectMarkers: ["pyrightconfig.json", "pyproject.toml", "setup.cfg", "setup.py"],
48
+ },
49
+ "rust-analyzer": {
50
+ executableCandidates: ["rust-analyzer"],
51
+ languages: ["rust"],
52
+ extensions: [".rs"],
53
+ languageIdByExtension: { ".rs": "rust" },
54
+ projectMarkers: ["Cargo.toml"],
55
+ },
56
+ gopls: {
57
+ executableCandidates: ["gopls"],
58
+ languages: ["go"],
59
+ extensions: [".go"],
60
+ languageIdByExtension: { ".go": "go" },
61
+ projectMarkers: ["go.work", "go.mod"],
62
+ },
63
+ clangd: {
64
+ executableCandidates: ["clangd"],
65
+ languages: ["c", "cpp", "objective-c", "objective-cpp"],
66
+ extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".m", ".mm"],
67
+ languageIdByExtension: {
68
+ ".c": "c",
69
+ ".cc": "cpp",
70
+ ".cpp": "cpp",
71
+ ".cxx": "cpp",
72
+ ".h": "cpp",
73
+ ".hh": "cpp",
74
+ ".hpp": "cpp",
75
+ ".hxx": "cpp",
76
+ ".m": "objective-c",
77
+ ".mm": "objective-cpp",
78
+ },
79
+ projectMarkers: ["compile_commands.json", "compile_flags.txt", ".clangd"],
80
+ },
81
+ };
82
+ export async function resolveLanguageProject(input) {
83
+ const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
84
+ const sourcePath = await resolveWorkspaceSourcePath(workspaceRoot, input.sourcePath);
85
+ const projectConfig = await loadProjectLanguageServerConfig(workspaceRoot);
86
+ const globalConfig = parseLanguageServerConfig(input.globalConfig ?? {}, "global ForgeRelay config");
87
+ const definitions = await effectiveDefinitions(globalConfig, projectConfig, input.env ?? process.env);
88
+ const extension = extname(sourcePath).toLowerCase();
89
+ const candidates = [];
90
+ for (const definition of definitions) {
91
+ if (!definition.extensions.includes(extension))
92
+ continue;
93
+ const projectRoot = await findLanguageProjectRoot(workspaceRoot, dirname(sourcePath), definition.projectMarkers);
94
+ if (!projectRoot)
95
+ continue;
96
+ candidates.push({ definition, projectRoot });
97
+ }
98
+ if (candidates.length === 0) {
99
+ throw new LanguageServerConfigurationError("code.language_service_unavailable", `No available Language-server definition matches ${relative(workspaceRoot, sourcePath) || "."}.`);
100
+ }
101
+ const sourceRank = { builtin: 0, global: 1, project: 2 };
102
+ const highestRank = Math.max(...candidates.map((candidate) => sourceRank[candidate.definition.source]));
103
+ const highest = candidates.filter((candidate) => sourceRank[candidate.definition.source] === highestRank);
104
+ const deepestLength = Math.max(...highest.map((candidate) => candidate.projectRoot.length));
105
+ const nearest = highest.filter((candidate) => candidate.projectRoot.length === deepestLength);
106
+ if (nearest.length !== 1) {
107
+ throw new LanguageServerConfigurationError("code.configuration_ambiguous", `Multiple Language-server definitions match ${relative(workspaceRoot, sourcePath)} at the same priority: ${nearest.map((candidate) => candidate.definition.id).join(", ")}.`);
108
+ }
109
+ return nearest[0];
110
+ }
111
+ export async function loadProjectLanguageServerConfig(workspaceRoot) {
112
+ const path = join(workspaceRoot, ".forgerelay", "language-servers.json");
113
+ try {
114
+ return parseLanguageServerConfig(JSON.parse(await readFile(path, "utf8")), path);
115
+ }
116
+ catch (error) {
117
+ if (isMissingFile(error))
118
+ return {};
119
+ if (error instanceof LanguageServerConfigurationError)
120
+ throw error;
121
+ const reason = error instanceof Error ? error.message : String(error);
122
+ throw new LanguageServerConfigurationError("code.configuration_invalid", `Unable to load Language-server configuration at ${path}: ${reason}`);
123
+ }
124
+ }
125
+ export function parseLanguageServerConfig(value, label) {
126
+ const parsed = configSchema.safeParse(value ?? {});
127
+ if (!parsed.success) {
128
+ const details = parsed.error.issues
129
+ .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
130
+ .join("; ");
131
+ throw new LanguageServerConfigurationError("code.configuration_invalid", `Invalid Language-server configuration in ${label}: ${details}`);
132
+ }
133
+ return parsed.data;
134
+ }
135
+ async function effectiveDefinitions(globalConfig, projectConfig, env) {
136
+ const ids = new Set([
137
+ ...Object.keys(BUILTIN_DEFINITIONS),
138
+ ...Object.keys(globalConfig),
139
+ ...Object.keys(projectConfig),
140
+ ]);
141
+ const definitions = [];
142
+ for (const id of ids) {
143
+ const builtin = BUILTIN_DEFINITIONS[id];
144
+ const global = globalConfig[id];
145
+ const project = projectConfig[id];
146
+ const source = project
147
+ ? "project"
148
+ : global
149
+ ? "global"
150
+ : "builtin";
151
+ const merged = {
152
+ id,
153
+ source,
154
+ ...(builtin ?? {}),
155
+ ...(global ?? {}),
156
+ ...(project ?? {}),
157
+ env: {
158
+ ...(builtin?.env ?? {}),
159
+ ...(global?.env ?? {}),
160
+ ...(project?.env ?? {}),
161
+ },
162
+ languageIdByExtension: {
163
+ ...(builtin?.languageIdByExtension ?? {}),
164
+ ...(global?.languageIdByExtension ?? {}),
165
+ ...(project?.languageIdByExtension ?? {}),
166
+ },
167
+ };
168
+ if (merged.enabled === false)
169
+ continue;
170
+ const languages = merged.languages ?? [];
171
+ const extensions = merged.extensions?.map((entry) => entry.toLowerCase()) ?? [];
172
+ if (languages.length === 0 || extensions.length === 0)
173
+ continue;
174
+ const languageIdByExtension = normalizeLanguageIds(id, merged, languages, extensions);
175
+ let command = merged.command;
176
+ if (!command && builtin?.executableCandidates) {
177
+ command = await findExecutable(builtin.executableCandidates, env);
178
+ if (!command)
179
+ continue;
180
+ }
181
+ if (!command) {
182
+ throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} requires a command.`);
183
+ }
184
+ const normalized = {
185
+ id,
186
+ command,
187
+ args: merged.args ?? [],
188
+ env: merged.env ?? {},
189
+ languages,
190
+ extensions,
191
+ languageIdByExtension,
192
+ projectMarkers: merged.projectMarkers ?? [],
193
+ source,
194
+ };
195
+ definitions.push({
196
+ ...normalized,
197
+ fingerprint: createHash("sha256").update(JSON.stringify(normalized)).digest("hex"),
198
+ });
199
+ }
200
+ return definitions;
201
+ }
202
+ function normalizeLanguageIds(id, definition, languages, extensions) {
203
+ const mapping = Object.fromEntries(Object.entries(definition.languageIdByExtension ?? {})
204
+ .map(([extension, languageId]) => [extension.toLowerCase(), languageId]));
205
+ for (let index = 0; index < extensions.length; index += 1) {
206
+ const extension = extensions[index];
207
+ if (mapping[extension])
208
+ continue;
209
+ if (languages.length === 1) {
210
+ mapping[extension] = languages[0];
211
+ continue;
212
+ }
213
+ if (languages.length === extensions.length) {
214
+ mapping[extension] = languages[index];
215
+ continue;
216
+ }
217
+ throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} must map extension ${extension} to a languageId when multiple language IDs do not align one-to-one with extensions.`);
218
+ }
219
+ for (const [extension, languageId] of Object.entries(mapping)) {
220
+ if (!extensions.includes(extension))
221
+ continue;
222
+ if (!languages.includes(languageId)) {
223
+ throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} maps ${extension} to unknown languageId ${languageId}.`);
224
+ }
225
+ }
226
+ return mapping;
227
+ }
228
+ async function findLanguageProjectRoot(workspaceRoot, startDirectory, markers) {
229
+ if (markers.length === 0)
230
+ return workspaceRoot;
231
+ let current = startDirectory;
232
+ while (isWithin(workspaceRoot, current)) {
233
+ for (const marker of markers) {
234
+ try {
235
+ await access(join(current, marker));
236
+ return current;
237
+ }
238
+ catch {
239
+ // Try the next marker or parent directory.
240
+ }
241
+ }
242
+ if (current === workspaceRoot)
243
+ break;
244
+ current = dirname(current);
245
+ }
246
+ return undefined;
247
+ }
248
+ async function canonicalWorkspaceRoot(inputPath) {
249
+ try {
250
+ return await realpath(resolve(inputPath));
251
+ }
252
+ catch {
253
+ throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence Workspace root does not exist: ${inputPath}`);
254
+ }
255
+ }
256
+ async function resolveWorkspaceSourcePath(workspaceRoot, inputPath) {
257
+ const candidate = resolve(workspaceRoot, inputPath);
258
+ if (!isWithin(workspaceRoot, candidate)) {
259
+ throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path must remain inside the Workspace: ${inputPath}`);
260
+ }
261
+ try {
262
+ const [canonicalRoot, canonicalCandidate] = await Promise.all([
263
+ realpath(workspaceRoot),
264
+ realpath(candidate),
265
+ ]);
266
+ if (!isWithin(canonicalRoot, canonicalCandidate)) {
267
+ throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path resolves outside the Workspace: ${inputPath}`);
268
+ }
269
+ return canonicalCandidate;
270
+ }
271
+ catch (error) {
272
+ if (error instanceof LanguageServerConfigurationError)
273
+ throw error;
274
+ throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path does not exist: ${inputPath}`);
275
+ }
276
+ }
277
+ async function findExecutable(candidates, env) {
278
+ for (const candidate of candidates) {
279
+ if (isAbsolute(candidate)) {
280
+ if (await executable(candidate))
281
+ return candidate;
282
+ continue;
283
+ }
284
+ const pathEntries = (env.PATH ?? "").split(delimiter).filter(Boolean);
285
+ const extensions = process.platform === "win32"
286
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
287
+ : [""];
288
+ for (const directory of pathEntries) {
289
+ for (const extension of extensions) {
290
+ const path = join(directory, process.platform === "win32" ? `${candidate}${extension}` : candidate);
291
+ if (await executable(path))
292
+ return path;
293
+ }
294
+ }
295
+ }
296
+ return undefined;
297
+ }
298
+ async function executable(path) {
299
+ try {
300
+ await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
301
+ return true;
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ function isWithin(root, candidate) {
308
+ const rel = relative(root, candidate);
309
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
310
+ }
311
+ function isMissingFile(error) {
312
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
313
+ }
@@ -0,0 +1,29 @@
1
+ export function normalizeHoverContents(contents) {
2
+ if (Array.isArray(contents)) {
3
+ if (contents.length === 1)
4
+ return normalizeMarkedString(contents[0]);
5
+ return {
6
+ contents: contents.map(renderMarkedString).join("\n\n"),
7
+ };
8
+ }
9
+ if (isMarkupContent(contents)) {
10
+ return { contents: contents.value };
11
+ }
12
+ return normalizeMarkedString(contents);
13
+ }
14
+ function isMarkupContent(value) {
15
+ return typeof value === "object" && value !== null && "kind" in value;
16
+ }
17
+ function normalizeMarkedString(value) {
18
+ if (typeof value === "string")
19
+ return { contents: value };
20
+ return {
21
+ contents: value.value,
22
+ language: value.language,
23
+ };
24
+ }
25
+ function renderMarkedString(value) {
26
+ if (typeof value === "string")
27
+ return value;
28
+ return `\`\`\`${value.language}\n${value.value}\n\`\`\``;
29
+ }
@@ -0,0 +1,88 @@
1
+ import { PositionEncodingKind } from "vscode-languageserver-protocol";
2
+ import { CodeIntelligenceError } from "./code-intelligence-error.js";
3
+ export function lspPositionFromUser(text, line, column, encoding) {
4
+ if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
5
+ throw new CodeIntelligenceError("code.invalid_position", `Code-intelligence positions are 1-based positive integers; received line=${line}, column=${column}.`);
6
+ }
7
+ const lines = text.split(/\r?\n/);
8
+ const sourceLine = lines[line - 1];
9
+ if (sourceLine === undefined) {
10
+ throw new CodeIntelligenceError("code.invalid_position", `Line ${line} is outside the document (${lines.length} lines).`);
11
+ }
12
+ const codePoints = Array.from(sourceLine);
13
+ const codePointIndex = column - 1;
14
+ if (codePointIndex > codePoints.length) {
15
+ throw new CodeIntelligenceError("code.invalid_position", `Column ${column} is outside line ${line} (${codePoints.length + 1} valid insertion positions).`);
16
+ }
17
+ const prefix = codePoints.slice(0, codePointIndex).join("");
18
+ return {
19
+ line: line - 1,
20
+ character: encodedLength(prefix, encoding),
21
+ };
22
+ }
23
+ export function wholeDocumentRange(text, encoding) {
24
+ const lines = text.split(/\r?\n/);
25
+ const lastLine = Math.max(0, lines.length - 1);
26
+ return {
27
+ start: { line: 0, character: 0 },
28
+ end: {
29
+ line: lastLine,
30
+ character: encodedLength(lines[lastLine] ?? "", encoding),
31
+ },
32
+ };
33
+ }
34
+ export function rangeFromLsp(text, range, encoding) {
35
+ return {
36
+ start: positionFromLsp(text, range.start, encoding),
37
+ end: positionFromLsp(text, range.end, encoding),
38
+ };
39
+ }
40
+ function positionFromLsp(text, position, encoding) {
41
+ const lines = text.split(/\r?\n/);
42
+ const sourceLine = lines[position.line];
43
+ if (sourceLine === undefined) {
44
+ throw new CodeIntelligenceError("code.invalid_position", `Language server returned line ${position.line} outside a ${lines.length}-line document.`);
45
+ }
46
+ return {
47
+ line: position.line + 1,
48
+ column: decodedCodePointOffset(sourceLine, position.character, encoding) + 1,
49
+ };
50
+ }
51
+ function encodedLength(text, encoding) {
52
+ if (encoding === PositionEncodingKind.UTF8)
53
+ return Buffer.byteLength(text, "utf8");
54
+ if (encoding === PositionEncodingKind.UTF32)
55
+ return Array.from(text).length;
56
+ return text.length;
57
+ }
58
+ function decodedCodePointOffset(text, encodedOffset, encoding) {
59
+ if (!Number.isInteger(encodedOffset) || encodedOffset < 0) {
60
+ throw new CodeIntelligenceError("code.invalid_position", `Language server returned invalid character offset ${encodedOffset}.`);
61
+ }
62
+ if (encoding === PositionEncodingKind.UTF32) {
63
+ if (encodedOffset > Array.from(text).length) {
64
+ throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
65
+ }
66
+ return encodedOffset;
67
+ }
68
+ if (encoding === PositionEncodingKind.UTF16) {
69
+ if (encodedOffset > text.length) {
70
+ throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
71
+ }
72
+ return Array.from(text.slice(0, encodedOffset)).length;
73
+ }
74
+ let bytes = 0;
75
+ let codePoints = 0;
76
+ for (const character of text) {
77
+ if (bytes === encodedOffset)
78
+ return codePoints;
79
+ bytes += Buffer.byteLength(character, "utf8");
80
+ codePoints += 1;
81
+ if (bytes > encodedOffset) {
82
+ throw new CodeIntelligenceError("code.invalid_position", `Language server returned UTF-8 offset ${encodedOffset} inside a code point.`);
83
+ }
84
+ }
85
+ if (bytes === encodedOffset)
86
+ return codePoints;
87
+ throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
88
+ }
package/dist/server.js CHANGED
@@ -21,6 +21,7 @@ import { deletePath, renamePath } from "./file-mutations.js";
21
21
  import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, } from "./artifact-tools.js";
22
22
  import { ArtifactError } from "./artifact-error.js";
23
23
  import { loadConfig } from "./config.js";
24
+ import { CodeIntelligenceError, CodeIntelligenceManager } from "./lsp/code-intelligence.js";
24
25
  import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
25
26
  import { checkHookConfiguration } from "./hook-cli.js";
26
27
  import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
@@ -743,7 +744,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
743
744
  });
744
745
  });
745
746
  }
746
- export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
747
+ export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence) {
747
748
  const toolDescriptions = buildToolDescriptions(config);
748
749
  const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
749
750
  const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
@@ -751,6 +752,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
751
752
  const reviewChangesAvailable = config.widgets === "changes";
752
753
  const capabilityRegistry = createCapabilityRegistry({
753
754
  inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
755
+ codeIntelligence: {
756
+ available: true,
757
+ run: async (input, context) => {
758
+ try {
759
+ return {
760
+ value: await codeIntelligence.run(context.workspaceRoot, input),
761
+ };
762
+ }
763
+ catch (error) {
764
+ if (error instanceof CodeIntelligenceError) {
765
+ throw new CapabilityError(error.code, error.message);
766
+ }
767
+ throw error;
768
+ }
769
+ },
770
+ },
754
771
  reviewChanges: {
755
772
  available: reviewChangesAvailable,
756
773
  unavailableReason: reviewChangesAvailable
@@ -2108,6 +2125,7 @@ export function createServer(config = loadConfig(), options = {}) {
2108
2125
  const workspaces = new WorkspaceRegistry(config, workspaceStore);
2109
2126
  const reviewCheckpoints = createReviewCheckpointManager();
2110
2127
  const processSessions = new ProcessManager();
2128
+ const codeIntelligence = new CodeIntelligenceManager(config);
2111
2129
  const localAgentProviders = config.subagents
2112
2130
  ? getLocalAgentProviderAvailabilitySnapshot()
2113
2131
  : [];
@@ -2154,6 +2172,7 @@ export function createServer(config = loadConfig(), options = {}) {
2154
2172
  processesCompleted: processStats.completed,
2155
2173
  cachedWorkspaces: workspaces.cachedWorkspaceCount,
2156
2174
  reviewStates: reviewCheckpoints.stateCount,
2175
+ languageServices: codeIntelligence.size,
2157
2176
  });
2158
2177
  };
2159
2178
  const transportCleanupTimer = setInterval(() => {
@@ -2276,7 +2295,7 @@ export function createServer(config = loadConfig(), options = {}) {
2276
2295
  });
2277
2296
  }
2278
2297
  };
2279
- const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters);
2298
+ const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence);
2280
2299
  await server.connect(transport);
2281
2300
  }
2282
2301
  else {
@@ -2306,6 +2325,7 @@ export function createServer(config = loadConfig(), options = {}) {
2306
2325
  const results = await transports.closeAll();
2307
2326
  logTransportCloseResults("server_shutdown", results);
2308
2327
  processSessions.shutdown();
2328
+ await codeIntelligence.shutdown();
2309
2329
  oauthProvider.close();
2310
2330
  workspaceStore.close?.();
2311
2331
  })();
@@ -151,6 +151,71 @@ snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integrati
151
151
  or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
152
152
  force a Host to invalidate its cached schema.
153
153
 
154
+ ### LSP code intelligence
155
+
156
+ ForgeRelay advertises `code.intelligence` through the Capability Gateway; it does
157
+ not add language-specific top-level MCP tools. ForgeRelay 0.4.1 supports
158
+ `definition` and `hover`. Both operations accept the same workspace-relative source
159
+ position. Hover results normalize plaintext, Markdown, and supported legacy LSP
160
+ payloads into one `contents` string with optional `language` and normalized `range`
161
+ metadata. Language Servers are external dependencies: ForgeRelay may discover an
162
+ executable already installed on the machine, but it never downloads or installs one
163
+ automatically.
164
+
165
+ Effective Language-server definitions resolve in this order:
166
+
167
+ 1. project configuration in `.forgerelay/language-servers.json`;
168
+ 2. global definitions from the `languageServers` object in
169
+ `~/.forgerelay/config.json`;
170
+ 3. built-in discovery for known executables.
171
+
172
+ A project configuration file is an object keyed by definition name. Definitions
173
+ use structured process launch and never go through a shell:
174
+
175
+ ```json
176
+ {
177
+ "typescript": {
178
+ "command": "typescript-language-server",
179
+ "args": ["--stdio"],
180
+ "env": {},
181
+ "languages": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
182
+ "extensions": [".ts", ".tsx", ".js", ".jsx"],
183
+ "languageIdByExtension": {
184
+ ".ts": "typescript",
185
+ ".tsx": "typescriptreact",
186
+ ".js": "javascript",
187
+ ".jsx": "javascriptreact"
188
+ },
189
+ "projectMarkers": ["tsconfig.json", "jsconfig.json"]
190
+ }
191
+ }
192
+ ```
193
+
194
+ Global configuration uses the same definition shape under `languageServers`:
195
+
196
+ ```json
197
+ {
198
+ "languageServers": {
199
+ "typescript": {
200
+ "command": "/absolute/path/to/typescript-language-server",
201
+ "args": ["--stdio"]
202
+ }
203
+ }
204
+ }
205
+ ```
206
+
207
+ Explicit configuration can set `"enabled": false` to suppress the matching
208
+ built-in definition. Project values override global values, and both override
209
+ built-in defaults. ForgeRelay resolves a Language project by walking ancestors of
210
+ the requested source file according to that definition's `projectMarkers`; it does
211
+ not recursively scan the Workspace.
212
+
213
+ Code-intelligence input positions are 1-based line and 1-based Unicode code-point
214
+ column values. The Workspace filesystem is the only v1 document source of truth.
215
+ Definition results may point outside the Workspace and are then marked
216
+ `external: true`; this is informational only and does not expand allowed roots or
217
+ file-tool authority.
218
+
154
219
  `rename` is the canonical move/rename primitive for files and directories; there
155
220
  is no separate `move` MCP tool.
156
221
 
package/docs/roadmap.md CHANGED
@@ -225,6 +225,21 @@ Candidate servers include `typescript-language-server`/tsserver, Pyright,
225
225
  `rust-analyzer`, `gopls`, and `clangd`, but ForgeRelay should treat server
226
226
  commands/configuration as external dependencies.
227
227
 
228
+ 0.4 is delivered as independently published patch releases rather than one large
229
+ 0.4.0 batch. Every boundary must complete local acceptance, update release metadata,
230
+ create and push its matching tag, pass cloud CI, publish npm and the GitHub Release,
231
+ and verify publication before work begins on the next boundary:
232
+
233
+ - **0.4.0** — Language-service foundation plus the complete `definition` tracer bullet;
234
+ - **0.4.1** — hover/type information;
235
+ - **0.4.2** — references and bounded semantic-location results;
236
+ - **0.4.3** — hierarchical document symbols and bounded workspace symbols;
237
+ - **0.4.4** — push/pull diagnostics and Diagnostic snapshots;
238
+ - **0.4.5** — cancellation, deadlines, crash recovery/config invalidation, concurrency,
239
+ and full Language-service resource/lifecycle hardening;
240
+ - **0.4.6** — optional real-server interoperability, cross-platform checks, fresh Host
241
+ acceptance, documentation, and final LSP v1 closure.
242
+
228
243
  ## 0.5 — First-class subagent MCP
229
244
 
230
245
  ForgeRelay already owns provider adapters and resumable local agent sessions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.7",
3
+ "version": "0.4.1",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -42,7 +42,7 @@
42
42
  "debug:accept": "node scripts/debug/accept.mjs",
43
43
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
44
44
  "start": "node dist/cli.js serve",
45
- "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
+ "test": "tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
47
  "release:check": "node scripts/release-version.mjs check",
48
48
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -79,6 +79,8 @@
79
79
  "react": "^19.2.6",
80
80
  "react-dom": "^19.2.6",
81
81
  "semver": "^7.8.4",
82
+ "vscode-jsonrpc": "^9.0.1",
83
+ "vscode-languageserver-protocol": "^3.18.2",
82
84
  "yaml": "^2.9.0",
83
85
  "zod": "^4.4.3"
84
86
  },
@@ -244,6 +244,7 @@ try {
244
244
  "process.lifecycle",
245
245
  "hooks.lifecycle",
246
246
  "capability-guides.read",
247
+ "code.intelligence",
247
248
  ...(process.platform === "linux" ? ["artifact.native-download"] : []),
248
249
  "ui.mcp-app",
249
250
  "review.changes",
@@ -253,6 +254,7 @@ try {
253
254
  assert.deepEqual(capabilityCatalog.map((entry) => entry.name), [
254
255
  "hooks.check",
255
256
  "review.changes",
257
+ "code.intelligence",
256
258
  ...(process.platform === "linux" ? ["artifact.download"] : []),
257
259
  ]);
258
260
  assert.equal(capabilityCatalog[0].available, true);
@@ -323,6 +325,18 @@ try {
323
325
  assert.equal(describedCapability.isError, undefined);
324
326
  assert.equal(describedCapability.structuredContent.capability.guide.name, "lifecycle-hooks");
325
327
  assert.equal(describedCapability.structuredContent.capability.inputSchema.type, "object");
328
+ const describedCodeIntelligence = callTool(oauth.accessToken, sessionId, 88, "capability", {
329
+ workspaceId,
330
+ name: "code.intelligence",
331
+ action: "describe",
332
+ });
333
+ assert.equal(describedCodeIntelligence.isError, undefined);
334
+ assert.equal(describedCodeIntelligence.structuredContent.capability.guide.name, "code-intelligence");
335
+ assert.equal(describedCodeIntelligence.structuredContent.capability.inputSchema.type, "object");
336
+ assert.deepEqual(
337
+ describedCodeIntelligence.structuredContent.capability.inputSchema.properties.operation.enum,
338
+ ["definition", "hover"],
339
+ );
326
340
  if (process.platform === "linux") {
327
341
  const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
328
342
  workspaceId,
@@ -350,6 +364,7 @@ try {
350
364
  "artifacts-review",
351
365
  "host-integration",
352
366
  "shell-processes",
367
+ "code-intelligence",
353
368
  ]);
354
369
  const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
355
370
  workspaceId,