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:
Generated
+33
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user