import type { AgentToolResult, BashToolDetails, ExecOptions, ExtensionAPI, ReadToolDetails, } from "@earendil-works/pi-coding-agent"; import { createBashToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, DEFAULT_MAX_LINES, getLanguageFromPath, highlightCode, isEditToolResult, isWriteToolResult, truncateHead, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { existsSync, readFileSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { extname, resolve } from "node:path"; import { homedir, platform } from "node:os"; import { McpBridge } from "./mcp-bridge.js"; import { loadPiConfig, resolveSuppressedBuiltins } from "./config.js"; import type { CompressionStats } from "./types.js"; const BRIDGE_STARTUP_TIMEOUT_MS = 10_000; const CODE_EXTENSIONS = new Set([ ".rs", ".ts", ".tsx", ".js", ".jsx", ".php", ".py", ".go", ".java", ".c", ".cc", ".cpp", ".cxx", ".cs", ".kt", ".swift", ".rb", ".vue", ".svelte", ".astro", ".html", ".css", ".scss", ".sass", ".less", ".lua", ".zig", ".nim", ".ex", ".exs", ".erl", ".hs", ".ml", ".mli", ".r", ".jl", ".dart", ".scala", ".groovy", ".pl", ".pm", ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd", ".sql", ".graphql", ".gql", ".proto", ".thrift", ".tf", ".hcl", ".nix", ".dhall", ]); const FULL_READ_EXTENSIONS = new Set([ ".md", ".txt", ".json", ".json5", ".yaml", ".yml", ".toml", ".env", ".ini", ".xml", ".lock", ]); const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); const CODE_FULL_READ_MAX_BYTES = 8 * 1024; const CODE_SIGNATURES_MIN_BYTES = 96 * 1024; // Which Pi builtins to suppress is resolved by resolveSuppressedBuiltins (in // config.ts, unit-tested). Settings resolve from (most explicit first): // LEAN_CTX_PI_* env vars, then ~/.pi/agent/extensions/pi-lean-ctx/config.json, // then defaults (issue #344). // mode "additive" (default) — keep Pi builtins, add ctx_* alongside // mode "replace" — disable Pi builtins, only expose ctx_* const PI_CONFIG = loadPiConfig(); const PI_MODE = PI_CONFIG.mode; // Max bytes constant for truncation warnings (same as Pi's DEFAULT_MAX_BYTES) const DEFAULT_MAX_BYTES = 8192; const readModeSchema = Type.Union([ Type.Literal("full"), Type.Literal("map"), Type.Literal("signatures"), ], { description: "Override auto-selection: full (complete content), map (deps+API signatures), signatures (AST only)" }); // Kept field-compatible with the canonical MCP `ctx_read` schema (registry in // rust/src/tools/registered/ctx_read.rs) so the tool looks identical across // harnesses: Codex sees `start_line`/`fresh`, Pi must too (GH #432). `offset` is // the historical Pi alias for `start_line`; both are accepted. const readSchema = Type.Object({ path: Type.String({ description: "Path to the file to read (relative or absolute)" }), mode: Type.Optional(readModeSchema), start_line: Type.Optional(Type.Number({ description: "1-based line to start reading from (alias: offset)" })), offset: Type.Optional(Type.Number({ description: "Alias for start_line — 1-based line to start reading from" })), limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), fresh: Type.Optional(Type.Boolean({ description: "Force a fresh disk re-read, bypassing the session cache" })), }); // `path` is REQUIRED on ls/find/grep (#395): with an optional path these tools // silently fell back to the extension's cwd — an agent working in a different // directory got results from the wrong tree and was derailed. Forcing the // parameter makes the scope an explicit, visible part of every call. const lsSchema = Type.Object({ path: Type.String({ description: "Directory to list. Pass the directory you are working in — there is no cwd fallback." }), limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })), }); const findSchema = Type.Object({ pattern: Type.String({ description: "Glob pattern to match files" }), path: Type.String({ description: "Directory to search in. Pass the directory you are working in — there is no cwd fallback." }), limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), }); const grepSchema = Type.Object({ pattern: Type.String({ description: "Search pattern (regex or literal string)" }), path: Type.String({ description: "Directory or file to search. Pass the directory you are working in — there is no cwd fallback." }), glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts'" })), ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })), literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal string (default: false)" })), context: Type.Optional(Type.Number({ description: "Lines of context around each match (default: 0)" })), limit: Type.Optional(Type.Number({ description: "Maximum number of matches (default: 100)" })), }); const leanCtxSchema = Type.Object({ args: Type.Array( Type.String({ description: "Arguments after 'lean-ctx'. Example: ['overview'] or ['knowledge','recall','Pi']", }), ), }); function shellQuote(value: string): string { if (!value) return "''"; if (/^[A-Za-z0-9_./=:@,+%^-]+$/.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; } // Environment for every lean-ctx subprocess: config.json `env` overrides // (lowest precedence) < the caller's env < the flags lean-ctx must always see. function leanCtxEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { return { ...PI_CONFIG.forwardedEnv, ...base, LEAN_CTX_COMPRESS: "1", LEAN_CTX_SAVINGS_FOOTER: "always", }; } function resolveBinary(): string { const envBin = process.env.LEAN_CTX_BIN; if (envBin && existsSync(envBin)) return envBin; if (PI_CONFIG.binaryOverride && existsSync(PI_CONFIG.binaryOverride)) { return PI_CONFIG.binaryOverride; } const home = homedir(); const isWin = platform() === "win32"; const candidates = isWin ? [ resolve(home, ".cargo", "bin", "lean-ctx.exe"), resolve(home, "AppData", "Local", "lean-ctx", "lean-ctx.exe"), ] : [ resolve(home, ".cargo", "bin", "lean-ctx"), resolve(home, ".local", "bin", "lean-ctx"), "/usr/local/bin/lean-ctx", ]; for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } return "lean-ctx"; } function normalizePathArg(path: string): string { return path.startsWith("@") ? path.slice(1) : path; } async function chooseReadMode(path: string): Promise<"full" | "map" | "signatures"> { const ext = extname(path).toLowerCase(); if (FULL_READ_EXTENSIONS.has(ext)) return "full"; const fileStat = await stat(path); const size = fileStat.size; if (!CODE_EXTENSIONS.has(ext)) return size > 48 * 1024 ? "map" : "full"; if (size >= CODE_SIGNATURES_MIN_BYTES) return "signatures"; if (size >= CODE_FULL_READ_MAX_BYTES) return "map"; return "full"; } async function readSlice(path: string, offset?: number, limit?: number) { const content = await readFile(path, "utf8"); const lines = content.split("\n"); const startLine = offset ? Math.max(0, offset - 1) : 0; const endLine = limit ? startLine + limit : lines.length; const selected = lines.slice(startLine, endLine).join("\n"); const truncation = truncateHead(selected, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, }); return { text: truncation.content, lines: lines.length, truncated: truncation.truncated }; } function estimateTokens(text: string) { return Math.ceil(text.length / 4); } function clampStats(original: number, compressed: number): CompressionStats { const orig = Math.max(0, original); const comp = Math.max(0, Math.min(orig, compressed)); const saved = Math.max(0, orig - comp); const percentSaved = orig > 0 ? Math.round((saved / orig) * 100) : 0; return { originalTokens: orig, compressedTokens: comp, percentSaved }; } function parseLeanCtxOutput(text: string) { const lines = text.replace(/\r\n/g, "\n").split("\n"); let stats: CompressionStats | undefined; const kept: string[] = []; for (const line of lines) { const trimmed = line.trim(); const shellMatch = trimmed.match(/^\[lean-ctx:\s*(\d+)\s*→\s*(\d+)\s*tok,\s*-?(\d+)%\]$/); if (shellMatch) { stats = clampStats(Number(shellMatch[1]), Number(shellMatch[2])); continue; } const savedMatch = trimmed.match(/^\[(\d+)\s+tok saved(?:\s+\((\d+)%\))?\]$/); if (savedMatch) { const saved = Number(savedMatch[1]); const pct = savedMatch[2] ? Number(savedMatch[2]) : 0; if (pct > 0) { const original = Math.round((saved * 100) / pct); stats = clampStats(original, Math.max(0, original - saved)); } else { stats = clampStats(saved, saved); } continue; } kept.push(line); } return { text: kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd(), stats }; } function formatFooter(stats: CompressionStats) { const pct = stats.percentSaved > 0 ? `-${stats.percentSaved}%` : "0%"; return `Compressed ${stats.originalTokens} → ${stats.compressedTokens} tokens (${pct})`; } function withFooter(text: string, opts?: { originalText?: string; limit?: number; always?: boolean; preferEstimate?: boolean; suppressIfNoSaving?: boolean; }) { const parsed = parseLeanCtxOutput(text); const limited = limitLines(parsed.text, opts?.limit); let stats = parsed.stats; if (opts?.originalText !== undefined && (opts.preferEstimate || !stats)) { stats = clampStats(estimateTokens(opts.originalText), estimateTokens(limited.text)); } if (!stats && opts?.always) { const tokens = estimateTokens(limited.text); stats = clampStats(tokens, tokens); } if (!stats) return { text: limited.text, stats: undefined, truncated: limited.truncated }; // On tiny files compression cannot beat the envelope, so a "0%" footer would // be pure overhead — larger payload than the source for no gain (#361). Keep // the computed stats for telemetry (`details.compression`) but drop the // visible footer when nothing was actually saved. if (opts?.suppressIfNoSaving && stats.percentSaved <= 0) { return { text: limited.text, stats, truncated: limited.truncated }; } const footer = formatFooter(stats); const base = limited.text.trimEnd(); return { text: base ? `${base}\n\n${footer}` : footer, stats, truncated: limited.truncated, }; } function limitLines(text: string, limit?: number) { if (!limit || limit <= 0) return { text, truncated: false }; const lines = text.split("\n"); if (lines.length <= limit) return { text, truncated: false }; return { text: lines.slice(0, limit).join("\n") + `\n\n[Output truncated to ${limit} lines]`, truncated: true, }; } function replaceTabs(text: string) { return text.replace(/\t/g, " "); } function trimTrailingEmpty(lines: string[]) { let end = lines.length; while (end > 0 && lines[end - 1] === "") end--; return lines.slice(0, end); } function splitFooter(text: string) { const normalized = text.replace(/\r\n/g, "\n").trimEnd(); const match = normalized.match(/\n\n(Compressed \d+ → \d+ tokens \((?:-?\d+|0)%\))$/); if (!match) return { body: normalized, footer: undefined as string | undefined }; return { body: normalized.slice(0, -match[0].length), footer: match[1] }; } function isMcpAdapterConfigured(): boolean { const home = homedir(); const mcpConfigPaths = [ resolve(home, ".pi", "agent", "mcp.json"), resolve(process.cwd(), ".pi", "mcp.json"), ]; for (const configPath of mcpConfigPaths) { if (!existsSync(configPath)) continue; try { const content = readFileSync(configPath, "utf8"); const json = JSON.parse(content); const servers = json?.mcpServers ?? {}; if ("lean-ctx" in servers) return true; } catch { continue; } } return false; } async function execLeanCtx(pi: ExtensionAPI, args: string[], options?: ExecOptions) { if (options?.signal?.aborted) { throw new Error("lean-ctx command interrupted by host."); } const bin = resolveBinary(); const result = await pi.exec(bin, args, options); if (options?.signal?.aborted || result.killed) { throw new Error("lean-ctx command interrupted by host."); } if (result.code !== 0) { const msg = (result.stderr || result.stdout || `lean-ctx failed: ${args.join(" ")}`).trim(); throw new Error(msg); } return result.stdout; } export default async function (pi: ExtensionAPI) { // pi.exec()'s ExecOptions carries no `env`, so lean-ctx subprocesses inherit // THIS process's environment. Seed it once with the config.json `env` overrides // (issue #344) plus the flags lean-ctx must always see, so every path — pi.exec, // the bash spawnHook, and the MCP bridge — shares one environment. An explicitly // set environment variable always wins over the config file. for (const [key, value] of Object.entries(PI_CONFIG.forwardedEnv)) { if (process.env[key] === undefined) process.env[key] = value; } process.env.LEAN_CTX_COMPRESS = "1"; process.env.LEAN_CTX_SAVINGS_FOOTER ??= "always"; // Defer setActiveTools to session_start — runtime actions aren't available // during extension load. Which Pi builtins to suppress: // - "replace" mode → all five (read/bash/ls/find/grep): expose only ctx_*. // - "additive" + routeShell → just `bash`: route shell through ctx_shell so // build/test output is compressed and metered, while the read/list/search // builtins stay available next to ctx_*. Without this, an agent offered // both `bash` and `ctx_shell` picks the uncompressed native `bash` (the R1 // finding: 102 bash / 0 ctx_shell), so the heaviest addressable surface in // a fix task — make/reproducer/test logs — never reaches the compressor. // - "additive" without routeShell → suppress nothing (keep Pi builtins). const suppressedBuiltins = resolveSuppressedBuiltins(PI_MODE, PI_CONFIG.routeShell); if (suppressedBuiltins.size > 0) { pi.on("session_start", () => { const activeTools = pi.getActiveTools().filter((name) => !suppressedBuiltins.has(name)); pi.setActiveTools(activeTools); }); } // Declared up-front so the ctx_read handler (registered below) can route // through the embedded bridge once it connects. Assigned after the tools are // registered (the bridge is started at the end of this function). let mcpBridge: McpBridge | null = null; // ── Collision-safe registration (#359) ─────────────────────────────────── // lean-ctx must coexist with other Pi extensions (AFT, magic-context). If a // tool name is already claimed, skip it with a warning instead of letting the // whole agent crash on load. Users can also hand a name to another extension // via LEAN_CTX_PI_DISABLE_TOOLS / config.json `disableTools`. All ctx_* tools // below register through this wrapper instead of pi.registerTool directly. const skippedExtensionTools: string[] = []; const disabledExtensionTools: string[] = []; // The exact set of tool names this extension owns locally (CLI-first // replacements). Handed to the embedded bridge so it skips precisely these // MCP namesakes and can never suppress a tool without a local replacement — // the root cause of #409. Recorded for every name we manage, so the set // tracks the registrations automatically instead of a hand-maintained list. const localToolNames = new Set(); const registerTool = ((def: { name?: unknown }): void => { const name = typeof def.name === "string" ? def.name : String(def.name); localToolNames.add(name); if (PI_CONFIG.disabledTools.has(name.toLowerCase())) { disabledExtensionTools.push(name); return; } try { (pi.registerTool as (d: unknown) => void)(def); } catch (err) { const msg = err instanceof Error ? err.message : String(err); skippedExtensionTools.push(name); console.error( `[pi-lean-ctx] Skipped tool "${name}" — already registered elsewhere? (${msg})`, ); } }) as unknown as ExtensionAPI["registerTool"]; const baseBashTool = createBashToolDefinition(process.cwd(), { spawnHook: ({ command, cwd, env }) => { const bin = resolveBinary(); return { command: `${shellQuote(bin)} -c ${shellQuote(command)}`, cwd, env: leanCtxEnv(env), }; }, }); const rawBash = createBashToolDefinition(process.cwd()); const bashSchemaWithRaw = Type.Object({ command: Type.String({ description: "Bash command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds to prevent hanging commands" })), raw: Type.Optional(Type.Boolean({ description: "Skip compression, return full uncompressed output" })), }); // ── ctx_shell (replaces bash) ───────────────────────────────────────── registerTool({ name: "ctx_shell", label: "ctx_shell", description: "Run shell commands. Prefer over native Bash/shell (auto-compressed output). " + "Runs the system POSIX shell ($SHELL, sh/bash-compatible) non-login, non-interactive " + "and profile-free: no .bash_profile/.zshrc/rc files are sourced, so behavior is " + "deterministic regardless of user shell config. " + "IMPORTANT: Do NOT use ctx_shell to read files (cat/head/tail) — use ctx_read instead. " + "Do NOT use ctx_shell for grep/find/ls — use ctx_grep, ctx_find, ctx_ls. " + "Set raw=true to skip compression when exact output matters. " + "Use timeout (seconds) to prevent hanging commands.", promptSnippet: "Run shell commands (not for file reading — use ctx_read)", promptGuidelines: [ "Use ctx_shell only for commands with side effects: build, test, install, git, run scripts.", ], parameters: bashSchemaWithRaw, renderCall(args, theme) { // Render with an explicit `ctx_shell` label instead of delegating to Pi's // bash renderer, whose bare `$` prefix made the call look like a native // bash shell and obscured that this is lean-ctx's profile-free shell (#451). const command = typeof args.command === "string" ? args.command : ""; return new Text( theme.fg("toolTitle", theme.bold("ctx_shell ")) + theme.fg("text", command), 0, 0, ); }, renderResult(result, options, theme, context) { // ctx_shell wraps Pi's bash tool; its renderer is typed for BashToolDetails, // while our result adds compression stats on top of the same shape. return baseBashTool.renderResult ? baseBashTool.renderResult( result as AgentToolResult, options, theme, context, ) : (context.lastComponent ?? new Text("", 0, 0)); }, async execute(toolCallId, params, signal, onUpdate, ctx) { const isRaw = !!params.raw; const toolParams = { command: params.command, timeout: params.timeout }; const tool = isRaw ? rawBash : baseBashTool; try { const result = await tool.execute(toolCallId, toolParams, signal, onUpdate, ctx); const text = result.content?.[0]?.type === "text" ? result.content[0].text : ""; if (isRaw) { return { ...result, content: [{ type: "text", text }], details: { raw: true } }; } const decorated = withFooter(text, { always: true }); return { ...result, content: [{ type: "text", text: decorated.text }], details: { ...(result.details ?? {}), compression: decorated.stats }, }; } catch (error) { if (error instanceof Error) { if (isRaw) throw error; const decorated = withFooter(error.message, { always: true }); throw new Error(decorated.text); } throw error; } }, }); // ── ctx_read (replaces read) ────────────────────────────────────────── const nativeReadTool = createReadToolDefinition(process.cwd()); registerTool({ name: "ctx_read", label: "ctx_read", description: "Read a file. Prefer over native Read/cat/head/tail (cached, compressed). " + "Unchanged re-reads cost ~13 tokens. " + "Auto-selects mode: configs (.yaml/.json/.toml/.env) are always full-read. " + "Code files: full (<8KB), map (8-96KB), signatures (>96KB). " + "Add mode=full to get complete file content. " + "Use offset and limit to read specific line ranges.", promptSnippet: "Read file contents (always use instead of cat)", promptGuidelines: [ "Use ctx_read to inspect file contents instead of cat or less.", "Use mode=full if you need the complete file content.", ], parameters: readSchema, renderCall(args, theme, context) { return nativeReadTool.renderCall ? nativeReadTool.renderCall(args, theme, context) : (context.lastComponent ?? new Text("", 0, 0)); }, renderResult(result, options, theme, context) { if (result.content.some((block) => block.type === "image")) { // Reuse Pi's read renderer for images; its detail type is ReadToolDetails. return nativeReadTool.renderResult ? nativeReadTool.renderResult( result as AgentToolResult, options, theme, context, ) : (context.lastComponent ?? new Text("", 0, 0)); } const textBlock = result.content.find((block) => block.type === "text"); const rawText = textBlock?.type === "text" ? textBlock.text : ""; const { body, footer } = splitFooter(rawText); const rawPath = typeof context.args?.path === "string" ? context.args.path : undefined; const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; const renderedLines = lang ? highlightCode(replaceTabs(body), lang) : body.split("\n"); const lines = trimTrailingEmpty(renderedLines); const maxLines = options.expanded ? lines.length : 10; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; let text = `\n${displayLines .map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) .join("\n")}`; if (remaining > 0) { text += `${theme.fg("muted", `\n... (${remaining} more lines, ctrl+o to expand)`)}`; } const truncation = (result.details as Record | undefined)?.truncation as | { truncated?: boolean; firstLineExceedsLimit?: boolean; truncatedBy?: string; outputLines?: number; totalLines?: number; maxLines?: number; maxBytes?: number } | undefined; if (truncation?.truncated) { if (truncation.firstLineExceedsLimit) { text += `\n${theme.fg("warning", `[First line exceeds ${Math.round((truncation.maxBytes ?? 8192) / 1024)}KB limit]`)}`; } else if (truncation.truncatedBy === "lines") { text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} of ${truncation.totalLines} lines]`)}`; } else { text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines (${Math.round((truncation.maxBytes ?? 8192) / 1024)}KB limit)]`)}`; } } if (footer) { text += `\n\n${theme.fg("muted", footer)}`; } // setText only exists on Text; lastComponent is the wider Component type. const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0); component.setText(text); return component; }, async execute(_toolCallId, params, signal, onUpdate, ctx) { const requestedPath = normalizePathArg(params.path); const absolutePath = resolve(ctx.cwd, requestedPath); // `start_line` is the canonical name; `offset` is the historical Pi alias // (GH #432). `fresh` forces a disk re-read past the session cache. const startOffset = params.offset ?? params.start_line; const forceFresh = params.fresh === true; if (startOffset !== undefined || params.limit !== undefined) { const startLine = startOffset ?? 1; const endLine = params.limit ? startLine + params.limit - 1 : 999999; const mode = `lines:${startLine}-${endLine}`; // Route line-range reads through the bridge too, so re-reading the same // slice hits the session cache instead of re-spawning a CLI per call (#361). if (mcpBridge?.isConnected()) { try { const bridged = await mcpBridge.callTool("ctx_read", { path: absolutePath, mode, ...(forceFresh ? { fresh: true } : {}) }, signal); const bridgedText = bridged.content.map((block) => block.text).join(""); const originalSlice = await readSlice(absolutePath, startOffset, params.limit); const decorated = withFooter(bridgedText, { originalText: originalSlice.text, always: true, preferEstimate: true, suppressIfNoSaving: true }); return { content: [{ type: "text", text: decorated.text }], details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx-bridge", mode, compression: decorated.stats }, }; } catch (err) { console.error(`[pi-lean-ctx] ctx_read(${mode}) bridge call failed, falling back to CLI: ${err}`); } } const args = ["read", absolutePath, "-m", mode, ...(forceFresh ? ["--fresh"] : [])]; try { const output = await execLeanCtx(pi, args, { signal }); const originalSlice = await readSlice(absolutePath, startOffset, params.limit); const decorated = withFooter(output, { originalText: originalSlice.text, always: true, preferEstimate: true, suppressIfNoSaving: true }); return { content: [{ type: "text", text: decorated.text }], details: { path: absolutePath, lines: originalSlice.lines, source: "lean-ctx", mode, compression: decorated.stats }, }; } catch { const sliced = await readSlice(absolutePath, startOffset, params.limit); return { content: [{ type: "text", text: sliced.text }], details: { path: absolutePath, lines: sliced.lines, source: "local-slice-fallback", truncated: sliced.truncated }, }; } } if (IMAGE_EXTENSIONS.has(extname(absolutePath).toLowerCase())) { return nativeReadTool.execute(_toolCallId, { ...params, path: absolutePath }, signal, onUpdate, ctx); } const isExplicitFull = params.mode === "full"; const wantsFresh = forceFresh || isExplicitFull; const mode = params.mode ?? await chooseReadMode(absolutePath); // When the embedded MCP bridge is connected, route the read through it so // the persistent session cache engages: an unchanged re-read then costs // ~13 tokens instead of the full file, and the read registers as a real // CEP session (counted by `lean-ctx gain`). The one-shot CLI path below // spawns a fresh `lean-ctx read` per call and therefore cannot cache // across calls — it is used only as a fallback when the bridge is // unavailable or errors. if (mcpBridge?.isConnected()) { try { const bridged = await mcpBridge.callTool( "ctx_read", { path: absolutePath, mode, ...(wantsFresh ? { fresh: true } : {}) }, signal, ); const bridgedText = bridged.content.map((block) => block.text).join(""); const originalText = await readFile(absolutePath, "utf8"); const decorated = withFooter(bridgedText, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true }); return { content: [{ type: "text", text: decorated.text }], details: { path: absolutePath, source: "lean-ctx-bridge", mode, compression: decorated.stats }, }; } catch (err) { console.error(`[pi-lean-ctx] ctx_read bridge call failed, falling back to CLI: ${err}`); } } const args = ["read", absolutePath, "-m", mode, ...(wantsFresh ? ["--fresh"] : [])]; const output = await execLeanCtx(pi, args, { signal }); const originalText = await readFile(absolutePath, "utf8"); const decorated = withFooter(output, { originalText, always: true, preferEstimate: true, suppressIfNoSaving: true }); return { content: [{ type: "text", text: decorated.text }], details: { path: absolutePath, source: "lean-ctx", mode, compression: decorated.stats }, }; }, }); // Native tool definitions are reused purely for their renderCall: Pi then // shows the invocation with its arguments ("grep /pattern/ in dir") instead // of a bare tool name, making the searched directory visible at a glance // (#395). The args shapes are supersets of the native ones. const nativeLsTool = createLsToolDefinition(process.cwd()); const nativeFindTool = createFindToolDefinition(process.cwd()); const nativeGrepTool = createGrepToolDefinition(process.cwd()); // ── ctx_ls (replaces ls) ────────────────────────────────────────────── registerTool({ name: "ctx_ls", label: "ctx_ls", description: "List a directory. Prefer over native ls (compact, summarized). `path` is required — pass the directory you are working in. Use limit to reduce output size.", promptSnippet: "List directory contents", parameters: lsSchema, renderCall(args, theme, context) { return nativeLsTool.renderCall ? nativeLsTool.renderCall(args, theme, context) : (context.lastComponent ?? new Text("", 0, 0)); }, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const requestedPath = normalizePathArg(params.path); const absolutePath = resolve(ctx.cwd, requestedPath); const output = await execLeanCtx(pi, ["ls", absolutePath], { signal }); const decorated = withFooter(output, { limit: params.limit, always: true }); return { content: [{ type: "text", text: decorated.text }], details: { path: absolutePath, source: "lean-ctx", truncated: decorated.truncated, compression: decorated.stats }, }; }, }); // ── ctx_find (replaces find) ────────────────────────────────────────── registerTool({ name: "ctx_find", label: "ctx_find", description: "Find files by glob. Prefer over native find/fd (gitignore-aware). `path` is required — pass the directory you are working in. Use limit to reduce output size.", promptSnippet: "Find files by glob pattern", parameters: findSchema, renderCall(args, theme, context) { return nativeFindTool.renderCall ? nativeFindTool.renderCall(args, theme, context) : (context.lastComponent ?? new Text("", 0, 0)); }, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const requestedPath = normalizePathArg(params.path); const absolutePath = resolve(ctx.cwd, requestedPath); const output = await execLeanCtx(pi, ["find", params.pattern, absolutePath], { signal }); const decorated = withFooter(output, { limit: params.limit, always: true }); return { content: [{ type: "text", text: decorated.text }], details: { path: absolutePath, pattern: params.pattern, source: "lean-ctx", truncated: decorated.truncated, compression: decorated.stats }, }; }, }); // ── ctx_grep (replaces grep) ────────────────────────────────────────── registerTool({ name: "ctx_grep", label: "ctx_grep", description: "Search code. Prefer over native Grep/ripgrep (compact, ranked). `path` is required — pass the directory you are working in. Use limit to cap matches, context for surrounding lines.", promptSnippet: "Search file contents for patterns", parameters: grepSchema, renderCall(args, theme, context) { return nativeGrepTool.renderCall ? nativeGrepTool.renderCall(args, theme, context) : (context.lastComponent ?? new Text("", 0, 0)); }, async execute(_toolCallId, params, signal, _onUpdate, ctx) { if (signal?.aborted) { throw new Error("lean-ctx grep interrupted by host."); } const requestedPath = normalizePathArg(params.path); const absolutePath = resolve(ctx.cwd, requestedPath); const searchArgs = ["rg", "--line-number", "--color=never"]; if (params.ignoreCase) searchArgs.push("-i"); if (params.literal) searchArgs.push("-F"); if (params.context && params.context > 0) searchArgs.push(`-C${params.context}`); if (params.glob) searchArgs.push("--glob", params.glob); const globalLimit = params.limit && params.limit > 0 ? params.limit : 100; searchArgs.push(params.pattern, absolutePath); const bin = resolveBinary(); const result = await pi.exec(bin, ["-c", ...searchArgs], { signal }); if (signal?.aborted || result.killed) { throw new Error("lean-ctx grep interrupted by host."); } if (result.code >= 2) { const msg = (result.stderr || result.stdout || `lean-ctx grep failed: ${params.pattern}`).trim(); throw new Error(msg); } const MAX_OUTPUT_BYTES = 512 * 1024; let output = result.code === 1 ? "(no matches)" : result.stdout; if (output.length > MAX_OUTPUT_BYTES) { output = output.slice(0, MAX_OUTPUT_BYTES) + "\n\n[Output truncated: exceeded 512 KB byte limit]"; } const decorated = withFooter(output, { limit: globalLimit, always: true }); return { content: [{ type: "text", text: decorated.text }], details: { path: absolutePath, pattern: params.pattern, source: "lean-ctx", compression: decorated.stats }, }; }, }); // ── lean_ctx (CLI passthrough) ──────────────────────────────────────── registerTool({ name: "lean_ctx", label: "lean_ctx", description: "Run lean-ctx CLI directly (CLI-first; no MCP required). " + "Use this for advanced commands like session/knowledge/overview/gain/stats/index/pack.", promptSnippet: "Run lean-ctx CLI directly", parameters: leanCtxSchema, async execute(_toolCallId, params, signal) { const output = await execLeanCtx(pi, params.args, { signal }); return { content: [{ type: "text", text: output.trimEnd() }], details: { source: "lean-ctx", args: params.args }, }; }, }); // ── ctx_edit (local wrapper with full-fidelity schema) ───────────────── const editSchema = Type.Object({ path: Type.String({ description: "File path to edit" }), old_string: Type.Optional( Type.String({ description: "Text to replace (unique unless replace_all=true)" }), ), new_string: Type.String({ description: "Replacement text" }), replace_all: Type.Optional( Type.Boolean({ description: "Replace all occurrences (default false)" }), ), create: Type.Optional( Type.Boolean({ description: "Create file if it does not exist" }), ), }); registerTool({ name: "ctx_edit", label: "ctx_edit", description: "Search-and-replace edit with race-condition guards. " + "old_string must be unique unless replace_all=true. create=true writes new files. " + "For editing code you've read, prefer ctx_patch (hash-anchored). " + "Pi's native edit tool is always available — use ctx_edit when you need TOCTOU " + "protection or lean-ctx cache coherence.", promptSnippet: "Edit file (search-and-replace with TOCTOU guards)", promptGuidelines: [ "Pi's native edit is preferred for most edits (better TUI diff rendering).", "Use ctx_edit when cache coherence matters or for race-condition protection.", ], parameters: editSchema, async execute(_toolCallId, params, signal) { if (!mcpBridge?.isConnected()) { throw new Error( "lean-ctx MCP bridge not connected. Use Pi's native edit tool instead.", ); } const result = await mcpBridge.callTool("ctx_edit", params as Record, signal); const text = result.content.map((b) => b.text).join(""); return { content: [{ type: "text", text }], details: undefined, }; }, }); const enableMcpBridge = PI_CONFIG.enableMcp; const adapterConfigured = isMcpAdapterConfigured(); // An explicit opt-in to the embedded bridge wins over mcp.json detection (#361). // A `lean-ctx` entry in ~/.pi/agent/mcp.json does NOT prove that pi-mcp-adapter // is actually serving it — pi has no native MCP support, and `lean-ctx init // --agent pi` writes that entry by default — so it must not silently disable the // bridge a user explicitly requested via LEAN_CTX_PI_ENABLE_MCP=1 / enableMcp. mcpBridge = enableMcpBridge ? new McpBridge(resolveBinary(), PI_CONFIG.forwardedEnv, { disabledTools: PI_CONFIG.disabledTools, toolPrefix: PI_CONFIG.toolPrefix, localTools: localToolNames, }) : null; if (mcpBridge) { pi.on("session_shutdown", async () => { await mcpBridge?.shutdown(); }); // Invalidate lean-ctx read cache when Pi's native edit/write modifies a // file, so subsequent ctx_read calls return fresh content. pi.on("tool_result", async (event) => { if (!mcpBridge?.isConnected()) return; if (!isEditToolResult(event) && !isWriteToolResult(event)) return; const path = event.input?.path as string | undefined; if (!path) return; try { await mcpBridge.callTool("ctx_session", { action: "invalidate", path, }); } catch { // Best-effort: cache miss on next read is harmless. } }); // GH #1426: await bridge startup so MCP tools are registered before // session_start snapshots the tool list. Bounded timeout prevents a // missing/unresponsive lean-ctx binary from blocking Pi indefinitely. try { await Promise.race([ mcpBridge.start(pi), new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), BRIDGE_STARTUP_TIMEOUT_MS), ), ]); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error( `[pi-lean-ctx] MCP bridge ${msg === "timeout" ? `startup timed out after ${BRIDGE_STARTUP_TIMEOUT_MS / 1000}s` : `startup failed: ${msg}`}. ` + "CLI-backed tools (ctx_read, ctx_shell) remain active; " + "advanced MCP tools (ctx_compose, ctx_search, ctx_callgraph, ctx_patch, ctx_call) are unavailable.", ); } } pi.registerCommand("lean-ctx", { description: "Show lean-ctx status: binary path, MCP bridge, and registered tools", handler: async (_args, ctx) => { const bin = resolveBinary(); const found = existsSync(bin); const status = mcpBridge?.getStatus(); const lines: string[] = []; lines.push(found ? `Binary: ${bin}` : "Binary: NOT FOUND — install: cargo install lean-ctx"); if (PI_CONFIG.loaded) { lines.push(`Config: ${PI_CONFIG.configPath}`); } lines.push(`Mode: ${PI_MODE}`); // The bridge advertises whatever surface this profile requests from the // engine. The lean default includes the anchored editor ctx_patch (#1008); // everything else lives behind ctx_call — point users at the power switch // so nothing looks "missing". const profileHint = PI_CONFIG.toolProfile === "lean" ? ' (lazy core incl. ctx_patch + ctx_call gateway — set "toolProfile": "power" for ctx_edit & the full surface)' : ""; lines.push(`Tool profile: ${PI_CONFIG.toolProfile}${profileHint}`); if (!enableMcpBridge) { lines.push("MCP bridge: disabled (CLI-first)"); lines.push(' Enable: LEAN_CTX_PI_ENABLE_MCP=1 or "enableMcp": true in config.json, then restart Pi'); } else if (status) { lines.push(`MCP bridge: ${status.mode} (${status.connected ? "connected" : "disconnected"})`); lines.push(`Reconnect attempts: ${status.reconnectAttempts}`); lines.push(`MCP tools: ${status.toolCount} registered`); if (status.toolNames.length > 0) { lines.push(` ${status.toolNames.join(", ")}`); } if (adapterConfigured) { lines.push( " Note: ~/.pi/agent/mcp.json also has a lean-ctx entry. The embedded bridge is serving tools; if you additionally run pi-mcp-adapter you may see duplicates.", ); } if (status.lastHungTool) { lines.push(`Last hung tool: ${status.lastHungTool}`); } if (status.lastRetry) { lines.push( `Last retry: ${status.lastRetry.toolName} (${status.lastRetry.reason}) at ${status.lastRetry.timestamp}`, ); } if (status.lastError) { lines.push(`Last bridge error: ${status.lastError}`); } } // Coexistence diagnostics (#359): the active prefix plus which tools we // handed off or skipped, so a user stacking AFT / magic-context can see // the exact split at a glance. const skipped = [...(status?.skippedTools ?? []), ...skippedExtensionTools]; const disabled = [...(status?.disabledTools ?? []), ...disabledExtensionTools]; const prefix = status?.toolPrefix ?? PI_CONFIG.toolPrefix; if (prefix) { lines.push(`Tool prefix: "${prefix}" (bridge tools exposed as ${prefix})`); } if (disabled.length > 0) { lines.push(`Disabled (handed to other extensions): ${disabled.join(", ")}`); } if (skipped.length > 0) { lines.push(`Skipped (name already taken): ${skipped.join(", ")}`); } // Show active ctx_ tools const ctxTools = pi.getActiveTools().filter((n) => n.startsWith("ctx_") || n === "lean_ctx"); if (ctxTools.length > 0) { lines.push(`Active tools: ${ctxTools.join(", ")}`); } const ok = found && (adapterConfigured || !enableMcpBridge || (status?.connected ?? false)); ctx.ui.notify(lines.join("\n"), ok ? "info" : "warning"); }, }); }