@akira-tl/forgerelay 0.3.6 → 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/CHANGELOG.md +36 -0
- package/capabilities/code-intelligence/GUIDE.md +11 -0
- package/capabilities/shell-processes/GUIDE.md +2 -2
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +20 -0
- package/dist/config.js +1 -0
- package/dist/logger.js +16 -0
- package/dist/lsp/code-intelligence-error.js +8 -0
- package/dist/lsp/code-intelligence.js +550 -0
- package/dist/lsp/language-server-config.js +313 -0
- package/dist/lsp/position-encoding.js +88 -0
- package/dist/mcp-sessions.js +30 -0
- package/dist/oauth-provider.js +9 -0
- package/dist/process-sessions.js +98 -16
- package/dist/review-checkpoints.js +36 -1
- package/dist/server.js +107 -11
- package/dist/workspace-store.js +96 -19
- package/dist/workspaces.js +130 -82
- package/docs/chatgpt-coding-workflow.md +10 -4
- package/docs/configuration.md +80 -3
- package/docs/roadmap.md +26 -0
- package/package.json +4 -2
- package/scripts/debug/accept.mjs +15 -0
|
@@ -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,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/mcp-sessions.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export class McpTransportRegistry {
|
|
2
2
|
transports = new Map();
|
|
3
3
|
now;
|
|
4
|
+
maxTransports;
|
|
4
5
|
constructor(options = {}) {
|
|
5
6
|
this.now = options.now ?? Date.now;
|
|
7
|
+
this.maxTransports = options.maxTransports ?? Number.POSITIVE_INFINITY;
|
|
8
|
+
if (this.maxTransports !== Number.POSITIVE_INFINITY &&
|
|
9
|
+
(!Number.isInteger(this.maxTransports) || this.maxTransports < 1)) {
|
|
10
|
+
throw new Error("MCP transport limit must be a positive integer.");
|
|
11
|
+
}
|
|
6
12
|
}
|
|
7
13
|
get size() {
|
|
8
14
|
return this.transports.size;
|
|
@@ -12,6 +18,30 @@ export class McpTransportRegistry {
|
|
|
12
18
|
transport,
|
|
13
19
|
lastActivityAt: this.now(),
|
|
14
20
|
});
|
|
21
|
+
const excess = [];
|
|
22
|
+
while (this.transports.size > this.maxTransports) {
|
|
23
|
+
let oldestTransportSessionId;
|
|
24
|
+
let oldestActivityAt = Number.POSITIVE_INFINITY;
|
|
25
|
+
for (const [candidateSessionId, entry] of this.transports) {
|
|
26
|
+
if (candidateSessionId === transportSessionId && this.transports.size > 1)
|
|
27
|
+
continue;
|
|
28
|
+
if (entry.lastActivityAt >= oldestActivityAt)
|
|
29
|
+
continue;
|
|
30
|
+
oldestTransportSessionId = candidateSessionId;
|
|
31
|
+
oldestActivityAt = entry.lastActivityAt;
|
|
32
|
+
}
|
|
33
|
+
if (!oldestTransportSessionId)
|
|
34
|
+
break;
|
|
35
|
+
const oldest = this.transports.get(oldestTransportSessionId);
|
|
36
|
+
this.transports.delete(oldestTransportSessionId);
|
|
37
|
+
if (oldest) {
|
|
38
|
+
excess.push({
|
|
39
|
+
transportSessionId: oldestTransportSessionId,
|
|
40
|
+
transport: oldest.transport,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return closeTransports(excess);
|
|
15
45
|
}
|
|
16
46
|
get(transportSessionId) {
|
|
17
47
|
const entry = this.transports.get(transportSessionId);
|
package/dist/oauth-provider.js
CHANGED
|
@@ -116,6 +116,7 @@ export class SingleUserOAuthProvider {
|
|
|
116
116
|
}));
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
|
+
this.pruneExpiredAuthorizationCodes();
|
|
119
120
|
const code = `code-${randomUUID()}`;
|
|
120
121
|
this.codes.set(code, {
|
|
121
122
|
clientId: client.client_id,
|
|
@@ -177,9 +178,17 @@ export class SingleUserOAuthProvider {
|
|
|
177
178
|
this.oauthStore.deleteRefreshToken(hashed);
|
|
178
179
|
}
|
|
179
180
|
close() {
|
|
181
|
+
this.codes.clear();
|
|
180
182
|
this.oauthStore.close();
|
|
181
183
|
}
|
|
184
|
+
pruneExpiredAuthorizationCodes(nowMs = Date.now()) {
|
|
185
|
+
for (const [code, record] of this.codes) {
|
|
186
|
+
if (record.expiresAtMs < nowMs)
|
|
187
|
+
this.codes.delete(code);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
182
190
|
validCodeRecord(client, authorizationCode) {
|
|
191
|
+
this.pruneExpiredAuthorizationCodes();
|
|
183
192
|
const record = this.codes.get(authorizationCode);
|
|
184
193
|
if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) {
|
|
185
194
|
throw new InvalidGrantError("Invalid authorization code");
|
package/dist/process-sessions.js
CHANGED
|
@@ -7,8 +7,10 @@ const MAX_START_YIELD_MS = 300_000;
|
|
|
7
7
|
const MAX_COMMAND_YIELD_MS = 300_000;
|
|
8
8
|
const MAX_POLL_YIELD_MS = 300_000;
|
|
9
9
|
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
|
|
10
|
-
const DEFAULT_BUFFER_CHARACTERS =
|
|
11
|
-
const
|
|
10
|
+
const DEFAULT_BUFFER_CHARACTERS = 256_000;
|
|
11
|
+
const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
|
|
12
|
+
const DEFAULT_MAX_COMPLETED_PROCESSES = 128;
|
|
13
|
+
const COMPLETED_PROCESS_TTL_MS = 5 * 60 * 1_000;
|
|
12
14
|
const DEFAULT_COLUMNS = 80;
|
|
13
15
|
const DEFAULT_ROWS = 24;
|
|
14
16
|
function boundedInteger(value, fallback, maximum) {
|
|
@@ -56,21 +58,55 @@ function processEnvironment(input) {
|
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
function codePointLength(value) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
let characters = 0;
|
|
62
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
63
|
+
const codeUnit = value.charCodeAt(index);
|
|
64
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
|
|
65
|
+
index + 1 < value.length) {
|
|
66
|
+
const nextCodeUnit = value.charCodeAt(index + 1);
|
|
67
|
+
if (nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff)
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
characters += 1;
|
|
71
|
+
}
|
|
72
|
+
return characters;
|
|
63
73
|
}
|
|
64
74
|
function takeHead(value, count) {
|
|
65
75
|
if (count <= 0)
|
|
66
76
|
return "";
|
|
67
|
-
|
|
77
|
+
let index = 0;
|
|
78
|
+
let characters = 0;
|
|
79
|
+
while (index < value.length && characters < count) {
|
|
80
|
+
const codeUnit = value.charCodeAt(index);
|
|
81
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
|
|
82
|
+
index + 1 < value.length) {
|
|
83
|
+
const nextCodeUnit = value.charCodeAt(index + 1);
|
|
84
|
+
index += nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff ? 2 : 1;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
index += 1;
|
|
88
|
+
}
|
|
89
|
+
characters += 1;
|
|
90
|
+
}
|
|
91
|
+
return value.slice(0, index);
|
|
68
92
|
}
|
|
69
93
|
function takeTail(value, count) {
|
|
70
94
|
if (count <= 0)
|
|
71
95
|
return "";
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
let index = value.length;
|
|
97
|
+
let characters = 0;
|
|
98
|
+
while (index > 0 && characters < count) {
|
|
99
|
+
index -= 1;
|
|
100
|
+
const codeUnit = value.charCodeAt(index);
|
|
101
|
+
if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff &&
|
|
102
|
+
index > 0) {
|
|
103
|
+
const previousCodeUnit = value.charCodeAt(index - 1);
|
|
104
|
+
if (previousCodeUnit >= 0xd800 && previousCodeUnit <= 0xdbff)
|
|
105
|
+
index -= 1;
|
|
106
|
+
}
|
|
107
|
+
characters += 1;
|
|
108
|
+
}
|
|
109
|
+
return value.slice(index);
|
|
74
110
|
}
|
|
75
111
|
function splitBudget(maxCharacters) {
|
|
76
112
|
return {
|
|
@@ -97,20 +133,29 @@ export class HeadTailBuffer {
|
|
|
97
133
|
append(output) {
|
|
98
134
|
if (!output)
|
|
99
135
|
return;
|
|
136
|
+
const outputCharacters = codePointLength(output);
|
|
100
137
|
const previousTotal = this.totalCharacters;
|
|
101
|
-
this.totalCharacters +=
|
|
138
|
+
this.totalCharacters += outputCharacters;
|
|
102
139
|
if (this.totalCharacters <= this.maxCharacters) {
|
|
103
140
|
this.head += output;
|
|
104
141
|
return;
|
|
105
142
|
}
|
|
106
143
|
const budget = splitBudget(this.maxCharacters);
|
|
107
144
|
if (previousTotal <= this.maxCharacters) {
|
|
108
|
-
const
|
|
109
|
-
this.head =
|
|
110
|
-
|
|
145
|
+
const previousHead = this.head;
|
|
146
|
+
this.head = previousTotal >= budget.head
|
|
147
|
+
? takeHead(previousHead, budget.head)
|
|
148
|
+
: previousHead + takeHead(output, budget.head - previousTotal);
|
|
149
|
+
this.tail = outputCharacters >= budget.tail
|
|
150
|
+
? takeTail(output, budget.tail)
|
|
151
|
+
: takeTail(previousHead, budget.tail - outputCharacters) + output;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (outputCharacters >= budget.tail) {
|
|
155
|
+
this.tail = takeTail(output, budget.tail);
|
|
111
156
|
return;
|
|
112
157
|
}
|
|
113
|
-
this.tail = takeTail(this.tail
|
|
158
|
+
this.tail = takeTail(this.tail, budget.tail - outputCharacters) + output;
|
|
114
159
|
}
|
|
115
160
|
hasOutput() {
|
|
116
161
|
return this.totalCharacters > 0;
|
|
@@ -145,13 +190,24 @@ function truncateOutput(output, maxCharacters) {
|
|
|
145
190
|
export class ProcessManager {
|
|
146
191
|
processes = new Map();
|
|
147
192
|
completedByWorkspace = new Map();
|
|
193
|
+
completedProcessIds = [];
|
|
148
194
|
maxBufferCharacters;
|
|
195
|
+
maxActiveProcesses;
|
|
196
|
+
maxCompletedProcesses;
|
|
149
197
|
completedProcessTtlMs;
|
|
150
198
|
maxStartYieldMs;
|
|
151
199
|
monotonicNow;
|
|
152
200
|
nextProcessId = 1;
|
|
153
201
|
constructor(options = {}) {
|
|
154
202
|
this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
|
|
203
|
+
this.maxActiveProcesses = options.maxActiveProcesses ?? DEFAULT_MAX_ACTIVE_PROCESSES;
|
|
204
|
+
if (!Number.isInteger(this.maxActiveProcesses) || this.maxActiveProcesses < 1) {
|
|
205
|
+
throw new Error("Active process limit must be a positive integer.");
|
|
206
|
+
}
|
|
207
|
+
this.maxCompletedProcesses = options.maxCompletedProcesses ?? DEFAULT_MAX_COMPLETED_PROCESSES;
|
|
208
|
+
if (!Number.isInteger(this.maxCompletedProcesses) || this.maxCompletedProcesses < 1) {
|
|
209
|
+
throw new Error("Completed process limit must be a positive integer.");
|
|
210
|
+
}
|
|
155
211
|
this.completedProcessTtlMs = options.completedProcessTtlMs
|
|
156
212
|
?? options.completedSessionTtlMs
|
|
157
213
|
?? COMPLETED_PROCESS_TTL_MS;
|
|
@@ -159,6 +215,9 @@ export class ProcessManager {
|
|
|
159
215
|
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
160
216
|
}
|
|
161
217
|
async start(input) {
|
|
218
|
+
if (this.stats().running >= this.maxActiveProcesses) {
|
|
219
|
+
throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
|
|
220
|
+
}
|
|
162
221
|
const processEntry = this.createProcess(input);
|
|
163
222
|
this.processes.set(processEntry.id, processEntry);
|
|
164
223
|
try {
|
|
@@ -214,6 +273,17 @@ export class ProcessManager {
|
|
|
214
273
|
activeWorkspaceIds() {
|
|
215
274
|
return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
|
|
216
275
|
}
|
|
276
|
+
stats() {
|
|
277
|
+
let running = 0;
|
|
278
|
+
let completed = 0;
|
|
279
|
+
for (const processEntry of this.processes.values()) {
|
|
280
|
+
if (processEntry.running)
|
|
281
|
+
running += 1;
|
|
282
|
+
else
|
|
283
|
+
completed += 1;
|
|
284
|
+
}
|
|
285
|
+
return { total: this.processes.size, running, completed };
|
|
286
|
+
}
|
|
217
287
|
takeCompleted(workspaceId, maxOutputTokens, excludeProcessId) {
|
|
218
288
|
const processIds = this.completedByWorkspace.get(workspaceId) ?? [];
|
|
219
289
|
if (processIds.length === 0)
|
|
@@ -250,6 +320,7 @@ export class ProcessManager {
|
|
|
250
320
|
}
|
|
251
321
|
this.processes.clear();
|
|
252
322
|
this.completedByWorkspace.clear();
|
|
323
|
+
this.completedProcessIds.length = 0;
|
|
253
324
|
}
|
|
254
325
|
async waitForExit(processEntry, yieldTimeMs) {
|
|
255
326
|
let timer;
|
|
@@ -352,16 +423,24 @@ export class ProcessManager {
|
|
|
352
423
|
processEntry.running = false;
|
|
353
424
|
processEntry.exitCode = exitCode;
|
|
354
425
|
processEntry.signal = signal;
|
|
426
|
+
processEntry.process = undefined;
|
|
355
427
|
processEntry.resolveExit();
|
|
428
|
+
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
|
|
429
|
+
processEntry.cleanupTimer.unref();
|
|
356
430
|
if (processEntry.background) {
|
|
357
431
|
const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
|
|
358
432
|
if (!completed.includes(processEntry.id)) {
|
|
359
433
|
completed.push(processEntry.id);
|
|
360
434
|
this.completedByWorkspace.set(processEntry.workspaceId, completed);
|
|
435
|
+
this.completedProcessIds.push(processEntry.id);
|
|
436
|
+
}
|
|
437
|
+
while (this.completedProcessIds.length > this.maxCompletedProcesses) {
|
|
438
|
+
const oldestProcessId = this.completedProcessIds[0];
|
|
439
|
+
if (oldestProcessId === undefined)
|
|
440
|
+
break;
|
|
441
|
+
this.removeProcess(oldestProcessId);
|
|
361
442
|
}
|
|
362
443
|
}
|
|
363
|
-
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
|
|
364
|
-
processEntry.cleanupTimer.unref();
|
|
365
444
|
}
|
|
366
445
|
append(processEntry, output) {
|
|
367
446
|
processEntry.buffer.append(output);
|
|
@@ -396,6 +475,9 @@ export class ProcessManager {
|
|
|
396
475
|
if (processEntry?.cleanupTimer)
|
|
397
476
|
clearTimeout(processEntry.cleanupTimer);
|
|
398
477
|
this.processes.delete(processId);
|
|
478
|
+
const completedIndex = this.completedProcessIds.indexOf(processId);
|
|
479
|
+
if (completedIndex >= 0)
|
|
480
|
+
this.completedProcessIds.splice(completedIndex, 1);
|
|
399
481
|
if (!processEntry)
|
|
400
482
|
return;
|
|
401
483
|
const completed = this.completedByWorkspace.get(processEntry.workspaceId);
|