diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 97456ec7d1..3e979dcee7 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -3,7 +3,7 @@ import { createWebSocketChannelServer } from "websocket-channel-server"; import { createDispatcherRpcServer } from "@typeagent/dispatcher-rpc/dispatcher/server"; -import { ClientIO, createDispatcher, RequestId } from "agent-dispatcher"; +import { ClientIO, createDispatcher } from "agent-dispatcher"; import { getInstanceDir, getClientId } from "agent-dispatcher/helpers/data"; import { getDefaultAppAgentProviders, @@ -14,46 +14,94 @@ import { getFsStorageProvider } from "dispatcher-node-providers"; import { ChannelProvider } from "@typeagent/agent-rpc/channel"; import { createClientIORpcClient } from "@typeagent/dispatcher-rpc/clientio/client"; import { createRpc } from "@typeagent/agent-rpc/rpc"; -import { createPromiseWithResolvers } from "@typeagent/common-utils"; import { AgentServerInvokeFunctions, ChannelName, } from "@typeagent/agent-server-protocol"; +import { AsyncLocalStorage } from "async_hooks"; import dotenv from "dotenv"; const envPath = new URL("../../../../.env", import.meta.url); dotenv.config({ path: envPath }); -const nullClientIO: ClientIO = { - clear: () => {}, - exit: () => {}, - setDisplayInfo: () => {}, - setDisplay: () => {}, - appendDisplay: () => {}, - appendDiagnosticData: () => {}, - setDynamicDisplay: () => {}, - askYesNo: async ( - message: string, - requestId: RequestId, - defaultValue: boolean = false, - ) => defaultValue, - proposeAction: async () => undefined, - popupQuestion: async () => { - throw new Error("popupQuestion not implemented"); - }, - notify: () => {}, - openLocalView: () => {}, - closeLocalView: () => {}, - takeAction: (action: string) => { - throw new Error(`Action ${action} not supported`); - }, -}; +// AsyncLocalStorage to track which client is making the current request +const currentClientContext = new AsyncLocalStorage(); async function main() { - const clientIO = { - ...nullClientIO, - }; const instanceDir = getInstanceDir(); + // Track all connected clients and their ClientIO + const connectedClients = new Map< + ChannelProvider, + { clientIO: ClientIO; closeFn: () => void } + >(); + + // Create a routing ClientIO that forwards calls to the current request's client + const routingClientIO: ClientIO = { + clear: (...args) => { + const client = currentClientContext.getStore(); + client?.clear?.(...args); + }, + exit: (...args) => { + const client = currentClientContext.getStore(); + client?.exit?.(...args); + }, + setDisplayInfo: (...args) => { + const client = currentClientContext.getStore(); + client?.setDisplayInfo?.(...args); + }, + setDisplay: (...args) => { + const client = currentClientContext.getStore(); + client?.setDisplay?.(...args); + }, + appendDisplay: (...args) => { + const client = currentClientContext.getStore(); + client?.appendDisplay?.(...args); + }, + appendDiagnosticData: (...args) => { + const client = currentClientContext.getStore(); + client?.appendDiagnosticData?.(...args); + }, + setDynamicDisplay: (...args) => { + const client = currentClientContext.getStore(); + client?.setDynamicDisplay?.(...args); + }, + askYesNo: async (...args) => { + const client = currentClientContext.getStore(); + return client?.askYesNo?.(...args) ?? false; + }, + proposeAction: async (...args) => { + const client = currentClientContext.getStore(); + return client?.proposeAction?.(...args); + }, + popupQuestion: async (...args) => { + const client = currentClientContext.getStore(); + if (!client?.popupQuestion) { + throw new Error("popupQuestion not implemented"); + } + return client.popupQuestion(...args); + }, + notify: (...args) => { + const client = currentClientContext.getStore(); + client?.notify?.(...args); + }, + openLocalView: (...args) => { + const client = currentClientContext.getStore(); + client?.openLocalView?.(...args); + }, + closeLocalView: (...args) => { + const client = currentClientContext.getStore(); + client?.closeLocalView?.(...args); + }, + takeAction: (action: string, data?: unknown) => { + const client = currentClientContext.getStore(); + if (!client?.takeAction) { + throw new Error(`Action ${action} not supported`); + } + return client.takeAction(action, data); + }, + }; + + // Create single shared dispatcher with routing ClientIO const dispatcher = await createDispatcher("agent server", { appAgentProviders: getDefaultAppAgentProviders(instanceDir), persistSession: true, @@ -62,7 +110,7 @@ async function main() { metrics: true, dblogging: false, clientId: getClientId(), - clientIO, + clientIO: routingClientIO, indexingServiceRegistry: await getIndexingServiceRegistry(instanceDir), constructionProvider: getDefaultConstructionProvider(), conversationMemorySettings: { @@ -74,39 +122,15 @@ async function main() { // Ignore dispatcher close requests dispatcher.close = async () => {}; - let currentChannelProvider: ChannelProvider | undefined; - let currentCloseFn: (() => void) | undefined; await createWebSocketChannelServer( { port: 8999 }, (channelProvider, closeFn) => { const invokeFunctions: AgentServerInvokeFunctions = { join: async () => { - if (currentChannelProvider !== undefined) { - if (channelProvider === currentChannelProvider) { - throw new Error("Already joined"); - } - - const promiseWithResolvers = - createPromiseWithResolvers(); - currentChannelProvider.on("disconnect", () => { - promiseWithResolvers.resolve(); - }); - currentCloseFn!(); - await promiseWithResolvers.promise; - } - - if (currentChannelProvider) { - throw new Error("Unable to disconnect"); + if (connectedClients.has(channelProvider)) { + throw new Error("Already joined"); } - currentChannelProvider = channelProvider; - currentCloseFn = closeFn; - channelProvider.on("disconnect", () => { - currentChannelProvider = undefined; - currentCloseFn = undefined; - Object.assign(clientIO, nullClientIO); - }); - const dispatcherChannel = channelProvider.createChannel( ChannelName.Dispatcher, ); @@ -115,8 +139,53 @@ async function main() { ); const clientIORpcClient = createClientIORpcClient(clientIOChannel); - Object.assign(clientIO, clientIORpcClient); - createDispatcherRpcServer(dispatcher, dispatcherChannel); + + // Store this client's ClientIO + connectedClients.set(channelProvider, { + clientIO: clientIORpcClient, + closeFn, + }); + + channelProvider.on("disconnect", () => { + connectedClients.delete(channelProvider); + console.log( + `Client disconnected. Active connections: ${connectedClients.size}`, + ); + }); + + // Wrap the dispatcher RPC server to set context for each request + const wrappedDispatcher = { + ...dispatcher, + processCommand: async ( + command: string, + requestId?: string, + attachments?: string[], + ) => { + return currentClientContext.run( + clientIORpcClient, + () => + dispatcher.processCommand( + command, + requestId, + attachments, + ), + ); + }, + checkCache: async (request: string) => { + return currentClientContext.run( + clientIORpcClient, + () => dispatcher.checkCache(request), + ); + }, + }; + + createDispatcherRpcServer( + wrappedDispatcher as any, + dispatcherChannel, + ); + console.log( + `Client connected. Active connections: ${connectedClients.size}`, + ); }, }; diff --git a/ts/packages/coderWrapper/README.md b/ts/packages/coderWrapper/README.md index 6e2d5af6ef..55d64530cb 100644 --- a/ts/packages/coderWrapper/README.md +++ b/ts/packages/coderWrapper/README.md @@ -38,6 +38,7 @@ coder-wrapper [options] Options: -a, --assistant Specify the assistant to use (default: claude) + -d, --debug Enable debug logging with cache timing information -h, --help Show this help message ``` @@ -49,14 +50,38 @@ coder-wrapper # Explicitly specify Claude coder-wrapper -a claude + +# Enable debug mode to see cache hit/miss timing +coder-wrapper --debug ``` ## How It Works 1. **PTY Wrapper**: Uses `node-pty` to spawn the assistant in a pseudo terminal -2. **Transparent I/O**: All stdin/stdout/stderr is passed through unchanged -3. **Terminal Features**: Supports colors, cursor control, and terminal resizing -4. **Clean Exit**: Handles Ctrl+C and process termination gracefully +2. **Cache Checking**: Intercepts user input and checks TypeAgent cache before forwarding to assistant +3. **Cache Hit**: Executes cached actions and returns results immediately (bypasses assistant) +4. **Cache Miss**: Forwards input to the assistant normally +5. **Transparent I/O**: All stdin/stdout/stderr is passed through unchanged +6. **Terminal Features**: Supports colors, cursor control, and terminal resizing +7. **Clean Exit**: Handles Ctrl+C and process termination gracefully + +### Debug Mode + +When `--debug` is enabled, the wrapper logs: + +- Cache check attempts with command text +- Cache hit/miss status with timing (in milliseconds) +- Whether request was forwarded to assistant +- Total time for cache hits + +Example debug output: + +``` +[CoderWrapper:Debug] Checking cache for: "play hello by adele" +[CoderWrapper:Debug] ✓ Cache HIT (234.56ms) +[Action result displayed here] +[CoderWrapper:Debug] Command completed from cache in 234.56ms +``` ## Architecture @@ -96,13 +121,19 @@ export const ASSISTANT_CONFIGS: Record = { }; ``` +## Features + +- [x] Cache checking before forwarding to assistant +- [x] Debug mode with timing metrics for cache operations +- [x] Transparent PTY passthrough +- [x] Support for multiple CLI assistants + ## Future Enhancements -- [ ] Cache checking before forwarding to assistant -- [ ] Request/response logging -- [ ] Performance metrics -- [ ] Cache hit/miss statistics +- [ ] Request/response logging to file +- [ ] Cumulative cache hit/miss statistics - [ ] Support for intercepting and modifying requests +- [ ] Configuration file support ## Development diff --git a/ts/packages/coderWrapper/package.json b/ts/packages/coderWrapper/package.json index 11d9663a08..e6df5f5ba5 100644 --- a/ts/packages/coderWrapper/package.json +++ b/ts/packages/coderWrapper/package.json @@ -28,6 +28,7 @@ "tsc": "tsc -b" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", "@typeagent/agent-server-client": "workspace:*", "@typeagent/dispatcher-types": "workspace:*", "node-pty": "^1.0.0" diff --git a/ts/packages/coderWrapper/src/cacheClient.ts b/ts/packages/coderWrapper/src/cacheClient.ts new file mode 100644 index 0000000000..d0984bc7f9 --- /dev/null +++ b/ts/packages/coderWrapper/src/cacheClient.ts @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { DebugLogger } from "./debugLogger.js"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Result of a cache check + */ +export interface CacheCheckResult { + hit: boolean; + result?: string; + error?: string; +} + +/** + * Client for checking the TypeAgent cache via MCP server + */ +export class CacheClient { + private client: Client | null = null; + private transport: StdioClientTransport | null = null; + private logger: DebugLogger | null = null; + + constructor(_mcpServerPath?: string, logger?: DebugLogger) { + this.logger = logger || null; + if (this.logger) { + this.logger.log("CacheClient initialized"); + } + } + + /** + * Connect to the MCP server + */ + async connect(): Promise { + if (this.client) { + if (this.logger) { + this.logger.log("Already connected to MCP server"); + } + return; // Already connected + } + + try { + // Resolve MCP server path relative to coderWrapper package + // __dirname points to packages/coderWrapper/dist + // We need to go up to ts root: ../../commandExecutor/dist/server.js + const mcpServerPath = path.resolve( + __dirname, + "..", + "..", + "commandExecutor", + "dist", + "server.js", + ); + + if (this.logger) { + this.logger.log( + `Attempting to connect to MCP server at ${mcpServerPath}`, + ); + } + + // Create transport and client + this.transport = new StdioClientTransport({ + command: "node", + args: [mcpServerPath], + }); + + if (this.logger) { + this.logger.log("StdioClientTransport created"); + } + + this.client = new Client( + { + name: "coder-wrapper-cache-client", + version: "0.0.1", + }, + { + capabilities: {}, + }, + ); + + if (this.logger) { + this.logger.log("MCP Client created, connecting..."); + } + + await this.client.connect(this.transport); + + if (this.logger) { + this.logger.log("Successfully connected to MCP server"); + } + } catch (error) { + if (this.logger) { + this.logger.error("Failed to connect to MCP server", error); + } + throw error; + } + } + + /** + * Check if a request is in the cache and execute it if found + */ + async checkCache(request: string): Promise { + if (this.logger) { + this.logger.log(`checkCache called for request: "${request}"`); + } + + if (!this.client) { + if (this.logger) { + this.logger.log( + "Client not connected, attempting to connect...", + ); + } + try { + await this.connect(); + } catch (error) { + if (this.logger) { + this.logger.error( + "Connection failed during checkCache", + error, + ); + } + return { + hit: false, + error: `Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + if (!this.client) { + if (this.logger) { + this.logger.error("Client is still null after connect attempt"); + } + return { + hit: false, + error: "Failed to connect to MCP server", + }; + } + + try { + if (this.logger) { + this.logger.log( + "Calling MCP execute_command tool with cacheCheck=true", + ); + } + + const result = await this.client.callTool({ + name: "execute_command", + arguments: { + request, + cacheCheck: true, + }, + }); + + if (this.logger) { + this.logger.log( + `MCP tool call completed, result: ${JSON.stringify(result, null, 2)}`, + ); + } + + // Parse the result + if ( + result.content && + Array.isArray(result.content) && + result.content.length > 0 + ) { + const content = result.content[0]; + if (content.type === "text") { + const text = content.text; + + if (text.startsWith("CACHE_HIT:")) { + if (this.logger) { + this.logger.log("Cache HIT detected"); + } + return { + hit: true, + result: text.substring("CACHE_HIT:".length).trim(), + }; + } else if (text.startsWith("CACHE_MISS:")) { + const missReason = text + .substring("CACHE_MISS:".length) + .trim(); + if (this.logger) { + this.logger.log(`Cache MISS: ${missReason}`); + } + return { + hit: false, + error: missReason, + }; + } + } + } + + if (this.logger) { + this.logger.error("Unexpected response format from MCP server"); + } + return { + hit: false, + error: "Unexpected response format", + }; + } catch (error) { + if (this.logger) { + this.logger.error("Cache check error", error); + } + return { + hit: false, + error: `Cache check error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Close the connection to the MCP server + */ + async close(): Promise { + if (this.client) { + await this.client.close(); + this.client = null; + } + if (this.transport) { + await this.transport.close(); + this.transport = null; + } + } +} diff --git a/ts/packages/coderWrapper/src/cli.ts b/ts/packages/coderWrapper/src/cli.ts index b2933e49c1..720dd41815 100644 --- a/ts/packages/coderWrapper/src/cli.ts +++ b/ts/packages/coderWrapper/src/cli.ts @@ -8,10 +8,11 @@ import { getAssistantConfig } from "./assistantConfig.js"; /** * Parse command line arguments */ -function parseArgs(): { assistant: string; help: boolean } { +function parseArgs(): { assistant: string; help: boolean; debug: boolean } { const args = process.argv.slice(2); let assistant = "claude"; let help = false; + let debug = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -19,10 +20,12 @@ function parseArgs(): { assistant: string; help: boolean } { help = true; } else if (arg === "--assistant" || arg === "-a") { assistant = args[++i]; + } else if (arg === "--debug" || arg === "-d") { + debug = true; } } - return { assistant, help }; + return { assistant, help, debug }; } /** @@ -34,6 +37,7 @@ Usage: coder-wrapper [options] Options: -a, --assistant Specify the assistant to use (default: claude) + -d, --debug Enable debug logging with cache timing information -h, --help Show this help message Available assistants: @@ -42,11 +46,12 @@ Available assistants: Examples: coder-wrapper # Use Claude Code (default) coder-wrapper -a claude # Explicitly use Claude Code + coder-wrapper --debug # Enable debug logging Description: Wraps CLI coding assistants in a pseudo terminal with caching support. - The wrapper transparently passes through all I/O to/from the assistant. - Future versions will add TypeAgent cache checking before forwarding requests. + The wrapper checks the TypeAgent cache before forwarding requests to the assistant. + Cache hits are executed and returned immediately without calling the assistant. `); } @@ -54,7 +59,7 @@ Description: * Main CLI entry point */ async function main() { - const { assistant, help } = parseArgs(); + const { assistant, help, debug } = parseArgs(); if (help) { printUsage(); @@ -69,6 +74,11 @@ async function main() { console.log( `[CoderWrapper] Command: ${config.command} ${config.args.join(" ")}`, ); + if (debug) { + console.log( + `[CoderWrapper] Debug mode enabled - cache timing will be logged`, + ); + } console.log( `[CoderWrapper] Press Ctrl+C to exit or type 'exit' in the assistant\n`, ); @@ -77,6 +87,7 @@ async function main() { const wrapper = new PtyWrapper(config, { cols: process.stdout.columns, rows: process.stdout.rows, + debug, }); wrapper.spawn(); diff --git a/ts/packages/coderWrapper/src/debugLogger.ts b/ts/packages/coderWrapper/src/debugLogger.ts new file mode 100644 index 0000000000..0cbc31307c --- /dev/null +++ b/ts/packages/coderWrapper/src/debugLogger.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +/** + * Debug logger that writes to a file + */ +export class DebugLogger { + private logFilePath: string = ""; + private logStream: fs.WriteStream | null = null; + private enabled: boolean; + + constructor(enabled: boolean = false) { + this.enabled = enabled; + + if (enabled) { + // Use ~/.tmp instead of system temp directory + const logDir = path.join( + os.homedir(), + ".tmp", + "typeagent-coder-wrapper", + ); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + this.logFilePath = path.join( + logDir, + `coder-wrapper-${Date.now()}.log`, + ); + this.logStream = fs.createWriteStream(this.logFilePath, { + flags: "a", + }); + this.log(`Debug log started at: ${new Date().toISOString()}`); + this.log(`Log file: ${this.logFilePath}`); + console.log(`[CoderWrapper] Debug log: ${this.logFilePath}`); + } + } + + private formatMessage(message: string): string { + const timestamp = new Date().toISOString(); + return `[${timestamp}] ${message}`; + } + + log(message: string): void { + if (!this.enabled || !this.logStream) { + return; + } + this.logStream.write(this.formatMessage(message) + "\n"); + } + + error(message: string, error?: any): void { + if (!this.enabled || !this.logStream) { + return; + } + const errorDetails = error + ? ` - ${error instanceof Error ? error.message : String(error)}` + : ""; + this.logStream.write( + this.formatMessage(`ERROR: ${message}${errorDetails}`) + "\n", + ); + if (error?.stack) { + this.logStream.write(error.stack + "\n"); + } + } + + getLogFilePath(): string { + return this.logFilePath; + } + + close(): void { + if (this.logStream) { + this.log(`Debug log ended at: ${new Date().toISOString()}`); + this.logStream.end(); + } + } +} diff --git a/ts/packages/coderWrapper/src/index.ts b/ts/packages/coderWrapper/src/index.ts index cfe705c9b7..693ff7d73c 100644 --- a/ts/packages/coderWrapper/src/index.ts +++ b/ts/packages/coderWrapper/src/index.ts @@ -7,3 +7,5 @@ export { ASSISTANT_CONFIGS, getAssistantConfig, } from "./assistantConfig.js"; +export { CacheClient, CacheCheckResult } from "./cacheClient.js"; +export { DebugLogger } from "./debugLogger.js"; diff --git a/ts/packages/coderWrapper/src/ptyWrapper.ts b/ts/packages/coderWrapper/src/ptyWrapper.ts index 0f7c4e013d..7b8709c84b 100644 --- a/ts/packages/coderWrapper/src/ptyWrapper.ts +++ b/ts/packages/coderWrapper/src/ptyWrapper.ts @@ -3,6 +3,8 @@ import * as pty from "node-pty"; import { AssistantConfig } from "./assistantConfig.js"; +import { CacheClient } from "./cacheClient.js"; +import { DebugLogger } from "./debugLogger.js"; /** * Options for the PTY wrapper @@ -12,6 +14,8 @@ export interface PtyWrapperOptions { rows?: number; cwd?: string; env?: Record; + enableCache?: boolean; + debug?: boolean; } /** @@ -21,6 +25,11 @@ export class PtyWrapper { private ptyProcess: pty.IPty | null = null; private readonly config: AssistantConfig; private readonly options: PtyWrapperOptions; + private cacheClient: CacheClient | null = null; + private inputBuffer: string = ""; + private debugLogger: DebugLogger | null = null; + private processingCommand: boolean = false; + private lastInputWasCarriageReturn: boolean = false; constructor(config: AssistantConfig, options: PtyWrapperOptions = {}) { this.config = config; @@ -36,7 +45,26 @@ export class PtyWrapper { rows: options.rows || 30, cwd: options.cwd || process.cwd(), env: { ...cleanEnv, ...config.env, ...options.env }, + enableCache: options.enableCache ?? false, + debug: options.debug ?? false, }; + + // Initialize debug logger if enabled + if (this.options.debug) { + this.debugLogger = new DebugLogger(true); + this.debugLogger.log(`PtyWrapper initialized for ${config.name}`); + } + + // Initialize cache client if enabled + if (this.options.enableCache) { + this.cacheClient = new CacheClient( + undefined, + this.debugLogger || undefined, + ); + if (this.debugLogger) { + this.debugLogger.log("Cache client initialized"); + } + } } /** @@ -80,9 +108,57 @@ export class PtyWrapper { // Handle stdin from the user process.stdin.setRawMode(true); - process.stdin.on("data", (data: Buffer) => { - if (this.ptyProcess) { - this.ptyProcess.write(data.toString()); + process.stdin.on("data", async (data: Buffer) => { + if (!this.ptyProcess) { + return; + } + + const input = data.toString(); + + if (this.debugLogger) { + this.debugLogger.log( + `stdin data received: ${JSON.stringify(input)} (length: ${input.length})`, + ); + } + + // If the last input was \r and this input is \n, skip it (Windows sends both separately) + if (this.lastInputWasCarriageReturn && input === "\n") { + if (this.debugLogger) { + this.debugLogger.log("Skipping \\n that follows \\r"); + } + this.lastInputWasCarriageReturn = false; + return; + } + + // Check for Enter key (carriage return, newline, or both) + if (input === "\r" || input === "\n" || input === "\r\n") { + // Track if this was a \r so we can skip the following \n + this.lastInputWasCarriageReturn = input === "\r"; + + // User pressed Enter - check if we should check cache + if (this.inputBuffer.trim() && this.cacheClient) { + await this.handleCommand(this.inputBuffer.trim()); + this.inputBuffer = ""; + } else { + // No cache or empty input, pass through + this.ptyProcess.write(input); + this.inputBuffer = ""; + } + } else if (input.includes("\r") || input.includes("\n")) { + // Input contains newline but with other characters - pass through + if (this.debugLogger) { + this.debugLogger.log( + `Mixed input with newline detected, passing through: ${JSON.stringify(input)}`, + ); + } + this.ptyProcess.write(input); + this.inputBuffer = ""; + this.lastInputWasCarriageReturn = false; + } else { + // Buffer the input and pass it through to PTY for echo + this.inputBuffer += input; + this.ptyProcess.write(input); + this.lastInputWasCarriageReturn = false; } }); @@ -116,15 +192,138 @@ export class PtyWrapper { this.ptyProcess.resize(cols, rows); } + /** + * Handle a command - check cache first, then forward to assistant if needed + */ + private async handleCommand(command: string): Promise { + // Prevent concurrent execution - skip if already processing + if (this.processingCommand) { + if (this.debugLogger) { + this.debugLogger.log( + `Skipping duplicate command (already processing): "${command}"`, + ); + } + return; + } + + this.processingCommand = true; + try { + await this.handleCommandInternal(command); + } finally { + this.processingCommand = false; + } + } + + private async handleCommandInternal(command: string): Promise { + if (!this.cacheClient || !this.ptyProcess) { + // No cache client or process, just forward + if (this.debugLogger) { + this.debugLogger.log( + "No cache client or pty process, forwarding to assistant", + ); + } + this.ptyProcess?.write("\r"); + return; + } + + // Immediately provide feedback by echoing the command + // This happens BEFORE the cache check so user sees response immediately + const terminalWidth = process.stdout.columns || 80; + const grayColor = "\x1b[90m"; // ANSI gray color + const resetColor = "\x1b[0m"; // Reset to default color + const separator = grayColor + "─".repeat(terminalWidth) + resetColor; + + // Clear current input line and echo the command with prompt prefix (no separator after) + process.stdout.write("\r\x1b[K"); + process.stdout.write(`> ${command}\n`); + + const startTime = performance.now(); + + if (this.debugLogger) { + this.debugLogger.log(`Handling command: "${command}"`); + } + + try { + const cacheResult = await this.cacheClient.checkCache(command); + const elapsedMs = performance.now() - startTime; + + if (cacheResult.hit && cacheResult.result) { + // Cache hit! The command was already echoed above, now print the output + + if (this.debugLogger) { + this.debugLogger.log( + `✓ Cache HIT (${elapsedMs.toFixed(2)}ms) - printing result to terminal`, + ); + // Print timing indicator on its own line BEFORE any separator + process.stdout.write(`(${Math.round(elapsedMs)}ms)\n`); + } + + // Print the result (might be empty for some commands) + if (cacheResult.result.trim()) { + process.stdout.write(cacheResult.result + "\n"); + } + + // Print separator line before prompt (gray) + process.stdout.write(separator + "\n"); + + // Print prompt and immediately save cursor position + process.stdout.write("> "); + process.stdout.write("\x1b7"); // Save cursor position (after prompt) + + // Print separator line after prompt (gray) on next line + process.stdout.write("\n" + separator); + + // Restore cursor to saved position (after the prompt) + process.stdout.write("\x1b8"); // Restore cursor position + } else { + // Cache miss - forward to assistant + if (this.debugLogger) { + this.debugLogger.log( + `✗ Cache MISS (${elapsedMs.toFixed(2)}ms): ${cacheResult.error}`, + ); + this.debugLogger.log("Forwarding to assistant"); + } + + // Forward the command normally + this.ptyProcess.write("\r"); + } + } catch (error) { + const elapsedMs = performance.now() - startTime; + // Error checking cache - fall back to forwarding + if (this.debugLogger) { + this.debugLogger.error( + `Cache check error (${elapsedMs.toFixed(2)}ms)`, + error, + ); + this.debugLogger.log("Forwarding to assistant after error"); + } + this.ptyProcess.write("\r"); + } + } + /** * Kill the assistant process */ kill(signal?: string): void { + if (this.debugLogger) { + this.debugLogger.log("Kill requested"); + } + if (this.ptyProcess) { console.log(`\n[CoderWrapper] Killing ${this.config.name}...`); this.ptyProcess.kill(signal); this.ptyProcess = null; } + // Close cache client + if (this.cacheClient) { + this.cacheClient.close().catch(console.error); + this.cacheClient = null; + } + // Close debug logger + if (this.debugLogger) { + this.debugLogger.close(); + this.debugLogger = null; + } } /** diff --git a/ts/packages/commandExecutor/src/commandServer.ts b/ts/packages/commandExecutor/src/commandServer.ts index be2966bdfc..97bcd89047 100644 --- a/ts/packages/commandExecutor/src/commandServer.ts +++ b/ts/packages/commandExecutor/src/commandServer.ts @@ -21,6 +21,7 @@ import * as os from "os"; function executeCommandRequestSchema() { return { request: z.string(), + cacheCheck: z.boolean().optional(), }; } const ExecuteCommandRequestSchema = z.object(executeCommandRequestSchema()); @@ -41,7 +42,8 @@ class Logger { private logStream: fs.WriteStream; constructor() { - const logDir = path.join(os.tmpdir(), "typeagent-mcp"); + // Use ~/.tmp instead of system temp directory + const logDir = path.join(os.homedir(), ".tmp", "typeagent-mcp"); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } @@ -377,11 +379,74 @@ export class CommandServer { } if (!this.dispatcher) { + // During cache check, return cache miss instead of error to avoid startup race condition messages + if (request.cacheCheck) { + this.logger.log( + "Cache check requested but not connected yet - returning cache miss", + ); + return toolResult( + "CACHE_MISS: Not connected to TypeAgent dispatcher yet", + ); + } const errorMsg = `Cannot execute command: not connected to TypeAgent dispatcher at ${this.agentServerUrl}. Make sure the TypeAgent server is running with: pnpm run start:agent-server`; this.logger.error(errorMsg); return toolResult(errorMsg); } + // If cacheCheck is requested, check cache and execute if hit + if (request.cacheCheck) { + try { + this.logger.log( + `Cache check requested for: ${request.request}`, + ); + + // Clear response collector before cache check + this.responseCollector.messages = []; + + const cacheResult = await this.dispatcher.checkCache( + request.request, + ); + + if (cacheResult?.lastError) { + // Cache miss or error + this.logger.log(`Cache miss: ${cacheResult.lastError}`); + return toolResult(`CACHE_MISS: ${cacheResult.lastError}`); + } + + // Cache hit - actions were executed, return the collected messages + this.logger.log(`Cache hit - executed successfully`); + + if (this.responseCollector.messages.length > 0) { + const response = + this.responseCollector.messages.join("\n\n"); + const processedResponse = await processHtmlImages(response); + // Return with CACHE_HIT prefix for detection (cacheClient strips it) + return toolResult(`CACHE_HIT: ${processedResponse}`); + } + + // Fallback if no messages were collected + return toolResult( + `CACHE_HIT: Successfully executed from cache`, + ); + } catch (error) { + const errorMsg = `Cache check failed: ${error instanceof Error ? error.message : String(error)}`; + this.logger.error(errorMsg); + + // If the error is "Agent channel disconnected", reset the dispatcher to trigger reconnection + if ( + error instanceof Error && + error.message.includes("Agent channel disconnected") + ) { + this.logger.log( + "Dispatcher connection lost, will reconnect on next request", + ); + this.dispatcher = null; + } + + return toolResult(`CACHE_MISS: ${errorMsg}`); + } + } + try { // Clear response collector before processing new command this.responseCollector.messages = []; diff --git a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts index 561b76681b..260f710a29 100644 --- a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts +++ b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts @@ -68,6 +68,51 @@ async function getTemplateCompletion( ); } +async function checkCache( + request: string, + context: CommandHandlerContext, +): Promise { + const agentCache = context.agentCache; + + // Check if cache is enabled + if (!agentCache.isEnabled()) { + return { + lastError: "Cache is not enabled", + }; + } + + // Get active schema names from enabled agents + const activeSchemaNames = context.agents.getActiveSchemas(); + + if (activeSchemaNames.length === 0) { + return { + lastError: "No active agents", + }; + } + + // Attempt to match the request against the cache + const matches = agentCache.match(request, { + wildcard: context.session.getConfig().cache.matchWildcard, + entityWildcard: context.session.getConfig().cache.matchEntityWildcard, + rejectReferences: + context.session.getConfig().explainer.filter.reference.list, + namespaceKeys: agentCache.getNamespaceKeys( + activeSchemaNames, + undefined, + ), + }); + + if (matches.length === 0) { + return { + lastError: "No cache match found", + }; + } + + // Cache hit - execute the command normally to get the full result + // This will execute the cached actions and return proper results through ClientIO + return await processCommand(request, context); +} + /** * Create a instance of the dispatcher. * @@ -87,6 +132,9 @@ export async function createDispatcher( getCommandCompletion(prefix) { return getCommandCompletion(prefix, context); }, + checkCache(request) { + return checkCache(request, context); + }, getDynamicDisplay(appAgentName, type, id) { return getDynamicDisplay(context, appAgentName, type, id); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts index 9bacb608d8..78753a67b0 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherClient.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherClient.ts @@ -25,6 +25,9 @@ export function createDispatcherRpcClient(channel: RpcChannel): Dispatcher { async getCommandCompletion(...args) { return rpc.invoke("getCommandCompletion", ...args); }, + async checkCache(...args) { + return rpc.invoke("checkCache", ...args); + }, async close() { return rpc.invoke("close"); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts index 51236f3e24..e4c8de0718 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherServer.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherServer.ts @@ -26,6 +26,9 @@ export function createDispatcherRpcServer( getCommandCompletion: async (...args) => { return dispatcher.getCommandCompletion(...args); }, + checkCache: async (...args) => { + return dispatcher.checkCache(...args); + }, close: async () => { await dispatcher.close(); }, diff --git a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts index 0a253487b4..15ed680712 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -41,6 +41,8 @@ export type DispatcherInvokeFunctions = { prefix: string, ): Promise; + checkCache(request: string): Promise; + close(): Promise; getStatus(): Promise; diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index f63a2989c0..3c8d2838cc 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -120,5 +120,8 @@ export interface Dispatcher { prefix: string, ): Promise; + // Check if a request can be handled by cache without executing + checkCache(request: string): Promise; + getStatus(): Promise; } diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index fe089cfb3e..c7e8306baa 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -2815,6 +2815,9 @@ importers: packages/coderWrapper: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.0.4 + version: 1.25.2(hono@4.11.3)(zod@4.1.13) '@typeagent/agent-server-client': specifier: workspace:* version: link:../agentServer/client