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 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
- if (!mcpConnectAuthorized)
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
- for (let i = 0; i < bytesRead; i++)
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 = stat.isFile() && stat.size <= MAX_READ_BYTES * 4 ? hashBuffer(await fs.readFile(file)) : null;
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 text = await fs.readFile(file, "utf8");
173
- const lines = text.split(/\r?\n/);
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 info = await mcpHub.toolInfo(server, tool);
458
- const readOnlyHint = info.annotations?.readOnlyHint === true;
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.invalidate();
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(() => void connect(), reconnectDelay + jitter);
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: "1.5.0-beta.2", 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() });
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
- this.beforeConnect ??= defaultRuntimeConnectGuard(this.workspaceRoot);
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 toolInfo(server, tool) {
347
+ async cachedToolInfo(server, tool) {
353
348
  const config = await this.resolveServer(server);
354
349
  const key = configKey(config);
355
- let catalog = await this.catalogFile();
356
- let found = catalog.tools.find((item) => item.serverKey === key && item.name === tool);
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
- catalog = await this.catalogFile();
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
- await this.beforeConnect?.(config);
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
- const session = { client, transport, connectedAt: Date.now(), lastUsedAt: Date.now(), stderrTail: "" };
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
- this.sessions.set(config.name, { client, transport, connectedAt: Date.now(), lastUsedAt: Date.now(), stderrTail: "" });
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?.stderrTail.trim();
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
  }
@@ -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 bytes = Buffer.byteLength(value, "utf8");
8
- buffer.totalBytes += bytes;
9
- buffer.text += value;
10
- const currentBytes = Buffer.byteLength(buffer.text, "utf8");
11
- if (currentBytes > MAX_BUFFER_BYTES) {
12
- const keep = buffer.text.slice(-MAX_BUFFER_BYTES);
13
- const keptBytes = Buffer.byteLength(keep, "utf8");
14
- buffer.baseOffset += currentBytes - keptBytes;
15
- buffer.text = keep;
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
- const relative = Math.max(0, requested - buffer.baseOffset);
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.text.slice(relative),
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: { text: "", baseOffset: 0, totalBytes: 0 },
81
- stderr: { text: "", baseOffset: 0, totalBytes: 0 },
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.on("data", (d) => this.emit(record, "stdout", d.toString()));
141
- child.stderr.on("data", (d) => this.emit(record, "stderr", d.toString()));
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).unref?.();
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
  }
@@ -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)
@@ -0,0 +1 @@
1
+ export const VERSION = "1.5.0-beta.3";
@@ -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.scanPromise = this.scan().finally(() => { this.scanPromise = null; });
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codelocal",
3
- "version": "1.5.0-beta.2",
3
+ "version": "1.5.0-beta.4",
4
4
  "description": "CodeLocal local code intelligence and execution runtime for ChatGPT.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",