@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.
@@ -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
+ }
@@ -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);
@@ -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");
@@ -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 = 1_000_000;
11
- const COMPLETED_PROCESS_TTL_MS = 24 * 60 * 60 * 1_000;
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
- return Array.from(value).length;
60
- }
61
- function sliceCodePoints(value, start, end) {
62
- return Array.from(value).slice(start, end).join("");
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
- return sliceCodePoints(value, 0, count);
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
- const characters = Array.from(value);
73
- return characters.slice(Math.max(0, characters.length - count)).join("");
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 += codePointLength(output);
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 fullOutput = this.head + output;
109
- this.head = takeHead(fullOutput, budget.head);
110
- this.tail = takeTail(fullOutput, budget.tail);
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 + output, budget.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);