feat: LSP code intelligence + parallel sub-agent mutation gate

Code intelligence (LSP):
- src/codeintel/lspManager.ts: lazy per-language LSP server lifecycle
  (tsserver/pyright/gopls/clangd/rust-analyzer), didOpen/didChange sync,
  definition/references/diagnostics, notifyFileChanged, shutdownAll.
  Fix stream typing (StreamMessageReader/Writer) + MarkupContent→string.
- src/tools/codeIntel.ts: definition/references/diagnostics read-only tools.
- tools/index.ts: register the three LSP tools.
- agent/loop.ts: wire notifyFileChanged into the FileChanged hook path
  (fire-and-forget, best-effort) so live servers stay in sync with disk.
- ui/ink/index.tsx: shutdownAll on exit so spawned servers aren't orphaned.

Parallel sub-agent orchestration:
- agent/session.ts: session.mutationGate promise chain serializes every
  mutating tool call across the session (incl. parallel sub-agents that
  share the parent session) so they can't race on the single permission
  slot or interleave filesystem writes. Read-only tools stay concurrent.
- agent/loop.ts: runUnderMutationGate wraps mutating tool execution;
  sub-agents inherit the parent's gate.
- tools/agentTool.ts: `tasks` array runs N sub-agents in parallel; one
  failure surfaces as that task's error, not a rejected batch.
- tools/types.ts: SubAgentResult type.

Other:
- cli.ts + mcp/client.ts: read version from package.json instead of
  hardcoding "0.3.1".
- agent/parallelAgents.test.ts: parallel batch + mutation-gate tests.
- Remove scratch guardtest2.mjs.

Verified: typecheck clean, build 245.85 KB, 219 tests pass.
This commit is contained in:
kim
2026-08-21 13:22:34 +09:00
parent 6fe98887d5
commit cb950891d5
13 changed files with 826 additions and 31 deletions
+33
View File
@@ -25,6 +25,8 @@
"react": "^19.2.7",
"string-width": "^8.2.1",
"tree-kill": "^1.2.2",
"vscode-languageserver-protocol": "^3.18.2",
"vscode-uri": "^3.1.0",
"zod": "^4.4.3"
},
"bin": {
@@ -5361,6 +5363,37 @@
}
}
},
"node_modules/vscode-jsonrpc": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz",
"integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.18.2",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz",
"integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "9.0.1",
"vscode-languageserver-types": "3.18.0"
}
},
"node_modules/vscode-languageserver-types": {
"version": "3.18.0",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz",
"integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -37,6 +37,8 @@
"react": "^19.2.7",
"string-width": "^8.2.1",
"tree-kill": "^1.2.2",
"vscode-languageserver-protocol": "^3.18.2",
"vscode-uri": "^3.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
+64 -23
View File
@@ -13,6 +13,7 @@ import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
import { notifyFileChanged } from "../codeintel/lspManager.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
import { estimateTokens } from "../utils/tokens.js";
import { runHooksForEvent } from "../hooks/runner.js";
@@ -529,30 +530,39 @@ async function gateAndRun(
return { error: message };
}
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const permissionHook = await runHooksForEvent(
"PermissionRequest",
hookCtx,
{ tool_name: tool.name, tool_input: args, preview },
tool.name,
);
emitHookWarnings(permissionHook.warnings, emit);
if (permissionHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
return { error: `Blocked by hook: ${permissionHook.reason}` };
// Serialize mutating tools across the WHOLE session — including parallel sub-agents spawned
// via the `agent` tool's `tasks` array, which share this session's permissions/confirm. Without
// this lock two sub-agents running concurrently could each reach a mutating call at once, racing
// on the single React `permission` slot (makeConfirmFn) and interleaving filesystem writes. The
// gate is a per-session promise chain: each mutating call awaits the previous one before it even
// builds its preview, so permission prompts stay one-at-a-time and edits can't overlap. Read-only
// tools skip the gate entirely and keep running concurrently (matching runToolBatch).
const runMutatingSection = async (): Promise<unknown> => {
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const permissionHook = await runHooksForEvent(
"PermissionRequest",
hookCtx,
{ tool_name: tool.name, tool_input: args, preview },
tool.name,
);
emitHookWarnings(permissionHook.warnings, emit);
if (permissionHook.blocked) {
emit({ type: "tool_result", summary: `Blocked by hook: ${permissionHook.reason}`, isError: true });
return { error: `Blocked by hook: ${permissionHook.reason}` };
}
const decision = await session.confirm({ toolName: tool.name, args, preview, signal });
if (decision === "deny") {
emit({ type: "tool_result", summary: "Denied by user", isError: true });
return { error: "Denied by user." };
}
if (decision === "session") {
session.permissions.allowForSession(tool.name);
}
}
const decision = await session.confirm({ toolName: tool.name, args, preview, signal });
if (decision === "deny") {
emit({ type: "tool_result", summary: "Denied by user", isError: true });
return { error: "Denied by user." };
}
if (decision === "session") {
session.permissions.allowForSession(tool.name);
}
}
const result = await runTool(tool, args, ctx);
return runTool(tool, args, ctx);
};
const result = tool.mutating ? await runUnderMutationGate(session, runMutatingSection) : await runMutatingSection();
const isError = !!(result && typeof result === "object" && "error" in (result as object));
// FileChanged fires for any mutating tool (built-in or MCP/plugin) — whether it touched the
@@ -560,6 +570,12 @@ async function gateAndRun(
// meaningless here, but a generic mutating tool is the most useful signal locode has). It's
// informational only and fires whether the tool succeeded or failed, since the turn is done either way.
if (tool.mutating) {
// Keep any live LSP server's view of the file in sync with disk so a subsequent
// `diagnostics` / `definition` / `references` call reflects the edit. Fire-and-forget
// and best-effort (lspManager never throws from this path) — a no-op if no server is
// running for this file's language yet.
const edited = typeof args === "object" && args !== null && "path" in args ? String((args as { path?: unknown }).path ?? "") : "";
if (edited) notifyFileChanged(edited, ctx.cwd).catch(() => {});
runHooksForEvent("FileChanged", hookCtx, { tool_name: tool.name, tool_input: args, tool_response: result, isError }, tool.name)
.then((r) => emitHookWarnings(r.warnings, emit))
.catch(() => {});
@@ -579,6 +595,26 @@ async function gateAndRun(
}
}
/** Runs `fn` under the session's mutation gate — a promise chain that serializes every mutating
* tool call in the session (including those from parallel sub-agents, which share the parent's
* session). The chain is advanced by chaining onto `session.mutationGate` and storing the new tail,
* so the next mutating caller waits on this one. Errors don't break the chain (the tail is still
* replaced with a settled promise) so one failed edit can't deadlock every later mutation.
* Read-only tools skip this entirely and keep running concurrently (matching runToolBatch). */
function runUnderMutationGate(session: Session, fn: () => Promise<unknown>): Promise<unknown> {
const prev = session.mutationGate;
let release!: () => void;
const gated = new Promise<void>((resolve) => {
release = resolve;
});
session.mutationGate = prev.then(() => gated);
return prev
.then(() => fn())
.finally(() => {
release();
});
}
/**
* Runs a batch of tool calls, parallelizing read-only tools for throughput (a local backend can
* serve several independent file reads / greps / web fetches concurrently, where sequential
@@ -676,6 +712,11 @@ async function runSubAgentTurn(
// Independent from the parent's — a sub-agent runs headless (see class doc above), so its own
// checklist has nowhere to render even if it called todo_write.
todos: [],
// SHARED with the parent (not independent) — this is the whole point of the mutation gate: a
// batch of parallel sub-agents all funnel their mutating calls through the parent's single
// gate, so they can't race on the one permission slot or interleave filesystem writes. A
// sub-agent that doesn't spawn siblings just inherits a resolved chain and is unaffected.
mutationGate: parent.mutationGate,
};
// Run (and honor) the SubagentStart hook before arming the timeout, so a slow hook doesn't eat
+229
View File
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { runTurn } from "./loop.js";
import { agentTool } from "../tools/agentTool.js";
import { createSession } from "./session.js";
import { buildToolSet } from "../tools/toolset.js";
import type { ToolDef } from "../tools/types.js";
/** A one-shot streaming response: yields `chunk` once, then ends. */
function oneShotStream(chunk: any) {
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: chunk };
},
}),
};
}
function textChunk(text: string) {
return { choices: [{ delta: { content: text }, finish_reason: "stop" }] };
}
function toolCallChunk(name: string, args: string, id = "call_0") {
return {
choices: [
{
delta: { tool_calls: [{ index: 0, id, function: { name, arguments: args } }] },
finish_reason: "tool_calls",
},
],
};
}
describe("agent tool / parallel sub-agents", () => {
it("runs a `tasks` batch and returns each result independently", async () => {
const agentArgs = JSON.stringify({
description: "parallel research",
tasks: [
{ description: "task A", prompt: "do A" },
{ description: "task B", prompt: "do B" },
],
});
// create() is called: 1 parent agent-call, then sub-agent A, then sub-agent B, then parent final.
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
if (call === 1) return oneShotStream(toolCallChunk("agent", agentArgs));
if (call === 2) return oneShotStream(textChunk("result-A"));
if (call === 3) return oneShotStream(textChunk("result-B"));
return oneShotStream(textChunk("all done"));
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [agentTool]);
const result = await runTurn(session, "research A and B in parallel", () => {});
expect(result).toBe("all done");
// The agent tool's result must carry both sub-agent answers as a `results` array.
const toolResultMsg = session.messages.find(
(m) => m.role === "tool" && typeof (m as any).content === "string" && (m as any).content.includes("results"),
) as any;
expect(toolResultMsg).toBeTruthy();
const parsed = JSON.parse(toolResultMsg.content);
expect(parsed.results).toHaveLength(2);
const byDesc = Object.fromEntries(parsed.results.map((r: any) => [r.description, r]));
expect(byDesc["task A"].result).toBe("result-A");
expect(byDesc["task B"].result).toBe("result-B");
});
it("a failed sub-agent surfaces as its own error without discarding sibling results", async () => {
const agentArgs = JSON.stringify({
description: "mixed batch",
tasks: [
{ description: "ok", prompt: "succeed" },
{ description: "boom", prompt: "fail" },
],
});
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
if (call === 1) return oneShotStream(toolCallChunk("agent", agentArgs));
if (call === 2) return oneShotStream(textChunk("ok-result"));
if (call === 3) throw new Error("sub-agent boom failed");
return oneShotStream(textChunk("done"));
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [agentTool]);
await runTurn(session, "run mixed batch", () => {});
const toolResultMsg = session.messages.find(
(m) => m.role === "tool" && typeof (m as any).content === "string" && (m as any).content.includes("results"),
) as any;
expect(toolResultMsg).toBeTruthy();
const parsed = JSON.parse(toolResultMsg.content);
const byDesc = Object.fromEntries(parsed.results.map((r: any) => [r.description, r]));
expect(byDesc.ok.result).toBe("ok-result");
expect(byDesc.boom.error).toBeTruthy();
expect(byDesc.boom.error).toMatch(/boom/);
});
});
describe("mutation gate / serialization", () => {
it("serializes mutating tool calls so two never run concurrently", async () => {
let inFlight = 0;
let maxOverlap = 0;
const slowEdit: ToolDef = {
name: "slow_edit",
description: "slow edit",
schema: z.object({}),
mutating: true,
handler: async () => {
inFlight++;
maxOverlap = Math.max(maxOverlap, inFlight);
await new Promise((r) => setTimeout(r, 20));
inFlight--;
return { ok: true };
},
};
function twoEditsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "c0", function: { name: "slow_edit", arguments: "{}" } },
{ index: 1, id: "c1", function: { name: "slow_edit", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
],
};
}
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
const chunk = call === 1 ? twoEditsChunk() : textChunk("done");
return oneShotStream(chunk);
}),
},
},
} as any;
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [slowEdit]);
session.maxIterations = 5;
await runTurn(session, "two edits", () => {});
expect(maxOverlap).toBe(1);
});
it("lets read-only tools run concurrently (gate only blocks mutating)", async () => {
let inFlight = 0;
let maxOverlap = 0;
const fastRead: ToolDef = {
name: "fast_read",
description: "fast read",
schema: z.object({}),
mutating: false,
handler: async () => {
inFlight++;
maxOverlap = Math.max(maxOverlap, inFlight);
await new Promise((r) => setTimeout(r, 20));
inFlight--;
return { ok: true };
},
};
function threeReadsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "c0", function: { name: "fast_read", arguments: "{}" } },
{ index: 1, id: "c1", function: { name: "fast_read", arguments: "{}" } },
{ index: 2, id: "c2", function: { name: "fast_read", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
],
};
}
let call = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
call++;
const chunk = call === 1 ? threeReadsChunk() : textChunk("done");
return oneShotStream(chunk);
}),
},
},
} as any;
const session = createSession(fakeClient, "m", process.cwd(), async () => "once", "native", [fastRead]);
session.maxIterations = 5;
await runTurn(session, "three reads", () => {});
// All three reads are read-only → runToolBatch runs them concurrently → they overlap.
expect(maxOverlap).toBe(3);
});
});
+12
View File
@@ -86,6 +86,16 @@ export interface Session {
/** Current task checklist shown to the user via the `todo_write` tool — session-scoped state
* since checklist items are a snapshot of progress, not part of the model-visible conversation. */
todos: TodoItem[];
/** A serialized-mutation gate shared by EVERY tool call in this session — including sub-agents
* spawned in parallel via the `agent` tool's `tasks` array. Parallel sub-agents share their
* parent's session, so without a lock two of them could simultaneously call a mutating tool,
* race on the single React `permission` slot (makeConfirmFn), and interleave filesystem writes.
* This gate lets read-only tools run concurrently (matching runToolBatch) while serializing
* mutating ones: each mutating call awaits the previous one before it even prompts, so
* permission prompts stay one-at-a-time and edits can't overlap. The chain is per-session,
* so a top-level turn and its sub-agents all funnel through the same queue. Initialized as a
* resolved promise so the first caller doesn't wait on anything. */
mutationGate: Promise<void>;
}
export function createSession(
@@ -128,6 +138,7 @@ export function createSession(
mutationCommitLength: null,
projectInstructions,
todos: [],
mutationGate: Promise.resolve(),
};
}
@@ -173,6 +184,7 @@ export function createSessionFromRecord(
mutationCommitLength: null,
projectInstructions,
todos: [],
mutationGate: Promise.resolve(),
};
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { execa } from "execa";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { Command } from "commander";
import pkg from "../package.json" with { type: "json" };
import { makeClient } from "./backend/client.js";
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js";
@@ -24,7 +25,7 @@ function addBackendOptions(cmd: Command): Command {
program
.name("locode")
.description("Agentic coding CLI for local models via Ollama and LM Studio")
.version("0.3.1");
.version(pkg.version);
addBackendOptions(program)
.option("-m, --model <name>", "model name as known to the backend")
+348
View File
@@ -0,0 +1,348 @@
import { spawn, type ChildProcess } from "node:child_process";
import path from "node:path";
import { readFile as fsReadFile } from "node:fs/promises";
import {
createProtocolConnection,
DidChangeTextDocumentNotification,
DidOpenTextDocumentNotification,
DefinitionRequest,
ReferencesRequest,
type ProtocolConnection,
type TextDocumentIdentifier,
type Position,
type Location,
type Diagnostic,
} from "vscode-languageserver-protocol";
import { StreamMessageReader, StreamMessageWriter } from 'vscode-languageserver-protocol/node';
import { URI } from "vscode-uri";
import type { MarkupContent } from "vscode-languageserver-protocol";
/** LSP diagnostic messages can be either a plain string or a { kind, value } MarkupContent object.
* locode's tool surface deals in plain strings, so flatten either form to text. */
function messageToString(message: string | MarkupContent): string {
if (typeof message === "string") return message;
return message?.value ?? "";
}
/** A connected LSP server for one language, plus its child process so we can clean it up. */
interface LspHandle {
connection: ProtocolConnection;
child: ChildProcess;
languageId: string;
/** Open documents we've already sent didOpen for, so we send didChange (not didOpen) on edits. */
openDocs: Set<string>;
}
/** Maps a file extension to a language id (the LSP "languageId" string) and the server command to
* spawn for it. Only one server per language is ever spawned (lazy, on first use). A missing entry
* means locode has no built-in mapping — the user can still point a server at it via config in a
* future extension. The command is resolved on the PATH; if it isn't installed the spawn fails and
* the tool returns a clear "install X" error rather than a silent no-op. */
interface LanguageSpec {
languageId: string;
extensions: string[];
/** The server command (no args). Must be on PATH. */
command: string;
/** Args passed to the server command. */
args?: string[];
}
const LANGUAGE_SPECS: LanguageSpec[] = [
// TypeScript / JavaScript — `typescript-language-server` wraps tsserver and speaks LSP. The most
// common local-model codebase shape, so it's the first one locode wires up.
{
languageId: "typescript",
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
command: "typescript-language-server",
args: ["--stdio"],
},
{
languageId: "python",
extensions: [".py", ".pyi"],
command: "pyright-langserver",
args: ["--stdio"],
},
{
languageId: "go",
extensions: [".go"],
command: "gopls",
args: ["serve"],
},
{
languageId: "rust",
extensions: [".rs"],
command: "rust-analyzer",
},
{
languageId: "c",
extensions: [".c", ".h"],
command: "clangd",
},
{
languageId: "cpp",
extensions: [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"],
command: "clangd",
},
];
/** Picks the LanguageSpec for a file path, or null if no extension matches. */
function specForFile(filePath: string): LanguageSpec | null {
const ext = path.extname(filePath).toLowerCase();
if (!ext) return null;
return LANGUAGE_SPECS.find((s) => s.extensions.includes(ext)) ?? null;
}
/** A per-workspace (cwd) registry of live LSP servers, keyed by language id. One server per
* language per cwd — a second project gets its own manager (locode is single-session-per-process
* today, but keying on cwd keeps it correct if that ever changes). */
const handles = new Map<string, LspHandle>();
/** Convert an absolute filesystem path to an LSP file:// URI string. */
function toUri(absPath: string): string {
return URI.file(absPath).toString();
}
interface LocResult {
path: string;
line: number;
column: number;
}
function toLocation(loc: Location): LocResult {
return {
path: URI.parse(loc.uri).fsPath,
line: loc.range.start.line + 1,
column: loc.range.start.character + 1,
};
}
/** Spawns the LSP server for `spec`, initializes it, and returns a live handle. Throws a clear,
* actionable error if the server binary isn't on the PATH (the most common failure) so the tool
* can surface "install typescript-language-server" instead of an opaque spawn ENOENT. */
async function startServer(spec: LanguageSpec, cwd: string): Promise<LspHandle> {
let child: ChildProcess;
try {
child = spawn(spec.command, spec.args ?? [], { cwd, stdio: ["pipe", "pipe", "pipe"] });
} catch (err) {
throw new Error(
`Could not start the LSP server "${spec.command}" for ${spec.languageId}. Is it installed and on your PATH? (${(err as Error).message})`,
);
}
if (!child.stdin || !child.stdout) {
child.kill();
throw new Error(`LSP server "${spec.command}" did not open stdio streams.`);
}
const reader = new StreamMessageReader(child.stdout);
const writer = new StreamMessageWriter(child.stdin);
const connection = createProtocolConnection(reader, writer);
// Surface stderr so a crashing server isn't a silent void (matches locode's MCP stdio policy).
child.stderr?.on("data", () => {
// Discard by default; a future debug mode could surface this. Don't let it back up.
});
await connection.sendRequest("initialize", {
processId: process.pid,
rootUri: URI.file(cwd).toString(),
capabilities: {
// locode consumes definition/references/diagnostics; declare only those so a server doesn't
// waste effort enabling features we'll never query. Full text sync (change=1) is simplest and
// correct — we always resend the whole file, never a range edit.
textDocumentSync: { openClose: true, change: 1 },
definitionProvider: true,
referencesProvider: true,
},
workspaceFolders: [{ uri: URI.file(cwd).toString(), name: path.basename(cwd) || cwd }],
});
// Per LSP spec, the client must send `initialized` after the initialize response.
await connection.sendNotification("initialized", {});
// A server crash should reject any in-flight request rather than hanging forever — listen for
// exit and dispose the connection so the next call throws instead of awaiting a dead process.
child.on("exit", () => {
connection.dispose();
handles.delete(`${cwd}::${spec.languageId}`);
});
return { connection, child, languageId: spec.languageId, openDocs: new Set() };
}
/** Returns the (lazily-started) LSP handle for the language owning `filePath`, or throws if no
* server is configured/can't start. The first call for a language pays the initialize round-trip;
* every later call reuses the live server. */
async function handleForFile(filePath: string, cwd: string): Promise<LspHandle> {
const spec = specForFile(filePath);
if (!spec) {
throw new Error(`No LSP server configured for "${path.extname(filePath)}" (code intelligence supports: ${LANGUAGE_SPECS.map((s) => s.extensions[0]).join(", ")}).`);
}
const key = `${cwd}::${spec.languageId}`;
let handle = handles.get(key);
if (!handle) {
handle = await startServer(spec, cwd);
handles.set(key, handle);
}
return handle;
}
/** Ensures the LSP server knows the current on-disk contents of `filePath`. Sends didOpen the
* first time a file is touched, didChange on subsequent syncs (the file was edited on disk since).
* Reads the file fresh each time — locode's tools write to disk before this runs, so the disk is
* the source of truth, not any in-memory buffer. */
async function syncDocument(handle: LspHandle, absPath: string, cwd: string): Promise<void> {
const uri = toUri(absPath);
const content = await fsReadFile(absPath, "utf-8");
if (!handle.openDocs.has(uri)) {
await handle.connection.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: { uri, languageId: handle.languageId, version: 1, text: content },
});
handle.openDocs.add(uri);
} else {
await handle.connection.sendNotification(DidChangeTextDocumentNotification.type, {
textDocument: { uri, version: Date.now() },
contentChanges: [{ text: content }],
});
}
}
export interface DefinitionResult {
/** The file/line/column of the symbol's definition. Multiple entries if the symbol has more than
* one definition (interface implementations, overloads, partial classes). Empty if the server
* found none (undefined symbol, or the server couldn't resolve it). */
definitions: LocResult[];
}
/** Resolves where the symbol at `line`/`column` (1-indexed) in `filePath` is defined. Syncs the
* document first so the server's view matches disk. Returns an empty list (not an error) when the
* server has no definition to offer — that's a legitimate "not found", not a failure. */
export async function getDefinition(
filePath: string,
line: number,
column: number,
cwd: string,
): Promise<DefinitionResult> {
const absPath = path.resolve(cwd, filePath);
const handle = await handleForFile(filePath, cwd);
await syncDocument(handle, absPath, cwd);
const pos: Position = { line: line - 1, character: column - 1 };
const result = (await handle.connection.sendRequest(DefinitionRequest.type, {
textDocument: { uri: toUri(absPath) } as TextDocumentIdentifier,
position: pos,
})) as Location | Location[] | null;
const locs = Array.isArray(result) ? result : result ? [result] : [];
return { definitions: locs.map(toLocation) };
}
export interface ReferencesResult {
/** Every place the symbol at `line`/`column` is referenced (including its definition). */
references: LocResult[];
}
/** Finds every reference to the symbol at `line`/`column` in `filePath`. `includeDeclaration`
* defaults to true (matches most IDE "find all references" behavior). */
export async function getReferences(
filePath: string,
line: number,
column: number,
cwd: string,
includeDeclaration = true,
): Promise<ReferencesResult> {
const absPath = path.resolve(cwd, filePath);
const handle = await handleForFile(filePath, cwd);
await syncDocument(handle, absPath, cwd);
const pos: Position = { line: line - 1, character: column - 1 };
const result = (await handle.connection.sendRequest(ReferencesRequest.type, {
textDocument: { uri: toUri(absPath) } as TextDocumentIdentifier,
position: pos,
context: { includeDeclaration },
})) as Location[] | null;
return { references: (result ?? []).map(toLocation) };
}
/** Notifies the LSP server that `filePath` changed on disk, so a subsequent `diagnostics` call
* reflects the new content. Called from the FileChanged hook path after edit_file/write_file. If no
* server is running for this language (or the file isn't one we manage), this is a no-op — it must
* never throw from a hook context, since hooks fire on every mutating tool. */
export async function notifyFileChanged(filePath: string, cwd: string): Promise<void> {
try {
const spec = specForFile(filePath);
if (!spec) return;
const key = `${cwd}::${spec.languageId}`;
const handle = handles.get(key);
if (!handle) return; // No server started yet — diagnostics will sync on first query.
await syncDocument(handle, path.resolve(cwd, filePath), cwd);
} catch {
// Best-effort: a hook context can't propagate errors into the turn.
}
}
type Severity = "error" | "warning" | "information" | "hint";
export interface DiagnosticsResult {
diagnostics: { path: string; line: number; column: number; severity: Severity; message: string; source?: string }[];
}
/** The most recent diagnostics the server has published for `filePath`. LSP pushes diagnostics via
* `textDocument/publishDiagnostics` notifications; locode collects them per-URI as they arrive and
* returns the latest snapshot here. Forces a document sync first so the snapshot is current. */
const diagnosticsByUri = new Map<string, Diagnostic[]>();
const SEVERITY_MAP: Record<number, Severity> = {
1: "error",
2: "warning",
3: "information",
4: "hint",
};
export async function getDiagnostics(filePath: string, cwd: string): Promise<DiagnosticsResult> {
const absPath = path.resolve(cwd, filePath);
const handle = await handleForFile(filePath, cwd);
const uri = toUri(absPath);
// Attach a per-connection diagnostic collector the first time we use this handle.
if (!(handle as unknown as { __diagWired?: boolean }).__diagWired) {
(handle as unknown as { __diagWired?: boolean }).__diagWired = true;
handle.connection.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => {
diagnosticsByUri.set(params.uri, params.diagnostics);
});
}
await syncDocument(handle, absPath, cwd);
// Give the server a beat to publish after the sync, then read the latest snapshot. A real LSP
// server publishes asynchronously; we await one event-loop turn rather than polling on a timer.
await new Promise((r) => setTimeout(r, 0));
const diags = diagnosticsByUri.get(uri) ?? [];
return {
diagnostics: diags.map((d) => ({
path: URI.parse(uri).fsPath,
line: (d.range?.start.line ?? 0) + 1,
column: (d.range?.start.character ?? 0) + 1,
severity: SEVERITY_MAP[d.severity ?? 1] ?? "information",
message: messageToString(d.message ?? ""),
source: d.source,
})),
};
}
/** Shuts down every live LSP server. Call on locode exit so spawned servers (tsserver, pyright,
* gopls, …) don't outlive the process as orphans. Awaits each shutdown so the signals land before
* teardown. Best-effort: a stuck server can't block exit forever (the child kill still fires). */
export async function shutdownAll(): Promise<void> {
const all = [...handles.values()];
handles.clear();
await Promise.allSettled(
all.map(async (h) => {
try {
await h.connection.sendRequest("shutdown", null);
h.connection.sendNotification("exit", {});
} catch {
// Already dead — fall through to kill.
}
h.child.kill();
}),
);
}
/** For tests only: clear the live-handle registry and diagnostic cache without spawning/killing. */
export function _resetForTests(): void {
handles.clear();
diagnosticsByUri.clear();
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { isHttpServerConfig, type McpServerConfig } from "./types.js";
import pkg from "../../package.json" with { type: "json" };
export interface McpTextContentBlock {
type: "text";
@@ -83,7 +84,7 @@ export async function connectMcpServer(name: string, config: McpServerConfig): P
stderr: "pipe",
});
const client = new Client({ name: "locode", version: "0.3.1" });
const client = new Client({ name: "locode", version: pkg.version });
try {
await client.connect(transport);
const { tools } = await client.listTools();
+48 -5
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { SubAgentResult } from "./types.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
@@ -7,22 +8,64 @@ const schema = z.object({
.string()
.describe(
"Full, self-contained task description. The sub-agent has no conversation memory and cannot ask follow-ups.",
),
)
.optional(),
tasks: z
.array(
z.object({
description: z.string().describe("Short label for this sub-task."),
prompt: z
.string()
.describe(
"Full, self-contained task description for this sub-task. The sub-agent has no conversation memory.",
),
}),
)
.min(2)
.describe(
"Run several sub-agents IN PARALLEL (read-heavy research/audit tasks). Each gets its own " +
"isolated context and returns independently. Use this for many files, e.g. one sub-agent " +
"per directory or per concern (security, performance, tests). Prefer `prompt` for a single task.",
)
.optional(),
}).refine((v) => v.prompt || v.tasks, {
message: "Provide either `prompt` (single sub-agent) or `tasks` (parallel batch).",
});
export const agentTool: ToolDef<z.infer<typeof schema>> = {
name: "agent",
description:
"Delegate a task to a sub-agent with its own tool loop (no nested agents). Only the final answer is returned. " +
"For many files, split into multiple sub-agents. Sub-agents have a smaller step budget; if one runs out, " +
"narrow the task rather than retrying.",
"For many files, split into multiple sub-agents — pass a `tasks` array to run several in PARALLEL " +
"(each returns independently; one failure doesn't discard the others). Sub-agents have a smaller " +
"step budget; if one runs out, narrow the task rather than retrying. Mutating tool calls from any " +
"sub-agent still go through the same permission prompts (serialized, so parallel sub-agents that " +
"edit don't race).",
schema,
mutating: false,
handler: async (args, ctx) => {
if (!ctx.runSubAgent) {
const runSubAgent = ctx.runSubAgent;
if (!runSubAgent) {
throw new Error("Sub-agents are not available in this context.");
}
const result = await ctx.runSubAgent(args);
// Parallel batch: run each task as its own sub-agent, concurrently. A single failure surfaces
// as that task's `error` rather than rejecting the whole batch — the model gets every sibling's
// result and can retry just the one that failed instead of paying for all of them again.
if (args.tasks && args.tasks.length > 0) {
const results = await Promise.all(
args.tasks.map(async (t): Promise<SubAgentResult> => {
try {
const result = await runSubAgent({ description: t.description, prompt: t.prompt });
return { description: t.description, result };
} catch (err) {
return { description: t.description, error: (err as Error).message ?? String(err) };
}
}),
);
return { description: args.description, results };
}
// Single sub-agent (the original path).
const result = await runSubAgent({ description: args.description, prompt: args.prompt ?? "" });
return { description: args.description, result };
},
};
+61
View File
@@ -0,0 +1,61 @@
import { z } from "zod";
import type { ToolDef } from "./types.js";
import { getDefinition, getReferences, getDiagnostics } from "../codeintel/lspManager.js";
// All three tools are read-only LSP queries. They share a common shape: point them at a file
// (relative to cwd) and a 1-indexed line/column, and they ask the language server for the answer.
// The server is lazily started on first use per language (tsserver, pyright, gopls, clangd,
// rust-analyzer) and reused across the whole session — see lspManager.ts for the lifecycle.
//
// The error messages from lspManager are written to be actionable (e.g. "install
// typescript-language-server"), so we let them surface verbatim rather than wrapping them — a
// generic "LSP unavailable" would hide the one piece of info the model needs to recover.
const positionSchema = z.object({
path: z
.string()
.describe("File path, relative to the working directory. Must match an extension with a configured LSP server (.ts/.tsx/.js/.jsx/.py/.go/.rs/.c/.cpp/…)."),
line: z.number().int().min(1).describe("1-indexed line number of the symbol to query."),
column: z.number().int().min(1).describe("1-indexed column number of the symbol to query."),
});
export const definitionTool: ToolDef<z.infer<typeof positionSchema>> = {
name: "definition",
description:
"Resolve where a symbol is DEFINED using the language server (LSP). Use when grep finds a call site but you need the actual declaration — e.g. a function/variable/type name at a line:column. Returns one or more file:line:column locations (empty list if the server couldn't resolve it, which is a legitimate 'not found', not an error). Requires the relevant language server on PATH (typescript-language-server, pyright-langserver, gopls, clangd, or rust-analyzer).",
schema: positionSchema,
mutating: false,
handler: async (args, ctx) => getDefinition(args.path, args.line, args.column, ctx.cwd),
};
const referencesSchema = positionSchema.extend({
include_declaration: z
.boolean()
.optional()
.describe("Whether to include the symbol's own declaration among the references. Defaults to true (matches most IDE 'find all references' behavior)."),
});
export const referencesTool: ToolDef<z.infer<typeof referencesSchema>> = {
name: "references",
description:
"Find every reference to a symbol using the language server (LSP) — the same as an IDE's 'find all references'. Use to enumerate all call/usage sites of a function/variable/type at a line:column before a rename or to gauge impact. Returns a list of file:line:column locations. Requires the relevant language server on PATH.",
schema: referencesSchema,
mutating: false,
handler: async (args, ctx) =>
getReferences(args.path, args.line, args.column, ctx.cwd, args.include_declaration ?? true),
};
const diagnosticsSchema = z.object({
path: z
.string()
.describe("File path, relative to the working directory, to check for type/syntax errors."),
});
export const diagnosticsTool: ToolDef<z.infer<typeof diagnosticsSchema>> = {
name: "diagnostics",
description:
"Get the latest type/syntax diagnostics (errors and warnings) the language server has published for a file — equivalent to an editor's Problems panel. Use right after an edit_file/write_file to verify the change didn't introduce a type error, or when `tsc --noEmit`/`pyright` would be the alternative. Forces a document sync first so the snapshot is current. Returns severity (error/warning/information/hint), line, column, and message for each diagnostic. Requires the relevant language server on PATH.",
schema: diagnosticsSchema,
mutating: false,
handler: async (args, ctx) => getDiagnostics(args.path, ctx.cwd),
};
+4
View File
@@ -1,4 +1,5 @@
import { agentTool } from "./agentTool.js";
import { definitionTool, referencesTool, diagnosticsTool } from "./codeIntel.js";
import { bashTool } from "./bash.js";
import { bashKillTool } from "./bashKill.js";
import { bashOutputTool } from "./bashOutput.js";
@@ -17,6 +18,9 @@ export const TOOLS: ToolDef[] = [
readFileTool,
listFilesTool,
grepTool,
definitionTool,
referencesTool,
diagnosticsTool,
webSearchTool,
webFetchTool,
gitStatusTool,
+17 -1
View File
@@ -7,6 +7,20 @@ export interface SubAgentTask {
prompt: string;
}
/** Result of one sub-agent in a parallel batch: either its final text, or the error that
* terminated it (timeout, MaxIterationsError, a thrown tool error, etc.). Kept separate from a
* plain string so the parent model can see at a glance which sub-tasks succeeded and which it
* needs to retry or work around — one failed sub-task shouldn't discard the (potentially
* expensive) results of its siblings. */
export interface SubAgentResult {
description: string;
/** The sub-agent's final text answer, or undefined if it failed before producing one. */
result?: string;
/** Present when the sub-agent failed. A timeout, MaxIterationsError, or any other thrown error
* surfaces here rather than rejecting the whole batch. */
error?: string;
}
export interface SubAgentOverrides {
/** Replaces locode's generic sub-agent system prompt entirely — used by plugin-defined agents
* (agents/*.md) that ship their own identity/instructions instead of the generic "delegate a
@@ -24,7 +38,9 @@ export interface TodoItem {
export interface ToolContext {
cwd: string;
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent`
* tool). Runs a SINGLE sub-agent and returns its final text; a parallel batch is the `agent`
* tool's own responsibility (it calls this once per task). */
runSubAgent?: (task: SubAgentTask, overrides?: SubAgentOverrides) => Promise<string>;
/** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a
* future tool context is built without one — every session-backed context provides it. */
+4
View File
@@ -9,6 +9,7 @@ import { buildSkillTool } from "../../plugins/skillTool.js";
import type { ToolDef } from "../../tools/types.js";
import { flushPendingSaves } from "../../persistence/sessionStore.js";
import { killAllBackgroundJobs } from "../../tools/backgroundJobs.js";
import { shutdownAll as shutdownAllLspServers } from "../../codeintel/lspManager.js";
import { App } from "./App.js";
export interface RunInkAppOptions {
@@ -102,6 +103,9 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
await flushPendingSaves();
// Best-effort: don't leave backgrounded shells (dev servers, watch builds) running as orphans.
await killAllBackgroundJobs();
// Best-effort: shut down any lazily-spawned LSP servers (tsserver, pyright, gopls,
// clangd, rust-analyzer) so they don't outlive locode as orphans.
await shutdownAllLspServers().catch(() => {});
await disconnectAllMcpServers();
// Best-effort — fires on every exit path, so there isn't always a specific session id to attach
// (e.g. exiting from the model picker before a session ever started).