diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 7d814b451..d3ea233fe 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,4 +1,8 @@ -interface McpTool { +/** + * Minimal tool interface matching GSD's AgentTool shape. + * Avoids a direct dependency on @gsd/pi-agent-core from this compiled module. + */ +export interface McpToolDef { name: string description: string parameters: Record @@ -17,8 +21,22 @@ interface McpTool { // specifiers dynamically so tsc treats them as `any`. const MCP_PKG = '@modelcontextprotocol/sdk' +/** + * Starts a native MCP (Model Context Protocol) server over stdin/stdout. + * + * This enables GSD's tools (read, write, edit, bash, grep, glob, ls, etc.) + * to be used by external AI clients such as Claude Desktop, VS Code Copilot, + * and any MCP-compatible host. + * + * The server registers all tools from the agent session's tool registry and + * maps MCP tools/list and tools/call requests to GSD tool definitions and + * execution, respectively. + * + * All MCP SDK imports are dynamic to avoid subpath export resolution issues + * with TypeScript's NodeNext module resolution. + */ export async function startMcpServer(options: { - tools: McpTool[] + tools: McpToolDef[] version?: string }): Promise { const { tools, version = '0.0.0' } = options @@ -31,7 +49,8 @@ export async function startMcpServer(options: { const StdioServerTransport = stdioMod.StdioServerTransport const { ListToolsRequestSchema, CallToolRequestSchema } = typesMod - const toolMap = new Map() + // Build a lookup map for fast tool resolution on calls + const toolMap = new Map() for (const tool of tools) { toolMap.set(tool.name, tool) } @@ -41,14 +60,16 @@ export async function startMcpServer(options: { { capabilities: { tools: {} } }, ) + // tools/list — return every registered GSD tool with its JSON Schema parameters server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: tools.map((t: McpTool) => ({ + tools: tools.map((t: McpToolDef) => ({ name: t.name, description: t.description, inputSchema: t.parameters, })), })) + // tools/call — execute the requested tool and return content blocks server.setRequestHandler(CallToolRequestSchema, async (request: any) => { const { name, arguments: args } = request.params const tool = toolMap.get(name) @@ -60,7 +81,14 @@ export async function startMcpServer(options: { } try { - const result = await tool.execute(`mcp-${Date.now()}`, args ?? {}, undefined, undefined) + const result = await tool.execute( + `mcp-${Date.now()}`, + args ?? {}, + undefined, // no AbortSignal + undefined, // no onUpdate callback + ) + + // Convert AgentToolResult content blocks to MCP content format const content = result.content.map((block: any) => { if (block.type === 'text') return { type: 'text' as const, text: block.text ?? '' } if (block.type === 'image') return { type: 'image' as const, data: block.data ?? '', mimeType: block.mimeType ?? 'image/png' } @@ -73,6 +101,7 @@ export async function startMcpServer(options: { } }) + // Connect to stdin/stdout transport const transport = new StdioServerTransport() await server.connect(transport) process.stderr.write(`[gsd] MCP server started (v${version})\n`) diff --git a/src/resources/extensions/gsd/mcp-server.ts b/src/resources/extensions/gsd/mcp-server.ts index 624ef2054..1f62ce7dc 100644 --- a/src/resources/extensions/gsd/mcp-server.ts +++ b/src/resources/extensions/gsd/mcp-server.ts @@ -1,15 +1,24 @@ -// @ts-ignore — @modelcontextprotocol/sdk types may not be in extensions tsconfig -import { Server } from '@modelcontextprotocol/sdk/server' -// @ts-ignore -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio' -// @ts-ignore -import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types' +/** + * MCP (Model Context Protocol) server for the GSD extension. + * + * This module provides the same MCP server functionality as src/mcp-server.ts + * but can be loaded via jiti in the extension runtime context. It enables + * GSD's tools to be used by external AI clients (Claude Desktop, VS Code + * Copilot, etc.) via the MCP standard protocol over stdin/stdout. + */ interface McpTool { name: string description: string parameters: Record - execute(toolCallId: string, params: Record, signal?: AbortSignal, onUpdate?: unknown): Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> }> + execute( + toolCallId: string, + params: Record, + signal?: AbortSignal, + onUpdate?: unknown, + ): Promise<{ + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> + }> } export async function startMcpServer(options: { @@ -18,6 +27,16 @@ export async function startMcpServer(options: { }): Promise { const { tools, version = '0.0.0' } = options + // Dynamic imports — MCP SDK subpath exports use a "./*" wildcard pattern + // that cannot be statically resolved by all TypeScript configurations. + // @ts-ignore + const { Server } = await import('@modelcontextprotocol/sdk/server') + // @ts-ignore + const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js') + // @ts-ignore + const sdkTypes = await import('@modelcontextprotocol/sdk/types') + const { ListToolsRequestSchema, CallToolRequestSchema } = sdkTypes + const toolMap = new Map() for (const tool of tools) { toolMap.set(tool.name, tool) @@ -28,9 +47,10 @@ export async function startMcpServer(options: { { capabilities: { tools: {} } }, ) + // tools/list — return every registered GSD tool with its JSON Schema parameters server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: tools.map((t) => ({ + tools: tools.map((t: McpTool) => ({ name: t.name, description: t.description, inputSchema: t.parameters, @@ -38,6 +58,7 @@ export async function startMcpServer(options: { } }) + // tools/call — execute the requested tool and return content blocks server.setRequestHandler(CallToolRequestSchema, async (request: any) => { const { name, arguments: args } = request.params const tool = toolMap.get(name) @@ -56,15 +77,15 @@ export async function startMcpServer(options: { undefined, ) - const content = result.content.map((block) => { + const content = result.content.map((block: any) => { if (block.type === 'text') { - return { type: 'text' as const, text: block.text } + return { type: 'text' as const, text: block.text ?? '' } } if (block.type === 'image') { return { type: 'image' as const, - data: block.data, - mimeType: block.mimeType, + data: block.data ?? '', + mimeType: block.mimeType ?? 'image/png', } } return { type: 'text' as const, text: JSON.stringify(block) } diff --git a/src/tests/mcp-server.test.ts b/src/tests/mcp-server.test.ts new file mode 100644 index 000000000..9581809dd --- /dev/null +++ b/src/tests/mcp-server.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const projectRoot = join(fileURLToPath(import.meta.url), '..', '..', '..') + +/** + * Resolve dist path as a file:// URL for cross-platform dynamic import. + * On Windows, bare paths like `D:\...\mcp-server.js` fail with + * ERR_UNSUPPORTED_ESM_URL_SCHEME because Node's ESM loader requires + * file:// URLs for absolute paths. + */ +function distUrl(filename: string): string { + return pathToFileURL(join(projectRoot, 'dist', filename)).href +} + +test('mcp-server module imports without errors', async () => { + // Import from the compiled dist output to avoid subpath resolution issues + // that occur when the resolve-ts test hook rewrites .js -> .ts paths. + const mod = await import(distUrl('mcp-server.js')) + assert.ok(mod, 'module should be importable') + assert.strictEqual(typeof mod.startMcpServer, 'function', 'startMcpServer should be a function') +}) + +test('startMcpServer accepts the correct argument shape', async () => { + const { startMcpServer } = await import(distUrl('mcp-server.js')) + + assert.strictEqual(typeof startMcpServer, 'function') + assert.strictEqual(startMcpServer.length, 1, 'startMcpServer should accept one argument') +}) + +test('startMcpServer can be called with mock tools', async () => { + const { startMcpServer } = await import(distUrl('mcp-server.js')) + + // Create a mock tool matching the McpToolDef interface + const mockTool = { + name: 'test_tool', + description: 'A test tool', + parameters: { type: 'object', properties: {} }, + execute: async () => ({ + content: [{ type: 'text', text: 'hello' }], + }), + } + + // Verify the function can be called with the correct signature + // without throwing during argument validation. It will attempt to + // connect to stdin/stdout as an MCP transport, which won't work in + // a test environment, but the Server instance is created successfully. + assert.doesNotThrow(() => { + void startMcpServer({ tools: [mockTool], version: '0.0.0-test' }) + .catch(() => { /* expected: no MCP client on stdin */ }) + }) +}) diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json new file mode 100644 index 000000000..58cb6c7a8 --- /dev/null +++ b/vscode-extension/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "gsd-vscode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gsd-vscode", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/vscode": "^1.95.0", + "typescript": "^5.7.0" + }, + "engines": { + "vscode": "^1.95.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 000000000..9214348ed --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,156 @@ +{ + "name": "gsd-vscode", + "displayName": "GSD - Get Shit Done", + "description": "VS Code integration for the GSD coding agent", + "publisher": "gsd-build", + "version": "0.1.0", + "license": "MIT", + "engines": { + "vscode": "^1.95.0" + }, + "categories": [ + "AI", + "Chat" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "dist/extension.js", + "contributes": { + "commands": [ + { + "command": "gsd.start", + "title": "GSD: Start Agent" + }, + { + "command": "gsd.stop", + "title": "GSD: Stop Agent" + }, + { + "command": "gsd.newSession", + "title": "GSD: New Session" + }, + { + "command": "gsd.sendMessage", + "title": "GSD: Send Message" + }, + { + "command": "gsd.cycleModel", + "title": "GSD: Cycle Model" + }, + { + "command": "gsd.cycleThinking", + "title": "GSD: Cycle Thinking Level" + }, + { + "command": "gsd.compact", + "title": "GSD: Compact Context" + }, + { + "command": "gsd.abort", + "title": "GSD: Abort Current Operation" + }, + { + "command": "gsd.exportHtml", + "title": "GSD: Export Conversation as HTML" + }, + { + "command": "gsd.sessionStats", + "title": "GSD: Show Session Stats" + }, + { + "command": "gsd.runBash", + "title": "GSD: Run Bash Command" + }, + { + "command": "gsd.switchModel", + "title": "GSD: Switch Model" + }, + { + "command": "gsd.setThinking", + "title": "GSD: Set Thinking Level" + }, + { + "command": "gsd.steer", + "title": "GSD: Steer Agent" + }, + { + "command": "gsd.listCommands", + "title": "GSD: List Available Commands" + } + ], + "keybindings": [ + { + "command": "gsd.newSession", + "key": "ctrl+shift+g ctrl+shift+n", + "mac": "cmd+shift+g cmd+shift+n" + }, + { + "command": "gsd.cycleModel", + "key": "ctrl+shift+g ctrl+shift+m", + "mac": "cmd+shift+g cmd+shift+m" + }, + { + "command": "gsd.cycleThinking", + "key": "ctrl+shift+g ctrl+shift+t", + "mac": "cmd+shift+g cmd+shift+t" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "gsd", + "title": "GSD", + "icon": "$(hubot)" + } + ] + }, + "views": { + "gsd": [ + { + "type": "webview", + "id": "gsd-sidebar", + "name": "GSD Agent" + } + ] + }, + "chatParticipants": [ + { + "id": "gsd.agent", + "name": "gsd", + "fullName": "GSD Agent", + "description": "Get Shit Done coding agent", + "isSticky": true + } + ], + "configuration": { + "title": "GSD", + "properties": { + "gsd.binaryPath": { + "type": "string", + "default": "gsd", + "description": "Path to the GSD binary" + }, + "gsd.autoStart": { + "type": "boolean", + "default": false, + "description": "Automatically start the GSD agent when the extension activates" + }, + "gsd.autoCompaction": { + "type": "boolean", + "default": true, + "description": "Enable automatic context compaction" + } + } + } + }, + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "package": "vsce package" + }, + "devDependencies": { + "@types/vscode": "^1.95.0", + "typescript": "^5.7.0" + } +} diff --git a/vscode-extension/src/chat-participant.ts b/vscode-extension/src/chat-participant.ts new file mode 100644 index 000000000..c4ba367df --- /dev/null +++ b/vscode-extension/src/chat-participant.ts @@ -0,0 +1,166 @@ +import * as vscode from "vscode"; +import type { AgentEvent, GsdClient } from "./gsd-client.js"; + +/** + * Registers the @gsd chat participant that forwards messages to the + * GSD RPC client and streams tool execution events back to the chat. + */ +export function registerChatParticipant( + context: vscode.ExtensionContext, + client: GsdClient, +): vscode.Disposable { + const participant = vscode.chat.createChatParticipant("gsd.agent", async ( + request: vscode.ChatRequest, + _chatContext: vscode.ChatContext, + response: vscode.ChatResponseStream, + token: vscode.CancellationToken, + ) => { + if (!client.isConnected) { + response.markdown("GSD agent is not running. Use the **GSD: Start Agent** command first."); + return; + } + + const message = request.prompt; + if (!message.trim()) { + response.markdown("Please provide a message."); + return; + } + + // Track streaming events while the prompt executes + let agentDone = false; + let totalInputTokens = 0; + let totalOutputTokens = 0; + + const eventHandler = (event: AgentEvent) => { + switch (event.type) { + case "agent_start": + response.progress("GSD is working..."); + break; + + case "tool_execution_start": { + const toolName = event.toolName as string; + const toolInput = event.toolInput as Record | undefined; + + let detail = `Running tool: ${toolName}`; + + // Show relevant parameters for common tools + if (toolInput) { + if (toolName === "Read" && toolInput.file_path) { + detail = `Reading: ${toolInput.file_path}`; + } else if (toolName === "Write" && toolInput.file_path) { + detail = `Writing: ${toolInput.file_path}`; + } else if (toolName === "Edit" && toolInput.file_path) { + detail = `Editing: ${toolInput.file_path}`; + } else if (toolName === "Bash" && toolInput.command) { + const cmd = String(toolInput.command); + detail = `Running: $ ${cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd}`; + } else if (toolName === "Glob" && toolInput.pattern) { + detail = `Searching: ${toolInput.pattern}`; + } else if (toolName === "Grep" && toolInput.pattern) { + detail = `Grep: ${toolInput.pattern}`; + } + } + + response.progress(detail); + break; + } + + case "tool_execution_end": { + const toolName = event.toolName as string; + const isError = event.isError as boolean; + if (isError) { + response.markdown(`\n**Tool \`${toolName}\` failed**\n`); + } else { + response.markdown(`\n*Tool \`${toolName}\` completed*\n`); + } + break; + } + + case "message_start": { + // Assistant message starting + break; + } + + case "message_update": { + const assistantEvent = event.assistantMessageEvent as Record | undefined; + if (!assistantEvent) break; + + if (assistantEvent.type === "text_delta") { + const delta = assistantEvent.delta as string | undefined; + if (delta) { + response.markdown(delta); + } + } else if (assistantEvent.type === "thinking_delta") { + // Show thinking content in a collapsed section + const delta = assistantEvent.delta as string | undefined; + if (delta) { + response.markdown(delta); + } + } + break; + } + + case "message_end": { + // Capture token usage from message end events + const usage = event.usage as { inputTokens?: number; outputTokens?: number } | undefined; + if (usage) { + if (usage.inputTokens) totalInputTokens += usage.inputTokens; + if (usage.outputTokens) totalOutputTokens += usage.outputTokens; + } + break; + } + + case "agent_end": + agentDone = true; + break; + } + }; + + const subscription = client.onEvent(eventHandler); + + // Handle cancellation + token.onCancellationRequested(() => { + client.abort().catch(() => {}); + }); + + try { + await client.sendPrompt(message); + + // Wait for agent_end or cancellation + await new Promise((resolve) => { + if (agentDone) { + resolve(); + return; + } + + const checkDone = client.onEvent((evt) => { + if (evt.type === "agent_end") { + checkDone.dispose(); + resolve(); + } + }); + + token.onCancellationRequested(() => { + checkDone.dispose(); + resolve(); + }); + }); + + // Show token usage summary at the end + if (totalInputTokens > 0 || totalOutputTokens > 0) { + response.markdown( + `\n\n---\n*Tokens: ${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out*\n`, + ); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + response.markdown(`\n**Error:** ${errorMessage}\n`); + } finally { + subscription.dispose(); + } + }); + + participant.iconPath = new vscode.ThemeIcon("hubot"); + + return participant; +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 000000000..0f2d2de65 --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,359 @@ +import * as vscode from "vscode"; +import { GsdClient, ThinkingLevel } from "./gsd-client.js"; +import { registerChatParticipant } from "./chat-participant.js"; +import { GsdSidebarProvider } from "./sidebar.js"; + +let client: GsdClient | undefined; +let sidebarProvider: GsdSidebarProvider | undefined; + +function requireConnected(): boolean { + if (!client?.isConnected) { + vscode.window.showWarningMessage("GSD agent is not running."); + return false; + } + return true; +} + +function handleError(err: unknown, context: string): void { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`${context}: ${msg}`); +} + +export function activate(context: vscode.ExtensionContext): void { + const config = vscode.workspace.getConfiguration("gsd"); + const binaryPath = config.get("binaryPath", "gsd"); + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + + client = new GsdClient(binaryPath, cwd); + context.subscriptions.push(client); + + // Log stderr to an output channel + const outputChannel = vscode.window.createOutputChannel("GSD Agent"); + context.subscriptions.push(outputChannel); + + client.onError((msg) => { + outputChannel.appendLine(`[stderr] ${msg}`); + }); + + client.onConnectionChange((connected) => { + if (connected) { + vscode.window.setStatusBarMessage("$(hubot) GSD connected", 3000); + } else { + vscode.window.setStatusBarMessage("$(hubot) GSD disconnected", 3000); + } + }); + + // -- Sidebar ----------------------------------------------------------- + + sidebarProvider = new GsdSidebarProvider(context.extensionUri, client); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + GsdSidebarProvider.viewId, + sidebarProvider, + ), + ); + + // -- Chat participant --------------------------------------------------- + + context.subscriptions.push(registerChatParticipant(context, client)); + + // -- Commands ----------------------------------------------------------- + + // Start + context.subscriptions.push( + vscode.commands.registerCommand("gsd.start", async () => { + try { + await client!.start(); + // Apply auto-compaction setting + const autoCompaction = vscode.workspace.getConfiguration("gsd").get("autoCompaction", true); + await client!.setAutoCompaction(autoCompaction).catch(() => {}); + sidebarProvider?.refresh(); + vscode.window.showInformationMessage("GSD agent started."); + } catch (err) { + handleError(err, "Failed to start GSD"); + } + }), + ); + + // Stop + context.subscriptions.push( + vscode.commands.registerCommand("gsd.stop", async () => { + await client!.stop(); + sidebarProvider?.refresh(); + vscode.window.showInformationMessage("GSD agent stopped."); + }), + ); + + // New Session + context.subscriptions.push( + vscode.commands.registerCommand("gsd.newSession", async () => { + if (!requireConnected()) return; + try { + await client!.newSession(); + sidebarProvider?.refresh(); + vscode.window.showInformationMessage("New GSD session started."); + } catch (err) { + handleError(err, "Failed to start new session"); + } + }), + ); + + // Send Message + context.subscriptions.push( + vscode.commands.registerCommand("gsd.sendMessage", async () => { + if (!requireConnected()) return; + const message = await vscode.window.showInputBox({ + prompt: "Enter message for GSD", + placeHolder: "What should I do?", + }); + if (!message) return; + try { + await client!.sendPrompt(message); + } catch (err) { + handleError(err, "Failed to send message"); + } + }), + ); + + // Abort + context.subscriptions.push( + vscode.commands.registerCommand("gsd.abort", async () => { + if (!requireConnected()) return; + try { + await client!.abort(); + vscode.window.showInformationMessage("Operation aborted."); + } catch (err) { + handleError(err, "Failed to abort"); + } + }), + ); + + // Cycle Model + context.subscriptions.push( + vscode.commands.registerCommand("gsd.cycleModel", async () => { + if (!requireConnected()) return; + try { + const result = await client!.cycleModel(); + if (result) { + vscode.window.showInformationMessage( + `Model: ${result.model.provider}/${result.model.id} (thinking: ${result.thinkingLevel})`, + ); + } else { + vscode.window.showInformationMessage("No other models available."); + } + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to cycle model"); + } + }), + ); + + // Switch Model (QuickPick) + context.subscriptions.push( + vscode.commands.registerCommand("gsd.switchModel", async () => { + if (!requireConnected()) return; + try { + const models = await client!.getAvailableModels(); + if (models.length === 0) { + vscode.window.showInformationMessage("No models available."); + return; + } + const items = models.map((m) => ({ + label: `${m.provider}/${m.id}`, + description: m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k context` : undefined, + provider: m.provider, + modelId: m.id, + })); + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Select a model", + }); + if (!selected) return; + await client!.setModel(selected.provider, selected.modelId); + vscode.window.showInformationMessage(`Model set to ${selected.label}`); + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to switch model"); + } + }), + ); + + // Cycle Thinking Level + context.subscriptions.push( + vscode.commands.registerCommand("gsd.cycleThinking", async () => { + if (!requireConnected()) return; + try { + const result = await client!.cycleThinkingLevel(); + if (result) { + vscode.window.showInformationMessage(`Thinking level: ${result.level}`); + } else { + vscode.window.showInformationMessage("Cannot change thinking level for this model."); + } + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to cycle thinking level"); + } + }), + ); + + // Set Thinking Level (QuickPick) + context.subscriptions.push( + vscode.commands.registerCommand("gsd.setThinking", async () => { + if (!requireConnected()) return; + const levels: ThinkingLevel[] = ["off", "low", "medium", "high"]; + const selected = await vscode.window.showQuickPick(levels, { + placeHolder: "Select thinking level", + }); + if (!selected) return; + try { + await client!.setThinkingLevel(selected as ThinkingLevel); + vscode.window.showInformationMessage(`Thinking level set to ${selected}`); + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to set thinking level"); + } + }), + ); + + // Compact Context + context.subscriptions.push( + vscode.commands.registerCommand("gsd.compact", async () => { + if (!requireConnected()) return; + try { + await client!.compact(); + vscode.window.showInformationMessage("Context compacted."); + sidebarProvider?.refresh(); + } catch (err) { + handleError(err, "Failed to compact context"); + } + }), + ); + + // Export HTML + context.subscriptions.push( + vscode.commands.registerCommand("gsd.exportHtml", async () => { + if (!requireConnected()) return; + try { + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file("gsd-conversation.html"), + filters: { "HTML Files": ["html"] }, + }); + const outputPath = saveUri?.fsPath; + const result = await client!.exportHtml(outputPath); + vscode.window.showInformationMessage(`Conversation exported to ${result.path}`); + } catch (err) { + handleError(err, "Failed to export HTML"); + } + }), + ); + + // Session Stats + context.subscriptions.push( + vscode.commands.registerCommand("gsd.sessionStats", async () => { + if (!requireConnected()) return; + try { + const stats = await client!.getSessionStats(); + const lines: string[] = []; + if (stats.inputTokens !== undefined) lines.push(`Input tokens: ${stats.inputTokens.toLocaleString()}`); + if (stats.outputTokens !== undefined) lines.push(`Output tokens: ${stats.outputTokens.toLocaleString()}`); + if (stats.cacheReadTokens !== undefined) lines.push(`Cache read: ${stats.cacheReadTokens.toLocaleString()}`); + if (stats.cacheWriteTokens !== undefined) lines.push(`Cache write: ${stats.cacheWriteTokens.toLocaleString()}`); + if (stats.totalCost !== undefined) lines.push(`Cost: $${stats.totalCost.toFixed(4)}`); + if (stats.turnCount !== undefined) lines.push(`Turns: ${stats.turnCount}`); + if (stats.messageCount !== undefined) lines.push(`Messages: ${stats.messageCount}`); + if (stats.duration !== undefined) lines.push(`Duration: ${Math.round(stats.duration / 1000)}s`); + + vscode.window.showInformationMessage( + lines.length > 0 ? lines.join(" | ") : "No stats available.", + ); + } catch (err) { + handleError(err, "Failed to get session stats"); + } + }), + ); + + // Run Bash Command + context.subscriptions.push( + vscode.commands.registerCommand("gsd.runBash", async () => { + if (!requireConnected()) return; + const command = await vscode.window.showInputBox({ + prompt: "Enter bash command to execute", + placeHolder: "ls -la", + }); + if (!command) return; + try { + const result = await client!.runBash(command); + outputChannel.appendLine(`[bash] $ ${command}`); + if (result.stdout) outputChannel.appendLine(result.stdout); + if (result.stderr) outputChannel.appendLine(`[stderr] ${result.stderr}`); + outputChannel.appendLine(`[exit code: ${result.exitCode}]`); + outputChannel.show(true); + + if (result.exitCode === 0) { + vscode.window.showInformationMessage("Bash command completed successfully."); + } else { + vscode.window.showWarningMessage(`Bash command exited with code ${result.exitCode}`); + } + } catch (err) { + handleError(err, "Failed to run bash command"); + } + }), + ); + + // Steer Agent + context.subscriptions.push( + vscode.commands.registerCommand("gsd.steer", async () => { + if (!requireConnected()) return; + const message = await vscode.window.showInputBox({ + prompt: "Enter steering message (interrupts current operation)", + placeHolder: "Focus on the error handling instead", + }); + if (!message) return; + try { + await client!.steer(message); + } catch (err) { + handleError(err, "Failed to steer agent"); + } + }), + ); + + // List Available Commands + context.subscriptions.push( + vscode.commands.registerCommand("gsd.listCommands", async () => { + if (!requireConnected()) return; + try { + const commands = await client!.getCommands(); + if (commands.length === 0) { + vscode.window.showInformationMessage("No slash commands available."); + return; + } + const items = commands.map((cmd) => ({ + label: `/${cmd.name}`, + description: cmd.description ?? "", + detail: `Source: ${cmd.source}${cmd.location ? ` (${cmd.location})` : ""}`, + })); + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Available slash commands", + }); + if (selected) { + // Send the selected command as a prompt + await client!.sendPrompt(selected.label); + } + } catch (err) { + handleError(err, "Failed to list commands"); + } + }), + ); + + // -- Auto-start --------------------------------------------------------- + + if (config.get("autoStart", false)) { + vscode.commands.executeCommand("gsd.start"); + } +} + +export function deactivate(): void { + client?.dispose(); + sidebarProvider?.dispose(); + client = undefined; + sidebarProvider = undefined; +} diff --git a/vscode-extension/src/gsd-client.ts b/vscode-extension/src/gsd-client.ts new file mode 100644 index 000000000..20db6d327 --- /dev/null +++ b/vscode-extension/src/gsd-client.ts @@ -0,0 +1,520 @@ +import { ChildProcess, spawn } from "node:child_process"; +import * as vscode from "vscode"; + +/** + * Mirrors the RPC command/response protocol from the GSD agent. + * These types are intentionally kept minimal and self-contained so the + * extension has no dependency on the agent packages at runtime. + */ + +export type ThinkingLevel = "off" | "low" | "medium" | "high"; + +export interface RpcSessionState { + model?: { provider: string; id: string; contextWindow?: number }; + thinkingLevel: ThinkingLevel; + isStreaming: boolean; + isCompacting: boolean; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; + sessionFile?: string; + sessionId: string; + sessionName?: string; + autoCompactionEnabled: boolean; + messageCount: number; + pendingMessageCount: number; +} + +export interface ModelInfo { + provider: string; + id: string; + contextWindow?: number; + reasoning?: boolean; +} + +export interface SessionStats { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + totalCost?: number; + messageCount?: number; + turnCount?: number; + duration?: number; +} + +export interface BashResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +export interface SlashCommand { + name: string; + description?: string; + source: "extension" | "prompt" | "skill"; + location?: "user" | "project" | "path"; + path?: string; +} + +export interface RpcResponse { + id?: string; + type: "response"; + command: string; + success: boolean; + data?: unknown; + error?: string; +} + +export interface AgentEvent { + type: string; + [key: string]: unknown; +} + +type PendingRequest = { + resolve: (response: RpcResponse) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +/** + * Client that spawns `gsd --mode rpc` and communicates via JSON lines + * over stdin/stdout. Emits VS Code events for streaming responses. + */ +export class GsdClient implements vscode.Disposable { + private process: ChildProcess | null = null; + private pendingRequests = new Map(); + private requestId = 0; + private buffer = ""; + private restartCount = 0; + private restartTimestamps: number[] = []; + + private readonly _onEvent = new vscode.EventEmitter(); + readonly onEvent = this._onEvent.event; + + private readonly _onConnectionChange = new vscode.EventEmitter(); + readonly onConnectionChange = this._onConnectionChange.event; + + private readonly _onError = new vscode.EventEmitter(); + readonly onError = this._onError.event; + + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly binaryPath: string, + private readonly cwd: string, + ) { + this.disposables.push(this._onEvent, this._onConnectionChange, this._onError); + } + + get isConnected(): boolean { + return this.process !== null && this.process.exitCode === null; + } + + /** + * Spawn the GSD agent in RPC mode. + */ + async start(): Promise { + if (this.process) { + return; + } + + this.process = spawn(this.binaryPath, ["--mode", "rpc", "--no-session"], { + cwd: this.cwd, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + }); + + this.buffer = ""; + + this.process.stdout?.on("data", (chunk: Buffer) => { + this.buffer += chunk.toString("utf8"); + this.drainBuffer(); + }); + + this.process.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8").trim(); + if (text) { + this._onError.fire(text); + } + }); + + this.process.on("exit", (code, signal) => { + this.process = null; + this.rejectAllPending(`GSD process exited (code=${code}, signal=${signal})`); + this._onConnectionChange.fire(false); + + if (code !== 0 && signal !== "SIGTERM") { + const now = Date.now(); + this.restartTimestamps.push(now); + // Keep only timestamps within the last 60 seconds + this.restartTimestamps = this.restartTimestamps.filter(t => now - t < 60_000); + + if (this.restartTimestamps.length > 3) { + // Too many crashes within 60s — stop retrying + this._onError.fire( + `GSD process crashed ${this.restartTimestamps.length} times within 60s. Not restarting. Use "GSD: Start Agent" to retry manually.`, + ); + } else if (this.restartCount < 3) { + this.restartCount++; + setTimeout(() => this.start(), 1000 * this.restartCount); + } + } + }); + + this._onConnectionChange.fire(true); + this.restartCount = 0; + } + + /** + * Stop the GSD agent process. + */ + async stop(): Promise { + if (!this.process) { + return; + } + + const proc = this.process; + this.process = null; + proc.kill("SIGTERM"); + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + proc.kill("SIGKILL"); + resolve(); + }, 2000); + proc.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + + this.rejectAllPending("Client stopped"); + this._onConnectionChange.fire(false); + } + + // ========================================================================= + // Prompting + // ========================================================================= + + /** + * Send a prompt message to the agent. + * Returns once the command is acknowledged; streaming events follow via onEvent. + */ + async sendPrompt(message: string): Promise { + const response = await this.send({ type: "prompt", message }); + this.assertSuccess(response); + } + + /** + * Interrupt the agent with a steering message while it is streaming. + */ + async steer(message: string): Promise { + const response = await this.send({ type: "steer", message }); + this.assertSuccess(response); + } + + /** + * Send a follow-up message after the agent has completed. + */ + async followUp(message: string): Promise { + const response = await this.send({ type: "follow_up", message }); + this.assertSuccess(response); + } + + /** + * Abort current operation. + */ + async abort(): Promise { + const response = await this.send({ type: "abort" }); + this.assertSuccess(response); + } + + // ========================================================================= + // State + // ========================================================================= + + /** + * Get current session state. + */ + async getState(): Promise { + const response = await this.send({ type: "get_state" }); + this.assertSuccess(response); + return response.data as RpcSessionState; + } + + // ========================================================================= + // Model + // ========================================================================= + + /** + * Set the active model. + */ + async setModel(provider: string, modelId: string): Promise { + const response = await this.send({ type: "set_model", provider, modelId }); + this.assertSuccess(response); + } + + /** + * Get available models. + */ + async getAvailableModels(): Promise { + const response = await this.send({ type: "get_available_models" }); + this.assertSuccess(response); + return (response.data as { models: ModelInfo[] }).models; + } + + /** + * Cycle through available models. + */ + async cycleModel(): Promise<{ model: ModelInfo; thinkingLevel: ThinkingLevel; isScoped: boolean } | null> { + const response = await this.send({ type: "cycle_model" }); + this.assertSuccess(response); + return response.data as { model: ModelInfo; thinkingLevel: ThinkingLevel; isScoped: boolean } | null; + } + + // ========================================================================= + // Thinking + // ========================================================================= + + /** + * Set the thinking level explicitly. + */ + async setThinkingLevel(level: ThinkingLevel): Promise { + const response = await this.send({ type: "set_thinking_level", level }); + this.assertSuccess(response); + } + + /** + * Cycle through thinking levels (off -> low -> medium -> high -> off). + */ + async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> { + const response = await this.send({ type: "cycle_thinking_level" }); + this.assertSuccess(response); + return response.data as { level: ThinkingLevel } | null; + } + + // ========================================================================= + // Compaction + // ========================================================================= + + /** + * Manually compact the conversation context. + */ + async compact(customInstructions?: string): Promise { + const cmd: Record = { type: "compact" }; + if (customInstructions) { + cmd.customInstructions = customInstructions; + } + const response = await this.send(cmd); + this.assertSuccess(response); + return response.data; + } + + /** + * Enable or disable automatic compaction. + */ + async setAutoCompaction(enabled: boolean): Promise { + const response = await this.send({ type: "set_auto_compaction", enabled }); + this.assertSuccess(response); + } + + // ========================================================================= + // Retry + // ========================================================================= + + /** + * Enable or disable automatic retry on failure. + */ + async setAutoRetry(enabled: boolean): Promise { + const response = await this.send({ type: "set_auto_retry", enabled }); + this.assertSuccess(response); + } + + /** + * Abort a pending retry. + */ + async abortRetry(): Promise { + const response = await this.send({ type: "abort_retry" }); + this.assertSuccess(response); + } + + // ========================================================================= + // Bash + // ========================================================================= + + /** + * Execute a bash command via the agent. + */ + async runBash(command: string): Promise { + const response = await this.send({ type: "bash", command }); + this.assertSuccess(response); + return response.data as BashResult; + } + + /** + * Abort a running bash command. + */ + async abortBash(): Promise { + const response = await this.send({ type: "abort_bash" }); + this.assertSuccess(response); + } + + // ========================================================================= + // Session + // ========================================================================= + + /** + * Start a new session. + */ + async newSession(): Promise { + const response = await this.send({ type: "new_session" }); + this.assertSuccess(response); + } + + /** + * Get session statistics (token counts, cost, etc.). + */ + async getSessionStats(): Promise { + const response = await this.send({ type: "get_session_stats" }); + this.assertSuccess(response); + return response.data as SessionStats; + } + + /** + * Export the conversation as HTML. + */ + async exportHtml(outputPath?: string): Promise<{ path: string }> { + const cmd: Record = { type: "export_html" }; + if (outputPath) { + cmd.outputPath = outputPath; + } + const response = await this.send(cmd); + this.assertSuccess(response); + return response.data as { path: string }; + } + + /** + * Switch to a different session file. + */ + async switchSession(sessionPath: string): Promise { + const response = await this.send({ type: "switch_session", sessionPath }); + this.assertSuccess(response); + } + + /** + * Set the display name for the current session. + */ + async setSessionName(name: string): Promise { + const response = await this.send({ type: "set_session_name", name }); + this.assertSuccess(response); + } + + /** + * Get all conversation messages. + */ + async getMessages(): Promise { + const response = await this.send({ type: "get_messages" }); + this.assertSuccess(response); + return (response.data as { messages: unknown[] }).messages; + } + + /** + * Get the text of the last assistant response. + */ + async getLastAssistantText(): Promise { + const response = await this.send({ type: "get_last_assistant_text" }); + this.assertSuccess(response); + return (response.data as { text: string | null }).text; + } + + /** + * List available slash commands. + */ + async getCommands(): Promise { + const response = await this.send({ type: "get_commands" }); + this.assertSuccess(response); + return (response.data as { commands: SlashCommand[] }).commands; + } + + dispose(): void { + this.stop(); + for (const d of this.disposables) { + d.dispose(); + } + } + + // -- Private helpers ------------------------------------------------------ + + private drainBuffer(): void { + while (true) { + const newlineIdx = this.buffer.indexOf("\n"); + if (newlineIdx === -1) { + break; + } + let line = this.buffer.slice(0, newlineIdx); + this.buffer = this.buffer.slice(newlineIdx + 1); + + if (line.endsWith("\r")) { + line = line.slice(0, -1); + } + if (!line) { + continue; + } + this.handleLine(line); + } + } + + private handleLine(line: string): void { + let data: Record; + try { + data = JSON.parse(line); + } catch { + return; // ignore non-JSON lines + } + + // Response to a pending request + if (data.type === "response" && typeof data.id === "string" && this.pendingRequests.has(data.id)) { + const pending = this.pendingRequests.get(data.id)!; + this.pendingRequests.delete(data.id); + clearTimeout(pending.timer); + pending.resolve(data as unknown as RpcResponse); + return; + } + + // Streaming event + this._onEvent.fire(data as AgentEvent); + } + + private send(command: Record): Promise { + if (!this.process?.stdin) { + return Promise.reject(new Error("GSD client not started")); + } + + const id = `req_${++this.requestId}`; + const fullCommand = { ...command, id }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Timeout waiting for response to ${command.type}`)); + }, 30_000); + + this.pendingRequests.set(id, { resolve, reject, timer }); + this.process!.stdin!.write(JSON.stringify(fullCommand) + "\n"); + }); + } + + private assertSuccess(response: RpcResponse): void { + if (!response.success) { + throw new Error(response.error ?? "Unknown RPC error"); + } + } + + private rejectAllPending(reason: string): void { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pendingRequests.clear(); + } +} diff --git a/vscode-extension/src/sidebar.ts b/vscode-extension/src/sidebar.ts new file mode 100644 index 000000000..961c56d0d --- /dev/null +++ b/vscode-extension/src/sidebar.ts @@ -0,0 +1,445 @@ +import * as vscode from "vscode"; +import type { GsdClient, SessionStats, ThinkingLevel } from "./gsd-client.js"; + +/** + * WebviewViewProvider that renders a sidebar panel showing connection status, + * model info, thinking level, token usage, cost, and quick action controls. + */ +export class GsdSidebarProvider implements vscode.WebviewViewProvider { + public static readonly viewId = "gsd-sidebar"; + + private view?: vscode.WebviewView; + private disposables: vscode.Disposable[] = []; + private refreshTimer: ReturnType | undefined; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly client: GsdClient, + ) { + this.disposables.push( + client.onConnectionChange(() => this.refresh()), + client.onEvent((evt) => { + // Refresh on streaming state changes + if (evt.type === "agent_start" || evt.type === "agent_end") { + this.refresh(); + } + }), + ); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + }; + + webviewView.webview.onDidReceiveMessage(async (msg: { command: string; value?: string }) => { + switch (msg.command) { + case "start": + await vscode.commands.executeCommand("gsd.start"); + break; + case "stop": + await vscode.commands.executeCommand("gsd.stop"); + break; + case "newSession": + await vscode.commands.executeCommand("gsd.newSession"); + break; + case "cycleModel": + await vscode.commands.executeCommand("gsd.cycleModel"); + break; + case "cycleThinking": + await vscode.commands.executeCommand("gsd.cycleThinking"); + break; + case "switchModel": + await vscode.commands.executeCommand("gsd.switchModel"); + break; + case "setThinking": + await vscode.commands.executeCommand("gsd.setThinking"); + break; + case "compact": + await vscode.commands.executeCommand("gsd.compact"); + break; + case "abort": + await vscode.commands.executeCommand("gsd.abort"); + break; + case "exportHtml": + await vscode.commands.executeCommand("gsd.exportHtml"); + break; + case "sessionStats": + await vscode.commands.executeCommand("gsd.sessionStats"); + break; + case "listCommands": + await vscode.commands.executeCommand("gsd.listCommands"); + break; + case "toggleAutoCompaction": + if (this.client.isConnected) { + const state = await this.client.getState().catch(() => null); + if (state) { + await this.client.setAutoCompaction(!state.autoCompactionEnabled).catch(() => {}); + this.refresh(); + } + } + break; + } + }); + + // Periodic refresh while connected (for token stats) + this.refreshTimer = setInterval(() => { + if (this.client.isConnected) { + this.refresh(); + } + }, 10_000); + + this.refresh(); + } + + async refresh(): Promise { + if (!this.view) { + return; + } + + let modelName = "N/A"; + let sessionId = "N/A"; + let sessionName = ""; + let messageCount = 0; + let thinkingLevel: ThinkingLevel = "off"; + let isStreaming = false; + let isCompacting = false; + let autoCompaction = false; + let stats: SessionStats | null = null; + + if (this.client.isConnected) { + try { + const state = await this.client.getState(); + modelName = state.model + ? `${state.model.provider}/${state.model.id}` + : "Not set"; + sessionId = state.sessionId; + sessionName = state.sessionName ?? ""; + messageCount = state.messageCount; + thinkingLevel = state.thinkingLevel as ThinkingLevel; + isStreaming = state.isStreaming; + isCompacting = state.isCompacting; + autoCompaction = state.autoCompactionEnabled; + } catch { + // State fetch failed, show defaults + } + + try { + stats = await this.client.getSessionStats(); + } catch { + // Stats fetch failed + } + } + + const connected = this.client.isConnected; + + this.view.webview.html = this.getHtml({ + connected, + modelName, + sessionId, + sessionName, + messageCount, + thinkingLevel, + isStreaming, + isCompacting, + autoCompaction, + stats, + }); + } + + dispose(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + } + for (const d of this.disposables) { + d.dispose(); + } + } + + private getHtml(info: { + connected: boolean; + modelName: string; + sessionId: string; + sessionName: string; + messageCount: number; + thinkingLevel: ThinkingLevel; + isStreaming: boolean; + isCompacting: boolean; + autoCompaction: boolean; + stats: SessionStats | null; + }): string { + const statusColor = info.connected ? "#4ec9b0" : "#f44747"; + const statusText = info.connected + ? info.isStreaming + ? "Processing..." + : info.isCompacting + ? "Compacting..." + : "Connected" + : "Disconnected"; + + const inputTokens = info.stats?.inputTokens?.toLocaleString() ?? "-"; + const outputTokens = info.stats?.outputTokens?.toLocaleString() ?? "-"; + const cost = info.stats?.totalCost !== undefined ? `$${info.stats.totalCost.toFixed(4)}` : "-"; + + const thinkingBadge = info.thinkingLevel !== "off" + ? `${info.thinkingLevel}` + : `off`; + + const autoCompBadge = info.autoCompaction + ? `on` + : `off`; + + const streamingIndicator = info.isStreaming + ? `
Agent is working...
` + : ""; + + const nonce = getNonce(); + + return /* html */ ` + + + + + + + + +
+
+ ${statusText} +
+ + ${streamingIndicator} + +
+
Session
+ + + + + + + + + + + + +
Model${escapeHtml(info.modelName)}
Session${escapeHtml(info.sessionName || info.sessionId)}
Messages${info.messageCount}
Thinking${thinkingBadge}
Auto-compact${autoCompBadge}
+
+ + ${info.connected && info.stats ? ` +
+
Token Usage
+
+ Input + ${inputTokens} + Output + ${outputTokens} + Cost + ${cost} +
+
+ ` : ""} + +
+
Controls
+
+ ${info.connected + ? ` +
+ + +
+
+ + +
` + : `` + } +
+
+ + ${info.connected ? ` +
+
Actions
+
+
+ + +
+
+ + +
+
+
+ ` : ""} + + + +`; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function getNonce(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let i = 0; i < 32; i++) { + nonce += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return nonce; +} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 000000000..1a56af858 --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}