codelocal 1.5.0-beta.2 → 1.5.0-beta.4
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/dist/client-v2.js +103 -28
- package/dist/mcp-hub.js +48 -36
- package/dist/process-manager.js +44 -20
- package/dist/runtime-daemon.js +2 -1
- package/dist/version.js +1 -0
- package/dist/workspace-index.js +75 -2
- package/package.json +1 -1
package/dist/client-v2.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { promises as fs } from "node:fs";
|
|
1
|
+
import { createReadStream, promises as fs } from "node:fs";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
@@ -21,6 +22,7 @@ import { EditingEngine } from "./editing-engine.js";
|
|
|
21
22
|
import { VerificationEngine } from "./verification.js";
|
|
22
23
|
import { defaultDeviceIdentity, loadLocalCredential } from "./identity.js";
|
|
23
24
|
import { McpHub } from "./mcp-hub.js";
|
|
25
|
+
import { VERSION } from "./version.js";
|
|
24
26
|
const SERVER_URL = process.env.SERVER_URL;
|
|
25
27
|
const PROJECT_ROOT = process.env.PROJECT_ROOT;
|
|
26
28
|
const LEGACY_DEVICE_TOKEN = process.env.DEVICE_TOKEN;
|
|
@@ -32,6 +34,8 @@ const MAX_READ_BYTES = Number(process.env.CODELOCAL_MAX_READ_BYTES ?? 2 * 1024 *
|
|
|
32
34
|
const MAX_BATCH_BYTES = Number(process.env.CODELOCAL_MAX_BATCH_BYTES ?? 8 * 1024 * 1024);
|
|
33
35
|
const MAX_LIST_ENTRIES = Number(process.env.CODELOCAL_MAX_LIST_ENTRIES ?? 10000);
|
|
34
36
|
const MAX_OUTPUT_BYTES = Number(process.env.CODELOCAL_MAX_OUTPUT_BYTES ?? 2 * 1024 * 1024);
|
|
37
|
+
const WORKSPACE_IDLE_MS = Math.max(60_000, Number(process.env.CODELOCAL_WORKSPACE_IDLE_MS ?? 20 * 60_000) || 20 * 60_000);
|
|
38
|
+
const DAEMON_CHILD = process.env.CODELOCAL_DAEMON_CHILD === "1";
|
|
35
39
|
if (!SERVER_URL || !PROJECT_ROOT) {
|
|
36
40
|
console.error("Required: SERVER_URL and PROJECT_ROOT. Pairing credential or DEVICE_TOKEN is also required for registration.");
|
|
37
41
|
process.exit(1);
|
|
@@ -53,10 +57,8 @@ const approvalMemory = new ApprovalMemory();
|
|
|
53
57
|
const chatApproval = new ChatApprovalBroker();
|
|
54
58
|
const terminalHistory = new TerminalHistory();
|
|
55
59
|
const journal = new IdempotencyJournal();
|
|
56
|
-
let mcpConnectAuthorized = false;
|
|
57
60
|
const mcpHub = new McpHub(root, async () => {
|
|
58
|
-
|
|
59
|
-
throw new Error("Starting an installed MCP runtime requires approval in ChatGPT. Call mcp_call and approve it there.");
|
|
61
|
+
throw new Error("Starting an installed MCP runtime requires approval in ChatGPT. Call mcp_call and approve it there.");
|
|
60
62
|
});
|
|
61
63
|
const processManager = new ProcessManager(root, WORKSPACE_KEY, (_record, stream, text) => {
|
|
62
64
|
if (MIRROR_PROCESS_OUTPUT)
|
|
@@ -72,6 +74,9 @@ let ignoreMatcher = ignore();
|
|
|
72
74
|
let metadataEpoch = 0;
|
|
73
75
|
let reconnectDelay = 1000;
|
|
74
76
|
let activeSocket = null;
|
|
77
|
+
let lastToolActivityAt = Date.now();
|
|
78
|
+
let shuttingDown = false;
|
|
79
|
+
let idleTimer = null;
|
|
75
80
|
function isInsideRoot(candidate) {
|
|
76
81
|
return candidate === root || candidate.startsWith(rootPrefix);
|
|
77
82
|
}
|
|
@@ -138,28 +143,70 @@ function isIgnored(relativePath) {
|
|
|
138
143
|
function hashBuffer(buf) {
|
|
139
144
|
return createHash("sha256").update(buf).digest("hex");
|
|
140
145
|
}
|
|
146
|
+
function bufferLooksBinary(buf) {
|
|
147
|
+
const limit = Math.min(buf.length, 8192);
|
|
148
|
+
for (let i = 0; i < limit; i++)
|
|
149
|
+
if (buf[i] === 0)
|
|
150
|
+
return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
141
153
|
async function isBinary(file) {
|
|
142
154
|
const handle = await fs.open(file, "r");
|
|
143
155
|
try {
|
|
144
156
|
const buf = Buffer.alloc(8192);
|
|
145
157
|
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
146
|
-
|
|
147
|
-
if (buf[i] === 0)
|
|
148
|
-
return true;
|
|
149
|
-
return false;
|
|
158
|
+
return bufferLooksBinary(buf.subarray(0, bytesRead));
|
|
150
159
|
}
|
|
151
160
|
finally {
|
|
152
161
|
await handle.close();
|
|
153
162
|
}
|
|
154
163
|
}
|
|
155
|
-
async function fileMeta(file) {
|
|
156
|
-
const stat = await fs.stat(file);
|
|
164
|
+
async function fileMeta(file, options = {}) {
|
|
165
|
+
const stat = options.stat ?? await fs.stat(file);
|
|
157
166
|
const relative = rel(file);
|
|
158
167
|
if (isSensitivePath(relative))
|
|
159
168
|
throw new Error(`Access blocked by sensitive-path policy: ${relative}`);
|
|
160
|
-
const hash =
|
|
169
|
+
const hash = options.hash !== undefined
|
|
170
|
+
? options.hash
|
|
171
|
+
: stat.isFile() && stat.size <= MAX_READ_BYTES * 4
|
|
172
|
+
? hashBuffer(options.buffer ?? await fs.readFile(file))
|
|
173
|
+
: null;
|
|
161
174
|
return { path: relative, size: stat.size, mtimeMs: stat.mtimeMs, hash, ignored: isIgnored(relative), isFile: stat.isFile(), isDirectory: stat.isDirectory() };
|
|
162
175
|
}
|
|
176
|
+
async function streamTextRange(file, stat, startLine, endLine) {
|
|
177
|
+
const from = Math.max(1, startLine);
|
|
178
|
+
const requestedTo = endLine == null ? Number.POSITIVE_INFINITY : Math.max(0, endLine);
|
|
179
|
+
const selected = [];
|
|
180
|
+
const decoder = new StringDecoder("utf8");
|
|
181
|
+
const digest = stat.size <= MAX_READ_BYTES * 4 ? createHash("sha256") : null;
|
|
182
|
+
let carry = "";
|
|
183
|
+
let line = 0;
|
|
184
|
+
const consume = (text) => {
|
|
185
|
+
const parts = `${carry}${text}`.split(/\r?\n/);
|
|
186
|
+
carry = parts.pop() ?? "";
|
|
187
|
+
for (const value of parts) {
|
|
188
|
+
line++;
|
|
189
|
+
if (line >= from && line <= requestedTo)
|
|
190
|
+
selected.push(value);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const stream = createReadStream(file);
|
|
194
|
+
for await (const raw of stream) {
|
|
195
|
+
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
196
|
+
digest?.update(chunk);
|
|
197
|
+
consume(decoder.write(chunk));
|
|
198
|
+
}
|
|
199
|
+
const tail = `${carry}${decoder.end()}`;
|
|
200
|
+
carry = "";
|
|
201
|
+
const finalParts = tail.split(/\r?\n/);
|
|
202
|
+
for (const value of finalParts) {
|
|
203
|
+
line++;
|
|
204
|
+
if (line >= from && line <= requestedTo)
|
|
205
|
+
selected.push(value);
|
|
206
|
+
}
|
|
207
|
+
const to = Math.min(line, Number.isFinite(requestedTo) ? requestedTo : line);
|
|
208
|
+
return { from, to, totalLines: line, content: selected.join("\n"), hash: digest?.digest("hex") ?? null };
|
|
209
|
+
}
|
|
163
210
|
async function readOne(requestedPath, startLine, endLine) {
|
|
164
211
|
const file = await safeExistingPath(requestedPath);
|
|
165
212
|
const stat = await fs.stat(file);
|
|
@@ -167,13 +214,20 @@ async function readOne(requestedPath, startLine, endLine) {
|
|
|
167
214
|
throw new Error(`${requestedPath}: not a file.`);
|
|
168
215
|
if (stat.size > MAX_READ_BYTES && startLine == null)
|
|
169
216
|
throw new Error(`${requestedPath}: exceeds read limit; use read_file_range.`);
|
|
217
|
+
if (stat.size <= MAX_READ_BYTES) {
|
|
218
|
+
const buffer = await fs.readFile(file);
|
|
219
|
+
if (bufferLooksBinary(buffer))
|
|
220
|
+
return { ...(await fileMeta(file, { stat, buffer })), binary: true, content: null };
|
|
221
|
+
const text = buffer.toString("utf8");
|
|
222
|
+
const lines = text.split(/\r?\n/);
|
|
223
|
+
const from = Math.max(1, startLine ?? 1);
|
|
224
|
+
const to = Math.min(lines.length, endLine ?? lines.length);
|
|
225
|
+
return { ...(await fileMeta(file, { stat, buffer })), binary: false, startLine: from, endLine: to, totalLines: lines.length, content: lines.slice(from - 1, to).join("\n") };
|
|
226
|
+
}
|
|
170
227
|
if (await isBinary(file))
|
|
171
|
-
return { ...(await fileMeta(file)), binary: true, content: null };
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
const from = Math.max(1, startLine ?? 1);
|
|
175
|
-
const to = Math.min(lines.length, endLine ?? lines.length);
|
|
176
|
-
return { ...(await fileMeta(file)), binary: false, startLine: from, endLine: to, totalLines: lines.length, content: lines.slice(from - 1, to).join("\n") };
|
|
228
|
+
return { ...(await fileMeta(file, { stat, hash: null })), binary: true, content: null };
|
|
229
|
+
const ranged = await streamTextRange(file, stat, Math.max(1, startLine ?? 1), endLine);
|
|
230
|
+
return { ...(await fileMeta(file, { stat, hash: ranged.hash })), binary: false, startLine: ranged.from, endLine: ranged.to, totalLines: ranged.totalLines, content: ranged.content };
|
|
177
231
|
}
|
|
178
232
|
async function listFiles(startRelative = ".", maxDepth = 4, includeIgnored = false) {
|
|
179
233
|
const start = await safeExistingPath(startRelative);
|
|
@@ -454,8 +508,8 @@ async function gitWrite(operation, detail, args, requestId, approvalToken) {
|
|
|
454
508
|
return { exitCode: result.exitCode, output: result.stdout + result.stderr };
|
|
455
509
|
}
|
|
456
510
|
async function callInstalledMcp(server, tool, args, requestId, approvalToken) {
|
|
457
|
-
const
|
|
458
|
-
const readOnlyHint =
|
|
511
|
+
const cachedInfo = await mcpHub.cachedToolInfo(server, tool);
|
|
512
|
+
const readOnlyHint = cachedInfo?.annotations?.readOnlyHint === true;
|
|
459
513
|
const rule = `mcp:${server}:${tool}`;
|
|
460
514
|
const decision = {
|
|
461
515
|
riskLevel: "REVIEW",
|
|
@@ -478,9 +532,8 @@ async function callInstalledMcp(server, tool, args, requestId, approvalToken) {
|
|
|
478
532
|
}
|
|
479
533
|
await audit({ event: "mcp.chat_approved", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, status: "approved", detail: { server, tool, rule } });
|
|
480
534
|
const startedAt = Date.now();
|
|
481
|
-
mcpConnectAuthorized = true;
|
|
482
535
|
try {
|
|
483
|
-
const result = await mcpHub.callTool(server, tool, args);
|
|
536
|
+
const result = await mcpHub.callTool(server, tool, args, { authorizeConnect: true });
|
|
484
537
|
await audit({ event: "mcp.call", requestId, workspaceKey: WORKSPACE_KEY, tool: `${server}.${tool}`, status: "ok", detail: { durationMs: Date.now() - startedAt } });
|
|
485
538
|
return result;
|
|
486
539
|
}
|
|
@@ -488,9 +541,6 @@ async function callInstalledMcp(server, tool, args, requestId, approvalToken) {
|
|
|
488
541
|
await audit({ event: "mcp.call", requestId, workspaceKey: WORKSPACE_KEY, tool: `${server}.${tool}`, status: "failed", detail: { durationMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error) } });
|
|
489
542
|
throw error;
|
|
490
543
|
}
|
|
491
|
-
finally {
|
|
492
|
-
mcpConnectAuthorized = false;
|
|
493
|
-
}
|
|
494
544
|
}
|
|
495
545
|
async function handleTool(tool, args, request) {
|
|
496
546
|
if (tool === "project_info") {
|
|
@@ -773,12 +823,24 @@ const watcher = chokidar.watch(root, {
|
|
|
773
823
|
watcher.on("all", (event, changed) => {
|
|
774
824
|
const relative = rel(changed);
|
|
775
825
|
semantic.invalidate();
|
|
776
|
-
context.
|
|
826
|
+
context.noteChange(relative);
|
|
777
827
|
metadataEpoch++;
|
|
778
828
|
if (relative === ".gitignore")
|
|
779
829
|
void reloadIgnore();
|
|
780
830
|
log("debug", "workspace.changed", { event, path: relative, metadataEpoch });
|
|
781
831
|
});
|
|
832
|
+
if (DAEMON_CHILD) {
|
|
833
|
+
const sweepMs = Math.min(60_000, Math.max(10_000, Math.floor(WORKSPACE_IDLE_MS / 4)));
|
|
834
|
+
idleTimer = setInterval(() => {
|
|
835
|
+
if (shuttingDown || Date.now() - lastToolActivityAt < WORKSPACE_IDLE_MS)
|
|
836
|
+
return;
|
|
837
|
+
if (processManager.list().some((record) => record.status === "running"))
|
|
838
|
+
return;
|
|
839
|
+
log("info", "workspace.idle_sleep", { workspaceId: WORKSPACE_ID, idleMs: Date.now() - lastToolActivityAt });
|
|
840
|
+
void shutdown().finally(() => process.exit(0));
|
|
841
|
+
}, sweepMs);
|
|
842
|
+
idleTimer.unref?.();
|
|
843
|
+
}
|
|
782
844
|
async function clientCapabilities() {
|
|
783
845
|
const semanticInfo = await semantic.info();
|
|
784
846
|
let pty = false;
|
|
@@ -837,6 +899,7 @@ async function connect() {
|
|
|
837
899
|
ws.send(JSON.stringify({
|
|
838
900
|
type: "register",
|
|
839
901
|
protocolVersion: PROTOCOL_VERSION,
|
|
902
|
+
clientVersion: VERSION,
|
|
840
903
|
token: localCredential ? undefined : LEGACY_DEVICE_TOKEN,
|
|
841
904
|
credentialId: localCredential?.credentialId,
|
|
842
905
|
credentialSecret: localCredential?.credentialSecret,
|
|
@@ -873,6 +936,7 @@ async function connect() {
|
|
|
873
936
|
}
|
|
874
937
|
if (message.type !== "tool_call")
|
|
875
938
|
return;
|
|
939
|
+
lastToolActivityAt = Date.now();
|
|
876
940
|
const requestId = String(message.requestId ?? message.id ?? "");
|
|
877
941
|
const startedAt = Date.now();
|
|
878
942
|
const trace = {
|
|
@@ -897,20 +961,31 @@ async function connect() {
|
|
|
897
961
|
}
|
|
898
962
|
});
|
|
899
963
|
ws.on("close", (code, reason) => {
|
|
900
|
-
log("warn", "client.disconnected", { code, reason: reason.toString(), reconnectInMs: reconnectDelay });
|
|
964
|
+
log("warn", "client.disconnected", { code, reason: reason.toString(), reconnectInMs: shuttingDown ? null : reconnectDelay });
|
|
901
965
|
if (activeSocket === ws)
|
|
902
966
|
activeSocket = null;
|
|
967
|
+
if (shuttingDown)
|
|
968
|
+
return;
|
|
903
969
|
const jitter = Math.floor(Math.random() * Math.min(1000, reconnectDelay / 2));
|
|
904
|
-
setTimeout(() =>
|
|
970
|
+
setTimeout(() => { if (!shuttingDown)
|
|
971
|
+
void connect(); }, reconnectDelay + jitter);
|
|
905
972
|
reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
|
|
906
973
|
});
|
|
907
974
|
ws.on("error", (error) => log("error", "client.socket_error", { error }));
|
|
908
975
|
}
|
|
909
976
|
async function shutdown() {
|
|
977
|
+
if (shuttingDown)
|
|
978
|
+
return;
|
|
979
|
+
shuttingDown = true;
|
|
980
|
+
if (idleTimer) {
|
|
981
|
+
clearInterval(idleTimer);
|
|
982
|
+
idleTimer = null;
|
|
983
|
+
}
|
|
984
|
+
processManager.stopAll();
|
|
910
985
|
await Promise.allSettled([semantic.shutdown(), mcpHub.shutdown(), watcher.close()]);
|
|
911
986
|
activeSocket?.close();
|
|
912
987
|
}
|
|
913
988
|
process.on("SIGINT", async () => { await shutdown(); process.exit(0); });
|
|
914
989
|
process.on("SIGTERM", async () => { await shutdown(); process.exit(0); });
|
|
915
|
-
log("info", "client.started", { version:
|
|
990
|
+
log("info", "client.started", { version: VERSION, protocolVersion: PROTOCOL_VERSION, deviceId: DEVICE_ID, workspaceId: WORKSPACE_ID, projectRoot: root, shell: ALLOW_SHELL, approvalMode: APPROVAL_MODE, terminalApproval: "chat-mediated", networkPolicy: NETWORK_POLICY, hostname: os.hostname() });
|
|
916
991
|
void connect();
|
package/dist/mcp-hub.js
CHANGED
|
@@ -5,11 +5,11 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
5
5
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
6
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
7
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
-
import { audit } from "./audit.js";
|
|
9
8
|
const REGISTRY_VERSION = 1;
|
|
10
9
|
const CATALOG_VERSION = 1;
|
|
11
10
|
const MAX_CATALOG_TOOLS = Number(process.env.CODELOCAL_MCP_MAX_TOOLS ?? 5000);
|
|
12
11
|
const MAX_STDERR_TAIL = 16 * 1024;
|
|
12
|
+
const MCP_SESSION_IDLE_MS = Math.max(60_000, Number(process.env.CODELOCAL_MCP_SESSION_IDLE_MS ?? 10 * 60_000) || 10 * 60_000);
|
|
13
13
|
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
14
14
|
function normalizeRoot(value) {
|
|
15
15
|
return path.resolve(value);
|
|
@@ -196,29 +196,24 @@ function safeConnectDetail(config) {
|
|
|
196
196
|
return `http ${config.name}`;
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
-
function defaultRuntimeConnectGuard(workspaceRoot) {
|
|
200
|
-
if (!process.env.SERVER_URL || !process.env.PROJECT_ROOT || process.env.CODELOCAL_MCP_START_APPROVAL === "0")
|
|
201
|
-
return undefined;
|
|
202
|
-
return async (config) => {
|
|
203
|
-
await audit({
|
|
204
|
-
event: "policy.mcp_runtime_start_blocked",
|
|
205
|
-
workspaceKey: workspaceRoot,
|
|
206
|
-
riskLevel: "REVIEW",
|
|
207
|
-
status: "blocked",
|
|
208
|
-
detail: { server: config.name, transport: config.transport, rule: `mcp-runtime:${config.name}` },
|
|
209
|
-
});
|
|
210
|
-
throw new Error(`Starting installed MCP runtime requires chat-mediated approval. Use mcp_call from ChatGPT: ${config.name}`);
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
199
|
export class McpHub {
|
|
214
200
|
workspaceRoot;
|
|
215
201
|
beforeConnect;
|
|
216
202
|
sessions = new Map();
|
|
203
|
+
connecting = new Map();
|
|
204
|
+
idleTimer;
|
|
217
205
|
constructor(workspaceRoot = process.cwd(), beforeConnect) {
|
|
218
206
|
this.workspaceRoot = workspaceRoot;
|
|
219
207
|
this.beforeConnect = beforeConnect;
|
|
220
208
|
this.workspaceRoot = normalizeRoot(workspaceRoot);
|
|
221
|
-
|
|
209
|
+
const sweepMs = Math.min(60_000, Math.max(10_000, Math.floor(MCP_SESSION_IDLE_MS / 4)));
|
|
210
|
+
this.idleTimer = setInterval(() => { void this.pruneIdleSessions(); }, sweepMs);
|
|
211
|
+
this.idleTimer.unref?.();
|
|
212
|
+
}
|
|
213
|
+
async pruneIdleSessions() {
|
|
214
|
+
const cutoff = Date.now() - MCP_SESSION_IDLE_MS;
|
|
215
|
+
const stale = [...this.sessions.entries()].filter(([, session]) => session.lastUsedAt < cutoff).map(([name]) => name);
|
|
216
|
+
await Promise.allSettled(stale.map((name) => this.disconnect(name)));
|
|
222
217
|
}
|
|
223
218
|
async registry() {
|
|
224
219
|
const value = await readJson(mcpStatePaths().registry, { version: REGISTRY_VERSION, servers: [] });
|
|
@@ -299,9 +294,9 @@ export class McpHub {
|
|
|
299
294
|
tools: catalog.tools.filter((tool) => tool.serverKey === key),
|
|
300
295
|
};
|
|
301
296
|
}
|
|
302
|
-
async probe(name) {
|
|
297
|
+
async probe(name, authorizeConnect = false) {
|
|
303
298
|
const config = await this.resolveServer(name);
|
|
304
|
-
const client = await this.getOrConnect(config);
|
|
299
|
+
const client = await this.getOrConnect(config, authorizeConnect);
|
|
305
300
|
const tools = await this.fetchAllTools(client);
|
|
306
301
|
await this.replaceCatalogForServer(config, tools);
|
|
307
302
|
return {
|
|
@@ -349,24 +344,26 @@ export class McpHub {
|
|
|
349
344
|
recommendation: ranked.length ? "Call mcp_tool_info before mcp_call when you need the exact input schema." : "Probe a newly installed MCP with explicit local approval, then search again.",
|
|
350
345
|
};
|
|
351
346
|
}
|
|
352
|
-
async
|
|
347
|
+
async cachedToolInfo(server, tool) {
|
|
353
348
|
const config = await this.resolveServer(server);
|
|
354
349
|
const key = configKey(config);
|
|
355
|
-
|
|
356
|
-
|
|
350
|
+
const catalog = await this.catalogFile();
|
|
351
|
+
return catalog.tools.find((item) => item.serverKey === key && item.name === tool) ?? null;
|
|
352
|
+
}
|
|
353
|
+
async toolInfo(server, tool, authorizeConnect = false) {
|
|
354
|
+
let found = await this.cachedToolInfo(server, tool);
|
|
357
355
|
if (!found) {
|
|
358
|
-
await this.probe(server);
|
|
359
|
-
|
|
360
|
-
found = catalog.tools.find((item) => item.serverKey === key && item.name === tool);
|
|
356
|
+
await this.probe(server, authorizeConnect);
|
|
357
|
+
found = await this.cachedToolInfo(server, tool);
|
|
361
358
|
}
|
|
362
359
|
if (!found)
|
|
363
360
|
throw new Error(`MCP tool not found: ${server}.${tool}`);
|
|
364
361
|
return found;
|
|
365
362
|
}
|
|
366
|
-
async callTool(server, tool, args = {}) {
|
|
363
|
+
async callTool(server, tool, args = {}, options = {}) {
|
|
367
364
|
const config = await this.resolveServer(server);
|
|
368
|
-
const info = await this.toolInfo(server, tool);
|
|
369
|
-
const client = await this.getOrConnect(config);
|
|
365
|
+
const info = await this.toolInfo(server, tool, options.authorizeConnect === true);
|
|
366
|
+
const client = await this.getOrConnect(config, options.authorizeConnect === true);
|
|
370
367
|
const session = this.sessions.get(server);
|
|
371
368
|
if (session)
|
|
372
369
|
session.lastUsedAt = Date.now();
|
|
@@ -386,6 +383,8 @@ export class McpHub {
|
|
|
386
383
|
await session.client.close().catch(() => undefined);
|
|
387
384
|
}
|
|
388
385
|
async shutdown() {
|
|
386
|
+
clearInterval(this.idleTimer);
|
|
387
|
+
await Promise.allSettled([...this.connecting.values()]);
|
|
389
388
|
await Promise.all([...this.sessions.keys()].map((name) => this.disconnect(name)));
|
|
390
389
|
}
|
|
391
390
|
publicServer(server) {
|
|
@@ -417,13 +416,28 @@ export class McpHub {
|
|
|
417
416
|
throw new Error(`MCP server is disabled: ${name}`);
|
|
418
417
|
return config;
|
|
419
418
|
}
|
|
420
|
-
async getOrConnect(config) {
|
|
419
|
+
async getOrConnect(config, authorizeConnect = false) {
|
|
421
420
|
const existing = this.sessions.get(config.name);
|
|
422
|
-
if (existing)
|
|
421
|
+
if (existing) {
|
|
422
|
+
existing.lastUsedAt = Date.now();
|
|
423
423
|
return existing.client;
|
|
424
|
-
|
|
424
|
+
}
|
|
425
|
+
if (!authorizeConnect)
|
|
426
|
+
await this.beforeConnect?.(config);
|
|
427
|
+
const inFlight = this.connecting.get(config.name);
|
|
428
|
+
if (inFlight)
|
|
429
|
+
return inFlight;
|
|
430
|
+
const connecting = this.connectNew(config).finally(() => {
|
|
431
|
+
if (this.connecting.get(config.name) === connecting)
|
|
432
|
+
this.connecting.delete(config.name);
|
|
433
|
+
});
|
|
434
|
+
this.connecting.set(config.name, connecting);
|
|
435
|
+
return connecting;
|
|
436
|
+
}
|
|
437
|
+
async connectNew(config) {
|
|
425
438
|
const client = new Client({ name: "codelocal-mcp-hub", version: "1.0.0" });
|
|
426
439
|
let transport;
|
|
440
|
+
let session;
|
|
427
441
|
if (config.transport === "stdio") {
|
|
428
442
|
const cwd = config.cwd ? (path.isAbsolute(config.cwd) ? config.cwd : path.resolve(config.scope === "workspace" ? this.workspaceRoot : process.cwd(), config.cwd)) : (config.scope === "workspace" ? this.workspaceRoot : undefined);
|
|
429
443
|
const stdio = new StdioClientTransport({
|
|
@@ -434,26 +448,24 @@ export class McpHub {
|
|
|
434
448
|
stderr: "pipe",
|
|
435
449
|
});
|
|
436
450
|
transport = stdio;
|
|
437
|
-
|
|
451
|
+
session = { client, transport, connectedAt: Date.now(), lastUsedAt: Date.now(), stderrTail: "" };
|
|
438
452
|
stdio.stderr?.on("data", (chunk) => {
|
|
439
453
|
session.stderrTail = (session.stderrTail + String(chunk)).slice(-MAX_STDERR_TAIL);
|
|
440
454
|
});
|
|
441
|
-
this.sessions.set(config.name, session);
|
|
442
455
|
}
|
|
443
456
|
else {
|
|
444
457
|
const headers = materializeHeaders(config.headers);
|
|
445
458
|
transport = new StreamableHTTPClientTransport(new URL(config.url), headers ? { requestInit: { headers } } : undefined);
|
|
446
|
-
|
|
459
|
+
session = { client, transport, connectedAt: Date.now(), lastUsedAt: Date.now(), stderrTail: "" };
|
|
447
460
|
}
|
|
448
461
|
try {
|
|
449
462
|
await client.connect(transport);
|
|
463
|
+
this.sessions.set(config.name, session);
|
|
450
464
|
return client;
|
|
451
465
|
}
|
|
452
466
|
catch (error) {
|
|
453
|
-
const session = this.sessions.get(config.name);
|
|
454
|
-
this.sessions.delete(config.name);
|
|
455
467
|
await client.close().catch(() => undefined);
|
|
456
|
-
const stderr = session
|
|
468
|
+
const stderr = session.stderrTail.trim();
|
|
457
469
|
throw new Error(`Failed to connect MCP ${config.name}: ${error instanceof Error ? error.message : String(error)}${stderr ? `\nMCP stderr:\n${stderr}` : ""}`);
|
|
458
470
|
}
|
|
459
471
|
}
|
package/dist/process-manager.js
CHANGED
|
@@ -4,22 +4,24 @@ import path from "node:path";
|
|
|
4
4
|
const MAX_BUFFER_BYTES = Number(process.env.CODELOCAL_MAX_PROCESS_BUFFER_BYTES ?? 2 * 1024 * 1024);
|
|
5
5
|
const MAX_PROCESSES = Number(process.env.CODELOCAL_MAX_PROCESSES ?? 64);
|
|
6
6
|
function append(buffer, value) {
|
|
7
|
-
const
|
|
8
|
-
buffer.totalBytes +=
|
|
9
|
-
buffer.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
const chunk = Buffer.from(value, "utf8");
|
|
8
|
+
buffer.totalBytes += chunk.length;
|
|
9
|
+
buffer.data = buffer.data.length ? Buffer.concat([buffer.data, chunk]) : chunk;
|
|
10
|
+
if (buffer.data.length <= MAX_BUFFER_BYTES)
|
|
11
|
+
return;
|
|
12
|
+
let start = buffer.data.length - MAX_BUFFER_BYTES;
|
|
13
|
+
while (start < buffer.data.length && (buffer.data[start] & 0xc0) === 0x80)
|
|
14
|
+
start++;
|
|
15
|
+
buffer.baseOffset += start;
|
|
16
|
+
buffer.data = buffer.data.subarray(start);
|
|
17
17
|
}
|
|
18
18
|
function readBuffer(buffer, cursor) {
|
|
19
19
|
const requested = Math.max(cursor ?? buffer.baseOffset, buffer.baseOffset);
|
|
20
|
-
|
|
20
|
+
let relative = Math.max(0, Math.min(buffer.data.length, requested - buffer.baseOffset));
|
|
21
|
+
while (relative < buffer.data.length && (buffer.data[relative] & 0xc0) === 0x80)
|
|
22
|
+
relative++;
|
|
21
23
|
return {
|
|
22
|
-
text: buffer.
|
|
24
|
+
text: buffer.data.subarray(relative).toString("utf8"),
|
|
23
25
|
cursor: buffer.totalBytes,
|
|
24
26
|
truncatedBeforeCursor: (cursor ?? buffer.baseOffset) < buffer.baseOffset,
|
|
25
27
|
};
|
|
@@ -64,11 +66,12 @@ export class ProcessManager {
|
|
|
64
66
|
if (this.records.size >= MAX_PROCESSES)
|
|
65
67
|
throw new Error(`Too many active CodeLocal processes (${MAX_PROCESSES}).`);
|
|
66
68
|
}
|
|
67
|
-
baseRecord(command, cwd, executionMode, ownerSessionId) {
|
|
69
|
+
baseRecord(command, cwd, executionMode, ownerSessionId, requestId) {
|
|
68
70
|
return {
|
|
69
71
|
processId: randomUUID(),
|
|
70
72
|
workspaceKey: this.workspaceKey,
|
|
71
73
|
ownerSessionId,
|
|
74
|
+
requestId,
|
|
72
75
|
pid: null,
|
|
73
76
|
command,
|
|
74
77
|
cwd,
|
|
@@ -77,8 +80,8 @@ export class ProcessManager {
|
|
|
77
80
|
status: "running",
|
|
78
81
|
exitCode: null,
|
|
79
82
|
signal: null,
|
|
80
|
-
stdout: {
|
|
81
|
-
stderr: {
|
|
83
|
+
stdout: { data: Buffer.alloc(0), baseOffset: 0, totalBytes: 0 },
|
|
84
|
+
stderr: { data: Buffer.alloc(0), baseOffset: 0, totalBytes: 0 },
|
|
82
85
|
timeoutAt: null,
|
|
83
86
|
pty: false,
|
|
84
87
|
executionMode,
|
|
@@ -93,12 +96,19 @@ export class ProcessManager {
|
|
|
93
96
|
if (this.settledNotified.has(record.processId))
|
|
94
97
|
return;
|
|
95
98
|
this.settledNotified.add(record.processId);
|
|
99
|
+
if (record.timeoutTimer) {
|
|
100
|
+
clearTimeout(record.timeoutTimer);
|
|
101
|
+
record.timeoutTimer = undefined;
|
|
102
|
+
}
|
|
103
|
+
if (record.requestId && this.requestToProcess.get(record.requestId) === record.processId) {
|
|
104
|
+
this.requestToProcess.delete(record.requestId);
|
|
105
|
+
}
|
|
96
106
|
Promise.resolve(this.onSettled?.(record)).catch(() => undefined);
|
|
97
107
|
}
|
|
98
108
|
async start(command, options) {
|
|
99
109
|
this.prune();
|
|
100
110
|
const executionMode = "host-policy";
|
|
101
|
-
const record = this.baseRecord(command, options.cwd, executionMode, options.ownerSessionId);
|
|
111
|
+
const record = this.baseRecord(command, options.cwd, executionMode, options.ownerSessionId, options.requestId);
|
|
102
112
|
this.records.set(record.processId, record);
|
|
103
113
|
if (options.requestId)
|
|
104
114
|
this.requestToProcess.set(options.requestId, record.processId);
|
|
@@ -137,8 +147,10 @@ export class ProcessManager {
|
|
|
137
147
|
});
|
|
138
148
|
record.child = child;
|
|
139
149
|
record.pid = child.pid ?? null;
|
|
140
|
-
child.stdout.
|
|
141
|
-
child.stderr.
|
|
150
|
+
child.stdout.setEncoding("utf8");
|
|
151
|
+
child.stderr.setEncoding("utf8");
|
|
152
|
+
child.stdout.on("data", (d) => this.emit(record, "stdout", String(d)));
|
|
153
|
+
child.stderr.on("data", (d) => this.emit(record, "stderr", String(d)));
|
|
142
154
|
child.on("error", (error) => {
|
|
143
155
|
record.status = "failed";
|
|
144
156
|
record.exitCode = -1;
|
|
@@ -159,11 +171,12 @@ export class ProcessManager {
|
|
|
159
171
|
if (!timeoutMs || timeoutMs <= 0)
|
|
160
172
|
return;
|
|
161
173
|
record.timeoutAt = Date.now() + timeoutMs;
|
|
162
|
-
setTimeout(() => {
|
|
174
|
+
record.timeoutTimer = setTimeout(() => {
|
|
163
175
|
const current = this.records.get(record.processId);
|
|
164
176
|
if (current?.status === "running")
|
|
165
177
|
this.cancel(record.processId, "timeout");
|
|
166
|
-
}, timeoutMs)
|
|
178
|
+
}, timeoutMs);
|
|
179
|
+
record.timeoutTimer.unref?.();
|
|
167
180
|
}
|
|
168
181
|
snapshot(processId, cursors = {}) {
|
|
169
182
|
const record = this.records.get(processId);
|
|
@@ -258,4 +271,15 @@ export class ProcessManager {
|
|
|
258
271
|
return { cancelled: false, reason: "no process associated with request" };
|
|
259
272
|
return this.cancel(processId, reason);
|
|
260
273
|
}
|
|
274
|
+
stopAll(reason = "runtime shutdown") {
|
|
275
|
+
let cancelled = 0;
|
|
276
|
+
for (const record of this.records.values()) {
|
|
277
|
+
if (record.status !== "running")
|
|
278
|
+
continue;
|
|
279
|
+
this.cancel(record.processId, reason);
|
|
280
|
+
cancelled++;
|
|
281
|
+
}
|
|
282
|
+
this.requestToProcess.clear();
|
|
283
|
+
return { cancelled };
|
|
284
|
+
}
|
|
261
285
|
}
|
package/dist/runtime-daemon.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
4
|
import { terminalHeader, terminalStatus } from "./log.js";
|
|
5
5
|
import { WorkspaceRegistry } from "./workspace-registry.js";
|
|
6
|
+
import { VERSION } from "./version.js";
|
|
6
7
|
function deviceHeaders(credential) {
|
|
7
8
|
return {
|
|
8
9
|
"content-type": "application/json",
|
|
@@ -44,7 +45,7 @@ export class RuntimeDaemon {
|
|
|
44
45
|
const response = await fetch(`${this.options.baseUrl}/api/client/workspaces/sync`, {
|
|
45
46
|
method: "POST",
|
|
46
47
|
headers: deviceHeaders(this.options.credential),
|
|
47
|
-
body: JSON.stringify({ workspaces: workspaces.map(({ workspaceId, workspaceName, grantedAt, lastActivatedAt }) => ({ workspaceId, workspaceName, grantedAt, lastActivatedAt })) }),
|
|
48
|
+
body: JSON.stringify({ clientVersion: VERSION, workspaces: workspaces.map(({ workspaceId, workspaceName, grantedAt, lastActivatedAt }) => ({ workspaceId, workspaceName, grantedAt, lastActivatedAt })) }),
|
|
48
49
|
signal: AbortSignal.timeout(10_000),
|
|
49
50
|
});
|
|
50
51
|
if (response.status === 401 || response.status === 403)
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "1.5.0-beta.3";
|
package/dist/workspace-index.js
CHANGED
|
@@ -129,6 +129,8 @@ export class WorkspaceIntelligenceIndex {
|
|
|
129
129
|
dirty = true;
|
|
130
130
|
scanPromise = null;
|
|
131
131
|
cacheLoaded = false;
|
|
132
|
+
pendingPaths = new Set();
|
|
133
|
+
fullRescanNeeded = true;
|
|
132
134
|
cacheDir;
|
|
133
135
|
cacheFile;
|
|
134
136
|
constructor(root, maxEntries = Math.max(1000, Number(process.env.CODELOCAL_INDEX_MAX_FILES ?? 12000) || 12000), maxDepth = Math.max(2, Number(process.env.CODELOCAL_INDEX_MAX_DEPTH ?? 8) || 8), maxReadBytes = Math.max(16_384, Number(process.env.CODELOCAL_INDEX_MAX_FILE_BYTES ?? 384 * 1024) || 384 * 1024), freshnessMs = Math.max(500, Number(process.env.CODELOCAL_INDEX_FRESHNESS_MS ?? 1500) || 1500)) {
|
|
@@ -143,15 +145,20 @@ export class WorkspaceIntelligenceIndex {
|
|
|
143
145
|
}
|
|
144
146
|
invalidate(paths) {
|
|
145
147
|
this.dirty = true;
|
|
146
|
-
if (paths?.length)
|
|
148
|
+
if (paths?.length) {
|
|
147
149
|
for (const file of paths)
|
|
148
150
|
this.noteChange(file);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.fullRescanNeeded = true;
|
|
154
|
+
this.pendingPaths.clear();
|
|
149
155
|
}
|
|
150
156
|
noteChange(relativePath) {
|
|
151
157
|
const normalized = normalizeRelative(relativePath);
|
|
152
158
|
if (!normalized || normalized === "." || normalized.startsWith("../"))
|
|
153
159
|
return;
|
|
154
160
|
this.recentChanges.set(normalized, Date.now());
|
|
161
|
+
this.pendingPaths.add(normalized);
|
|
155
162
|
this.dirty = true;
|
|
156
163
|
}
|
|
157
164
|
async loadCache() {
|
|
@@ -255,6 +262,69 @@ export class WorkspaceIntelligenceIndex {
|
|
|
255
262
|
const tokens = [...new Set([...tokenize(meta.path), ...symbols.flatMap(tokenize), ...imports.flatMap(tokenize), ...(packageName ? tokenize(packageName) : [])])].slice(0, 800);
|
|
256
263
|
return { ...meta, language, kind, imports, symbols, tokens, packageName, changedAt };
|
|
257
264
|
}
|
|
265
|
+
async scanChanged() {
|
|
266
|
+
await this.loadCache();
|
|
267
|
+
const changed = [...this.pendingPaths];
|
|
268
|
+
this.pendingPaths.clear();
|
|
269
|
+
let requiresFullScan = false;
|
|
270
|
+
for (const relative of changed) {
|
|
271
|
+
if (!relative || relative === "." || relative.startsWith("../") || isSensitivePath(relative)) {
|
|
272
|
+
this.files.delete(relative);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const segments = relative.split("/");
|
|
276
|
+
if (segments.some((segment) => SKIP_DIRS.has(segment)) || segments.length - 1 > this.maxDepth) {
|
|
277
|
+
this.files.delete(relative);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const absolute = path.join(this.root, relative);
|
|
281
|
+
let stat;
|
|
282
|
+
try {
|
|
283
|
+
stat = await fs.lstat(absolute);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
const prefix = `${relative}/`;
|
|
287
|
+
if ([...this.files.keys()].some((file) => file.startsWith(prefix))) {
|
|
288
|
+
requiresFullScan = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
this.files.delete(relative);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (stat.isSymbolicLink()) {
|
|
295
|
+
this.files.delete(relative);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (stat.isDirectory()) {
|
|
299
|
+
requiresFullScan = true;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (!stat.isFile()) {
|
|
303
|
+
this.files.delete(relative);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!this.files.has(relative) && this.files.size >= this.maxEntries) {
|
|
307
|
+
requiresFullScan = true;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
this.files.set(relative, await this.indexOne({ path: relative, size: stat.size, mtimeMs: stat.mtimeMs }, this.files.get(relative)));
|
|
311
|
+
}
|
|
312
|
+
if (requiresFullScan) {
|
|
313
|
+
this.fullRescanNeeded = true;
|
|
314
|
+
await this.scan();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.rebuildGraphMetadata();
|
|
318
|
+
this.builtAt = Date.now();
|
|
319
|
+
this.lastScanAt = this.builtAt;
|
|
320
|
+
this.dirty = false;
|
|
321
|
+
this.fullRescanNeeded = false;
|
|
322
|
+
const cutoff = Date.now() - 10 * 60_000;
|
|
323
|
+
for (const [file, at] of this.recentChanges)
|
|
324
|
+
if (at < cutoff)
|
|
325
|
+
this.recentChanges.delete(file);
|
|
326
|
+
await this.persistCache();
|
|
327
|
+
}
|
|
258
328
|
async scan() {
|
|
259
329
|
await this.loadCache();
|
|
260
330
|
const discovered = await this.discover();
|
|
@@ -267,6 +337,8 @@ export class WorkspaceIntelligenceIndex {
|
|
|
267
337
|
this.recentChanges.set(oldPath, Date.now());
|
|
268
338
|
}
|
|
269
339
|
this.files = next;
|
|
340
|
+
this.pendingPaths.clear();
|
|
341
|
+
this.fullRescanNeeded = false;
|
|
270
342
|
this.rebuildGraphMetadata();
|
|
271
343
|
this.builtAt = Date.now();
|
|
272
344
|
this.lastScanAt = this.builtAt;
|
|
@@ -322,7 +394,8 @@ export class WorkspaceIntelligenceIndex {
|
|
|
322
394
|
if (!force && this.dirty && this.lastScanAt > 0 && now - this.lastScanAt < Math.min(this.freshnessMs, 750))
|
|
323
395
|
return this.summary();
|
|
324
396
|
if (!this.scanPromise) {
|
|
325
|
-
this.
|
|
397
|
+
const incremental = !force && !this.fullRescanNeeded && this.pendingPaths.size > 0 && this.pendingPaths.size <= 256;
|
|
398
|
+
this.scanPromise = (incremental ? this.scanChanged() : this.scan()).finally(() => { this.scanPromise = null; });
|
|
326
399
|
}
|
|
327
400
|
await this.scanPromise;
|
|
328
401
|
return this.summary();
|