├── .gitattributes ├── .github ├── copilot-instructions.md └── workflows │ └── publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .releaserc.yml ├── LICENSE ├── README.md ├── docs └── assets │ ├── end-intensive-chat.gif │ ├── end-notification.gif │ ├── normal-question.gif │ └── start-intensive-chat.gif ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── commands │ ├── input │ │ ├── index.ts │ │ └── ui.tsx │ └── intensive-chat │ │ ├── index.ts │ │ └── ui.tsx ├── components │ └── InteractiveInput.tsx ├── constants.ts ├── index.ts ├── tool-definitions │ ├── intensive-chat.ts │ ├── message-complete-notification.ts │ ├── request-user-input.ts │ └── types.ts ├── ui │ └── interactive-input.tsx └── utils │ └── logger.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | ## Interaction with User 2 | 3 | - When you want to ask a question in the middle of your thinking, you could use the MCP tool request_user_input 4 | - Always ask for clarification if you feel uncertain in your response 5 | - Always ask for confirmation if you feel uncertain in your response 6 | - Feel free to ask for more information if you need it 7 | 8 | ## AI Notes - Cursor Documentation System 9 | 10 | - Always read .notes/README.md 11 | - Update the files in .notes folder when you learn something 12 | - when user input "!rmb", do a self reflection and store the key knowledge in .notes folder 13 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release Package 2 | 3 | on: 4 | # push: 5 | # branches: 6 | # - main # Trigger on pushes to the main branch 7 | workflow_dispatch: # Allows manual triggering 8 | inputs: # Add inputs section 9 | release_type: # Add release_type input 10 | description: 'Type of release to perform (stable or rc)' # Add description 11 | required: true # Make it required 12 | default: 'stable' # Default to stable 13 | type: choice # Use choice type 14 | options: # Define options 15 | - stable 16 | - rc 17 | 18 | jobs: 19 | release: 20 | name: Release (${{ github.event.inputs.release_type }}) # Add input to job name for clarity 21 | runs-on: ubuntu-latest 22 | environment: release # This line pauses the job until approved 23 | permissions: 24 | contents: write # Needed to push tags, update package.json, create releases 25 | issues: write # Needed to comment on issues/PRs linked in commits 26 | pull-requests: write # Needed to comment on issues/PRs linked in commits 27 | id-token: write # Needed for npm provenance if enabled 28 | 29 | steps: 30 | - name: Determine Branch 31 | id: determine_branch 32 | run: | 33 | if [[ "${{ github.event.inputs.release_type }}" == "rc" ]]; then 34 | echo "branch=next" >> $GITHUB_OUTPUT 35 | else 36 | echo "branch=main" >> $GITHUB_OUTPUT 37 | fi 38 | 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | with: 42 | ref: ${{ steps.determine_branch.outputs.branch }} # Checkout the determined branch 43 | fetch-depth: 0 # Fetch all history for semantic-release analysis 44 | persist-credentials: false # Recommended for semantic-release 45 | 46 | - name: Setup pnpm 47 | uses: pnpm/action-setup@v4 48 | with: 49 | version: latest 50 | 51 | - name: Setup Node.js 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: '20' 55 | cache: 'pnpm' 56 | 57 | - name: Install dependencies 58 | run: pnpm install --frozen-lockfile 59 | 60 | - name: Build package 61 | run: pnpm run build 62 | 63 | - name: Run semantic-release 64 | run: npx semantic-release 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env 4 | *.log 5 | .vscode/ 6 | localtestscript/ 7 | .cursor 8 | .DS_Store 9 | 10 | # Log files 11 | logs/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run lint-staged 4 | pnpm exec lint-staged 5 | 6 | # Check types (existing command) 7 | pnpm check-types 8 | 9 | # Exit with the status of the last command 10 | exit $? 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "endOfLine": "lf" 8 | } -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | - main 3 | - name: next 4 | prerelease: true 5 | plugins: 6 | - '@semantic-release/commit-analyzer': 7 | preset: conventionalcommits 8 | - '@semantic-release/release-notes-generator': 9 | preset: conventionalcommits 10 | - '@semantic-release/npm' 11 | - '@semantic-release/git': 12 | assets: 13 | - package.json 14 | - pnpm-lock.yaml 15 | message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 16 | - '@semantic-release/github' # Handles creating GitHub Releases 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ttommyth 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # interactive-mcp 2 | 3 | [![npm version](https://img.shields.io/npm/v/interactive-mcp)](https://www.npmjs.com/package/interactive-mcp) [![npm downloads](https://img.shields.io/npm/dm/interactive-mcp)](https://www.npmjs.com/package/interactive-mcp) [![smithery badge](https://smithery.ai/badge/@ttommyth/interactive-mcp)](https://smithery.ai/server/@ttommyth/interactive-mcp) [![GitHub license](https://img.shields.io/github/license/ttommyth/interactive-mcp)](https://github.com/ttommyth/interactive-mcp/blob/main/LICENSE) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) [![Platforms](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-blue)](https://github.com/ttommyth/interactive-mcp) [![GitHub last commit](https://img.shields.io/github/last-commit/ttommyth/interactive-mcp)](https://github.com/ttommyth/interactive-mcp/commits/main) 4 | 5 | [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=interactive&config=eyJjb21tYW5kIjoibnB4IC15IGludGVyYWN0aXZlLW1jcCJ9) 6 | 7 | ![Screenshot 2025-05-13 213745](https://github.com/user-attachments/assets/40208534-5910-4eb2-bfbc-58f7d93aec95) 8 | 9 | A MCP Server implemented in Node.js/TypeScript, facilitating interactive communication between LLMs and users. **Note:** This server is designed to run locally alongside the MCP client (e.g., Claude Desktop, VS Code), as it needs direct access to the user's operating system to display notifications and command-line prompts. 10 | 11 | _(Note: This project is in its early stages.)_ 12 | 13 | **Want a quick overview?** Check out the introductory blog post: [Stop Your AI Assistant From Guessing — Introducing interactive-mcp](https://medium.com/@ttommyth/stop-your-ai-assistant-from-guessing-introducing-interactive-mcp-b42ac6d9b0e2) 14 | 15 | [Demo Video](https://youtu.be/ebwDZdfgSHo) 16 | 17 |
18 | 19 | interactive-mcp MCP server 20 | 21 |
22 | 23 | ## Tools 24 | 25 | This server exposes the following tools via the Model Context Protocol (MCP): 26 | 27 | - `request_user_input`: Asks the user a question and returns their answer. Can display predefined options. 28 | - `message_complete_notification`: Sends a simple OS notification. 29 | - `start_intensive_chat`: Initiates a persistent command-line chat session. 30 | - `ask_intensive_chat`: Asks a question within an active intensive chat session. 31 | - `stop_intensive_chat`: Closes an active intensive chat session. 32 | 33 | ## Demo 34 | 35 | Here are demonstrations of the interactive features: 36 | 37 | | Normal Question | Completion Notification | 38 | | :--------------------------------------------------------: | :-----------------------------------------------------------------: | 39 | | ![Normal Question Demo](./docs/assets/normal-question.gif) | ![Completion Notification Demo](./docs/assets/end-notification.gif) | 40 | 41 | | Intensive Chat Start | Intensive Chat End | 42 | | :------------------------------------------------------------------: | :--------------------------------------------------------------: | 43 | | ![Start Intensive Chat Demo](./docs/assets/start-intensive-chat.gif) | ![End Intensive Chat Demo](./docs/assets/end-intensive-chat.gif) | 44 | 45 | ## Usage Scenarios 46 | 47 | This server is ideal for scenarios where an LLM needs to interact directly with the user on their local machine, such as: 48 | 49 | - Interactive setup or configuration processes. 50 | - Gathering feedback during code generation or modification. 51 | - Clarifying instructions or confirming actions in pair programming. 52 | - Any workflow requiring user input or confirmation during LLM operation. 53 | 54 | ## Client Configuration 55 | 56 | This section explains how to configure MCP clients to use the `interactive-mcp` server. 57 | 58 | By default, user prompts will time out after 30 seconds. You can customize server options like timeout or disabled tools by adding command-line flags directly to the `args` array when configuring your client. 59 | 60 | Please make sure you have the `npx` command available. 61 | 62 | ### Usage with Claude Desktop / Cursor 63 | 64 | Add the following minimal configuration to your `claude_desktop_config.json` (Claude Desktop) or `mcp.json` (Cursor): 65 | 66 | ```json 67 | { 68 | "mcpServers": { 69 | "interactive": { 70 | "command": "npx", 71 | "args": ["-y", "interactive-mcp"] 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | **With specific version** 78 | 79 | ```json 80 | { 81 | "mcpServers": { 82 | "interactive": { 83 | "command": "npx", 84 | "args": ["-y", "interactive-mcp@1.9.0"] 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | **Example with Custom Timeout (30s):** 91 | 92 | ```json 93 | { 94 | "mcpServers": { 95 | "interactive": { 96 | "command": "npx", 97 | "args": ["-y", "interactive-mcp", "-t", "30"] 98 | } 99 | } 100 | } 101 | ``` 102 | 103 | ### Usage with VS Code 104 | 105 | Add the following minimal configuration to your User Settings (JSON) file or `.vscode/mcp.json`: 106 | 107 | ```json 108 | { 109 | "mcp": { 110 | "servers": { 111 | "interactive-mcp": { 112 | "command": "npx", 113 | "args": ["-y", "interactive-mcp"] 114 | } 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | #### macOS Recommendations 121 | 122 | For a smoother experience on macOS using the default `Terminal.app`, consider this profile setting: 123 | 124 | - **(Shell Tab):** Under **"When the shell exits"** (**Terminal > Settings > Profiles > _[Your Profile]_ > Shell**), select **"Close if the shell exited cleanly"** or **"Close the window"**. This helps manage windows when the MCP server starts and stops. 125 | 126 | ## Development Setup 127 | 128 | This section is primarily for developers looking to modify or contribute to the server. If you just want to _use_ the server with an MCP client, see the "Client Configuration" section above. 129 | 130 | ### Prerequisites 131 | 132 | - **Node.js:** Check `package.json` for version compatibility. 133 | - **pnpm:** Used for package management. Install via `npm install -g pnpm` after installing Node.js. 134 | 135 | ### Installation (Developers) 136 | 137 | 1. Clone the repository: 138 | 139 | ```bash 140 | git clone https://github.com/ttommyth/interactive-mcp.git 141 | cd interactive-mcp 142 | ``` 143 | 144 | 2. Install dependencies: 145 | 146 | ```bash 147 | pnpm install 148 | ``` 149 | 150 | ### Running the Application (Developers) 151 | 152 | ```bash 153 | pnpm start 154 | ``` 155 | 156 | #### Command-Line Options 157 | 158 | The `interactive-mcp` server accepts the following command-line options. These should typically be configured in your MCP client's JSON settings by adding them directly to the `args` array (see "Client Configuration" examples). 159 | 160 | | Option | Alias | Description | 161 | | ----------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 162 | | `--timeout` | `-t` | Sets the default timeout (in seconds) for user input prompts. Defaults to 30 seconds. | 163 | | `--disable-tools` | `-d` | Disables specific tools or groups (comma-separated list). Prevents the server from advertising or registering them. Options: `request_user_input`, `message_complete_notification`, `intensive_chat`. | 164 | 165 | **Example:** Setting multiple options in the client config `args` array: 166 | 167 | ```jsonc 168 | // Example combining options in client config's "args": 169 | "args": [ 170 | "-y", "interactive-mcp", 171 | "-t", "30", // Set timeout to 30 seconds 172 | "--disable-tools", "message_complete_notification,intensive_chat" // Disable notifications and intensive chat 173 | ] 174 | ``` 175 | 176 | ## Development Commands 177 | 178 | - **Build:** `pnpm build` 179 | - **Lint:** `pnpm lint` 180 | - **Format:** `pnpm format` 181 | 182 | ## Guiding Principles for Interaction 183 | 184 | When interacting with this MCP server (e.g., as an LLM client), please adhere to the following principles to ensure clarity and reduce unexpected changes: 185 | 186 | - **Prioritize Interaction:** Utilize the provided MCP tools (`request_user_input`, `start_intensive_chat`, etc.) frequently to engage with the user. 187 | - **Seek Clarification:** If requirements, instructions, or context are unclear, **always** ask clarifying questions before proceeding. Do not make assumptions. 188 | - **Confirm Actions:** Before performing significant actions (like modifying files, running complex commands, or making architectural decisions), confirm the plan with the user. 189 | - **Provide Options:** Whenever possible, present the user with predefined options through the MCP tools to facilitate quick decisions. 190 | 191 | You can provide these instructions to an LLM client like this: 192 | 193 | ```markdown 194 | # Interaction 195 | 196 | - Please use the interactive MCP tools 197 | - Please provide options to interactive MCP if possible 198 | 199 | # Reduce Unexpected Changes 200 | 201 | - Do not make assumption. 202 | - Ask more questions before executing, until you think the requirement is clear enough. 203 | ``` 204 | 205 | ## Contributing 206 | 207 | Contributions are welcome! Please follow standard development practices. (Further details can be added later). 208 | 209 | ## License 210 | 211 | MIT (See `LICENSE` file for details - if applicable, or specify license directly). 212 | -------------------------------------------------------------------------------- /docs/assets/end-intensive-chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttommyth/interactive-mcp/cd74350c557d23abdc3c8bcac2ec7ab2b9e5bb03/docs/assets/end-intensive-chat.gif -------------------------------------------------------------------------------- /docs/assets/end-notification.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttommyth/interactive-mcp/cd74350c557d23abdc3c8bcac2ec7ab2b9e5bb03/docs/assets/end-notification.gif -------------------------------------------------------------------------------- /docs/assets/normal-question.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttommyth/interactive-mcp/cd74350c557d23abdc3c8bcac2ec7ab2b9e5bb03/docs/assets/normal-question.gif -------------------------------------------------------------------------------- /docs/assets/start-intensive-chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttommyth/interactive-mcp/cd74350c557d23abdc3c8bcac2ec7ab2b9e5bb03/docs/assets/start-intensive-chat.gif -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | 5 | export default tseslint.config( 6 | { 7 | // Global ignores 8 | ignores: ['node_modules/', 'dist/'], 9 | }, 10 | // Base ESLint recommended rules 11 | eslint.configs.recommended, 12 | // TypeScript rules 13 | ...tseslint.configs.recommended, 14 | // Prettier integration (must be last) 15 | eslintPluginPrettierRecommended, 16 | // Custom project rules 17 | { 18 | // Ignore the config file itself from type-aware linting 19 | ignores: ['eslint.config.js'], 20 | languageOptions: { 21 | parserOptions: { 22 | project: true, // Use tsconfig.json from the root 23 | tsconfigRootDir: import.meta.dirname, // Correctly set root directory for TS project parsing 24 | }, 25 | globals: { 26 | node: true, 27 | es2022: true, 28 | }, 29 | }, 30 | rules: { 31 | // Example: Allow unused vars starting with _ 32 | // '@typescript-eslint/no-unused-vars': [ 33 | // 'warn', 34 | // { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 35 | // ], 36 | '@typescript-eslint/no-unused-vars': 'warn', // Kept the original simple 'warn' rule for now 37 | // 'prettier/prettier': 'error', // This is usually handled by eslintPluginPrettierRecommended now 38 | }, 39 | }, 40 | ); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "interactive-mcp", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "type": "module", 6 | "bin": { 7 | "interactive-mcp": "dist/index.js" 8 | }, 9 | "files": [ 10 | "dist", 11 | "README.md", 12 | "LICENSE", 13 | "package.json" 14 | ], 15 | "scripts": { 16 | "build": "tsc --outDir dist && tsc-alias", 17 | "start": "node dist/index.js", 18 | "lint": "eslint \"src/**/*.{js,ts,jsx,tsx}\"", 19 | "format": "prettier --write \"src/**/*.{js,ts,jsx,tsx,json,md}\"", 20 | "check-types": "tsc --noEmit", 21 | "prepare": "husky" 22 | }, 23 | "keywords": [], 24 | "author": "", 25 | "license": "MIT", 26 | "description": "", 27 | "devDependencies": { 28 | "@eslint/js": "^9.25.1", 29 | "@semantic-release/commit-analyzer": "^13.0.1", 30 | "@semantic-release/git": "^10.0.1", 31 | "@semantic-release/github": "^11.0.2", 32 | "@semantic-release/npm": "^12.0.1", 33 | "@semantic-release/release-notes-generator": "^14.0.3", 34 | "@types/node": "^22.15.2", 35 | "@types/node-notifier": "^8.0.5", 36 | "@types/pino": "^7.0.5", 37 | "@types/react": "^19.1.2", 38 | "@types/react-dom": "^19.1.2", 39 | "conventional-changelog-conventionalcommits": "^8.0.0", 40 | "eslint": "^9.25.1", 41 | "eslint-config-prettier": "^10.1.2", 42 | "eslint-plugin-prettier": "^5.2.6", 43 | "globals": "^16.0.0", 44 | "husky": "^9.1.7", 45 | "jiti": "^2.4.2", 46 | "lint-staged": "^15.5.1", 47 | "pino-pretty": "^13.0.0", 48 | "prettier": "^3.5.3", 49 | "semantic-release": "^24.2.3", 50 | "tsc-alias": "^1.8.15", 51 | "typescript": "^5.8.3", 52 | "typescript-eslint": "^8.31.0" 53 | }, 54 | "dependencies": { 55 | "@inkjs/ui": "^2.0.0", 56 | "@modelcontextprotocol/sdk": "^1.10.2", 57 | "@types/yargs": "^17.0.33", 58 | "ink": "^5.2.0", 59 | "node-notifier": "^10.0.1", 60 | "pino": "^9.6.0", 61 | "react": "^18.3.1", 62 | "react-dom": "^18.3.1", 63 | "yargs": "^17.7.2", 64 | "zod": "^3.24.3" 65 | }, 66 | "lint-staged": { 67 | "*.{js,ts,jsx,tsx}": [ 68 | "eslint --fix" 69 | ], 70 | "*.{js,ts,jsx,tsx,json,md}": [ 71 | "prettier --write" 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/input/index.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import fsPromises from 'fs/promises'; 5 | import { watch, FSWatcher } from 'fs'; 6 | import os from 'os'; 7 | import crypto from 'crypto'; 8 | // Updated import to use @ alias 9 | import { USER_INPUT_TIMEOUT_SECONDS } from '@/constants.js'; // Import the constant 10 | import logger from '../../utils/logger.js'; 11 | 12 | // Get the directory name of the current module 13 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 14 | 15 | // Define cleanupResources outside the promise to be accessible in the final catch 16 | async function cleanupResources( 17 | heartbeatPath: string, 18 | responsePath: string, 19 | optionsPath: string, // Added optionsPath 20 | ) { 21 | await Promise.allSettled([ 22 | fsPromises.unlink(responsePath).catch(() => {}), 23 | fsPromises.unlink(heartbeatPath).catch(() => {}), 24 | fsPromises.unlink(optionsPath).catch(() => {}), // Cleanup options file 25 | // Potentially add cleanup for other session-related files if needed 26 | ]); 27 | } 28 | 29 | /** 30 | * Display a command window with a prompt and return user input 31 | * @param projectName Name of the project requesting input (used for title) 32 | * @param promptMessage Message to display to the user 33 | * @param timeoutSeconds Timeout in seconds 34 | * @param showCountdown Whether to show a countdown timer 35 | * @param predefinedOptions Optional list of predefined options for quick selection 36 | * @returns User input or empty string if timeout 37 | */ 38 | export async function getCmdWindowInput( 39 | projectName: string, 40 | promptMessage: string, 41 | timeoutSeconds: number = USER_INPUT_TIMEOUT_SECONDS, // Use constant as default 42 | showCountdown: boolean = true, 43 | predefinedOptions?: string[], 44 | ): Promise { 45 | // Create a temporary file for the detached process to write to 46 | const sessionId = crypto.randomBytes(8).toString('hex'); 47 | const tempDir = os.tmpdir(); 48 | const tempFilePath = path.join(tempDir, `cmd-ui-response-${sessionId}.txt`); 49 | const heartbeatFilePath = path.join( 50 | tempDir, 51 | `cmd-ui-heartbeat-${sessionId}.txt`, 52 | ); 53 | const optionsFilePath = path.join( 54 | tempDir, 55 | `cmd-ui-options-${sessionId}.json`, 56 | ); // New options file path 57 | 58 | return new Promise((resolve) => { 59 | // Wrap the async setup logic in an IIFE 60 | void (async () => { 61 | // Path to the UI script (will be in the same directory after compilation) 62 | const uiScriptPath = path.join(__dirname, 'ui.js'); 63 | 64 | // Gather options 65 | const options = { 66 | projectName, 67 | prompt: promptMessage, 68 | timeout: timeoutSeconds, 69 | showCountdown, 70 | sessionId, 71 | outputFile: tempFilePath, 72 | heartbeatFile: heartbeatFilePath, // Pass heartbeat file path too 73 | predefinedOptions, 74 | }; 75 | 76 | let ui; 77 | 78 | // Moved setup into try block 79 | try { 80 | // Write options to the file before spawning 81 | await fsPromises.writeFile( 82 | optionsFilePath, 83 | JSON.stringify(options), 84 | 'utf8', 85 | ); 86 | 87 | // Platform-specific spawning 88 | const platform = os.platform(); 89 | 90 | if (platform === 'darwin') { 91 | // macOS 92 | const escapedScriptPath = uiScriptPath; 93 | const escapedSessionId = sessionId; // Only need sessionId now 94 | 95 | // Construct the command string directly for the shell. Quotes handle paths with spaces. 96 | // Pass only the sessionId 97 | const nodeCommand = `exec node "${escapedScriptPath}" "${escapedSessionId}" "${tempDir}"; exit 0`; 98 | 99 | // Escape the node command for osascript's AppleScript string: 100 | const escapedNodeCommand = nodeCommand 101 | .replace(/\\/g, '\\\\') // Escape backslashes 102 | .replace(/"/g, '\\"'); // Escape double quotes 103 | 104 | // Activate Terminal first, then do script with exec 105 | const command = `osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapedNodeCommand}"'`; 106 | const commandArgs: string[] = []; 107 | 108 | ui = spawn(command, commandArgs, { 109 | stdio: ['ignore', 'ignore', 'ignore'], 110 | shell: true, 111 | detached: true, 112 | }); 113 | } else if (platform === 'win32') { 114 | // Windows 115 | // Pass only the sessionId 116 | ui = spawn('node', [uiScriptPath, sessionId], { 117 | stdio: ['ignore', 'ignore', 'ignore'], 118 | shell: true, 119 | detached: true, 120 | windowsHide: false, 121 | }); 122 | } else { 123 | // Linux or other 124 | // Pass only the sessionId 125 | ui = spawn('node', [uiScriptPath, sessionId], { 126 | stdio: ['ignore', 'ignore', 'ignore'], 127 | shell: true, 128 | detached: true, 129 | }); 130 | } 131 | 132 | let watcher: FSWatcher | null = null; 133 | let timeoutHandle: NodeJS.Timeout | null = null; 134 | let heartbeatInterval: NodeJS.Timeout | null = null; 135 | let heartbeatFileSeen = false; // Track if we've ever seen the heartbeat file 136 | const startTime = Date.now(); // Record start time for initial grace period 137 | 138 | // Define cleanupAndResolve inside the promise scope 139 | const cleanupAndResolve = async (response: string) => { 140 | if (heartbeatInterval) { 141 | clearInterval(heartbeatInterval); 142 | heartbeatInterval = null; 143 | } 144 | if (watcher) { 145 | watcher.close(); 146 | watcher = null; 147 | } 148 | if (timeoutHandle) { 149 | clearTimeout(timeoutHandle); 150 | timeoutHandle = null; 151 | } 152 | 153 | // Pass optionsFilePath to cleanupResources 154 | await cleanupResources( 155 | heartbeatFilePath, 156 | tempFilePath, 157 | optionsFilePath, 158 | ); 159 | 160 | resolve(response); 161 | }; 162 | 163 | // Listen for process exit events - moved definition before IIFE start 164 | const handleExit = (code?: number | null) => { 165 | // If the process exited with a non-zero code and watcher/timeout still exist 166 | if (code !== 0 && (watcher || timeoutHandle)) { 167 | void cleanupAndResolve(''); 168 | } 169 | }; 170 | 171 | const handleError = () => { 172 | if (watcher || timeoutHandle) { 173 | // Only cleanup if not already cleaned up 174 | void cleanupAndResolve(''); 175 | } 176 | }; 177 | 178 | ui.on('exit', handleExit); 179 | ui.on('error', handleError); 180 | 181 | // Unref the child process so the parent can exit independently 182 | ui.unref(); 183 | 184 | // Create an empty temp file before watching for user response 185 | await fsPromises.writeFile(tempFilePath, '', 'utf8'); // Use renamed import 186 | 187 | // Wait briefly for the heartbeat file to potentially be created 188 | await new Promise((res) => setTimeout(res, 500)); 189 | 190 | // Watch for content being written to the temp file 191 | watcher = watch(tempFilePath, (eventType: string) => { 192 | // Removed async 193 | if (eventType === 'change') { 194 | // Read the response and cleanup 195 | // Use an async IIFE inside the non-async callback 196 | void (async () => { 197 | try { 198 | const data = await fsPromises.readFile(tempFilePath, 'utf8'); // Use renamed import 199 | if (data) { 200 | const response = data.trim(); 201 | void cleanupAndResolve(response); // Mark promise as intentionally ignored 202 | } 203 | } catch (readError) { 204 | logger.error('Error reading response file:', readError); 205 | void cleanupAndResolve(''); // Cleanup on read error 206 | } 207 | })(); 208 | } 209 | }); 210 | 211 | // Start heartbeat check interval 212 | heartbeatInterval = setInterval(() => { 213 | // Removed async 214 | // Use an async IIFE inside the non-async callback 215 | void (async () => { 216 | try { 217 | const stats = await fsPromises.stat(heartbeatFilePath); // Use renamed import 218 | const now = Date.now(); 219 | // If file hasn't been modified in the last 3 seconds, assume dead 220 | if (now - stats.mtime.getTime() > 3000) { 221 | logger.info( 222 | `Heartbeat file ${heartbeatFilePath} hasn't been updated recently. Process likely exited.`, // Added logger info 223 | ); 224 | void cleanupAndResolve(''); // Mark promise as intentionally ignored 225 | } else { 226 | heartbeatFileSeen = true; // Mark that we've seen the file 227 | } 228 | } catch (err: unknown) { 229 | // Type err as unknown 230 | // Check if err is an error object with a code property 231 | if (err && typeof err === 'object' && 'code' in err) { 232 | const error = err as { code: string }; // Type assertion 233 | if (error.code === 'ENOENT') { 234 | // File not found 235 | if (heartbeatFileSeen) { 236 | // File existed before but is now gone, assume dead 237 | logger.info( 238 | `Heartbeat file ${heartbeatFilePath} not found after being seen. Process likely exited.`, // Added logger info 239 | ); 240 | void cleanupAndResolve(''); // Mark promise as intentionally ignored 241 | } else if (Date.now() - startTime > 7000) { 242 | // File never appeared and initial grace period (7s) passed, assume dead 243 | logger.info( 244 | `Heartbeat file ${heartbeatFilePath} never appeared. Process likely failed to start.`, // Added logger info 245 | ); 246 | void cleanupAndResolve(''); // Mark promise as intentionally ignored 247 | } 248 | // Otherwise, file just hasn't appeared yet, wait longer 249 | } else { 250 | // Removed check for !== 'ENOENT' as it's implied 251 | // Log other errors and resolve 252 | logger.error('Heartbeat check error:', error); 253 | void cleanupAndResolve(''); // Resolve immediately on other errors? Marked promise as intentionally ignored 254 | } 255 | } else { 256 | // Handle cases where err is not an object with a code property 257 | logger.error('Unexpected heartbeat check error:', err); 258 | void cleanupAndResolve(''); // Mark promise as intentionally ignored 259 | } 260 | } 261 | })(); 262 | }, 1500); // Check every 1.5 seconds 263 | 264 | // Timeout to stop watching if no response within limit 265 | timeoutHandle = setTimeout( 266 | () => { 267 | logger.info( 268 | `Input timeout reached after ${timeoutSeconds} seconds.`, 269 | ); // Added logger info 270 | void cleanupAndResolve(''); // Mark promise as intentionally ignored 271 | }, 272 | timeoutSeconds * 1000 + 5000, 273 | ); // Add a bit more buffer 274 | } catch (setupError) { 275 | logger.error('Error during cmd-input setup:', setupError); 276 | // Ensure cleanup happens even if setup fails 277 | // Pass optionsFilePath to cleanupResources 278 | await cleanupResources( 279 | heartbeatFilePath, 280 | tempFilePath, 281 | optionsFilePath, 282 | ); 283 | resolve(''); // Resolve with empty string after attempting cleanup 284 | } 285 | })(); // Execute the IIFE 286 | }); 287 | } 288 | -------------------------------------------------------------------------------- /src/commands/input/ui.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useEffect } from 'react'; 2 | import { render, Box, Text, useApp } from 'ink'; 3 | import { ProgressBar } from '@inkjs/ui'; 4 | import fs from 'fs/promises'; 5 | import path from 'path'; // Import path module 6 | import os from 'os'; // Import os module for tmpdir 7 | import logger from '../../utils/logger.js'; 8 | import { InteractiveInput } from '../../components/InteractiveInput.js'; // Import shared component 9 | 10 | interface CmdOptions { 11 | projectName?: string; 12 | prompt: string; 13 | timeout: number; 14 | showCountdown: boolean; 15 | sessionId: string; // Should always be present now 16 | outputFile: string; // Should always be present now 17 | heartbeatFile: string; // Should always be present now 18 | predefinedOptions?: string[]; 19 | } 20 | 21 | // Define defaults separately 22 | const defaultOptions = { 23 | prompt: 'Enter your response:', 24 | timeout: 30, 25 | showCountdown: false, 26 | projectName: undefined, 27 | predefinedOptions: undefined, 28 | }; 29 | 30 | // Function to read options from the file specified by sessionId 31 | const readOptionsFromFile = async (): Promise => { 32 | const args = process.argv.slice(2); 33 | const sessionId = args[0]; 34 | 35 | if (!sessionId) { 36 | logger.error('No sessionId provided. Exiting.'); 37 | throw new Error('No sessionId provided'); // Throw error to prevent proceeding 38 | } 39 | 40 | let tempDir = args[1]; 41 | if (!tempDir) { 42 | tempDir = os.tmpdir(); 43 | } 44 | 45 | const optionsFilePath = path.join( 46 | tempDir, 47 | `cmd-ui-options-${sessionId}.json`, 48 | ); 49 | 50 | try { 51 | const optionsData = await fs.readFile(optionsFilePath, 'utf8'); 52 | const parsedOptions = JSON.parse(optionsData) as Partial; // Parse as partial 53 | 54 | // Validate required fields after parsing 55 | if ( 56 | !parsedOptions.sessionId || 57 | !parsedOptions.outputFile || 58 | !parsedOptions.heartbeatFile 59 | ) { 60 | throw new Error('Required options missing in options file.'); 61 | } 62 | 63 | // Merge defaults with parsed options, ensuring required fields are fully typed 64 | return { 65 | ...defaultOptions, 66 | ...parsedOptions, 67 | sessionId: parsedOptions.sessionId, // Ensure these are strings 68 | outputFile: parsedOptions.outputFile, 69 | heartbeatFile: parsedOptions.heartbeatFile, 70 | } as CmdOptions; 71 | } catch (error) { 72 | logger.error( 73 | `Failed to read or parse options file ${optionsFilePath}:`, 74 | error instanceof Error ? error.message : error, 75 | ); 76 | // Re-throw to ensure the calling code knows initialization failed 77 | throw error; 78 | } 79 | }; 80 | 81 | // Function to write response to output file if provided 82 | const writeResponseToFile = async (outputFile: string, response: string) => { 83 | if (!outputFile) return; 84 | // write file in UTF-8 format, errors propagate to caller 85 | await fs.writeFile(outputFile, response, 'utf8'); 86 | }; 87 | 88 | // Global state for options and exit handler setup 89 | let options: CmdOptions | null = null; 90 | let exitHandlerAttached = false; 91 | 92 | // Async function to initialize options and setup exit handlers 93 | async function initialize() { 94 | try { 95 | options = await readOptionsFromFile(); 96 | // Setup exit handlers only once after options are successfully read 97 | if (!exitHandlerAttached) { 98 | const handleExit = () => { 99 | if (options && options.outputFile) { 100 | // Write empty string to indicate abnormal exit (e.g., Ctrl+C) 101 | writeResponseToFile(options.outputFile, '') 102 | .catch((error) => { 103 | logger.error('Failed to write exit file:', error); 104 | }) 105 | .finally(() => process.exit(0)); // Exit gracefully after attempting write 106 | } else { 107 | process.exit(0); 108 | } 109 | }; 110 | 111 | process.on('SIGINT', handleExit); 112 | process.on('SIGTERM', handleExit); 113 | process.on('beforeExit', handleExit); // Catches graceful exits too 114 | exitHandlerAttached = true; 115 | } 116 | } catch (error) { 117 | logger.error('Initialization failed:', error); 118 | process.exit(1); // Exit if initialization fails 119 | } 120 | } 121 | 122 | interface AppProps { 123 | options: CmdOptions; 124 | } 125 | 126 | const App: FC = ({ options: appOptions }) => { 127 | const { exit } = useApp(); 128 | const { 129 | projectName, 130 | prompt, 131 | timeout, 132 | showCountdown, 133 | outputFile, 134 | heartbeatFile, 135 | predefinedOptions, 136 | } = appOptions; 137 | 138 | const [timeLeft, setTimeLeft] = useState(timeout); 139 | 140 | // Clear console only once on mount 141 | useEffect(() => { 142 | console.clear(); 143 | }, []); 144 | 145 | // Handle countdown and auto-exit on timeout 146 | useEffect(() => { 147 | const timer = setInterval(() => { 148 | setTimeLeft((prev) => { 149 | if (prev <= 1) { 150 | clearInterval(timer); 151 | writeResponseToFile(outputFile, '__TIMEOUT__') // Use outputFile from props 152 | .catch((err) => logger.error('Failed to write timeout file:', err)) 153 | .finally(() => exit()); // Use Ink's exit for timeout 154 | return 0; 155 | } 156 | return prev - 1; 157 | }); 158 | }, 1000); 159 | 160 | // Add heartbeat interval 161 | let heartbeatInterval: NodeJS.Timeout | undefined; 162 | if (heartbeatFile) { 163 | heartbeatInterval = setInterval(async () => { 164 | try { 165 | // Touch the file (create if not exists, update mtime if exists) 166 | const now = new Date(); 167 | await fs.utimes(heartbeatFile, now, now); 168 | } catch (err: unknown) { 169 | // If file doesn't exist, try to create it 170 | if ( 171 | err && 172 | typeof err === 'object' && 173 | 'code' in err && 174 | (err as { code: string }).code === 'ENOENT' 175 | ) { 176 | try { 177 | await fs.writeFile(heartbeatFile, '', 'utf8'); 178 | } catch (createErr) { 179 | // Ignore errors creating heartbeat file (e.g., permissions) 180 | } 181 | } else { 182 | // Ignore other errors writing heartbeat file 183 | } 184 | } 185 | }, 1000); // Update every second 186 | } 187 | 188 | return () => { 189 | clearInterval(timer); 190 | if (heartbeatInterval) { 191 | clearInterval(heartbeatInterval); 192 | } 193 | }; 194 | }, [exit, outputFile, heartbeatFile, timeout]); // Added timeout to dependencies 195 | 196 | // Handle final submission 197 | const handleSubmit = (value: string) => { 198 | logger.debug(`User submitted: ${value}`); 199 | writeResponseToFile(outputFile, value) // Use outputFile from props 200 | .catch((err) => logger.error('Failed to write response file:', err)) 201 | .finally(() => { 202 | exit(); // Use Ink's exit for normal submission 203 | }); 204 | }; 205 | 206 | // Wrapper for handleSubmit to match the signature of InteractiveInput's onSubmit 207 | const handleInputSubmit = (_questionId: string, value: string) => { 208 | handleSubmit(value); 209 | }; 210 | 211 | const progressValue = (timeLeft / timeout) * 100; 212 | 213 | return ( 214 | 220 | {projectName && ( 221 | 222 | 223 | {projectName} 224 | 225 | 226 | )} 227 | 233 | {showCountdown && ( 234 | 235 | Time remaining: {timeLeft}s 236 | 237 | 238 | )} 239 | 240 | ); 241 | }; 242 | 243 | // Initialize and render the app 244 | initialize() 245 | .then(() => { 246 | if (options) { 247 | render(); 248 | } else { 249 | // This case should theoretically not be reached due to error handling in initialize 250 | logger.error('Options could not be initialized. Cannot render App.'); 251 | process.exit(1); 252 | } 253 | }) 254 | .catch(() => { 255 | // Error already logged in initialize or readOptionsFromFile 256 | process.exit(1); 257 | }); 258 | -------------------------------------------------------------------------------- /src/commands/intensive-chat/index.ts: -------------------------------------------------------------------------------- 1 | import { spawn, ChildProcess } from 'child_process'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import fs from 'fs/promises'; 5 | import os from 'os'; 6 | import crypto from 'crypto'; 7 | import logger from '../../utils/logger.js'; 8 | 9 | // Get the directory name of the current module 10 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 11 | 12 | // Interface for active session info 13 | interface SessionInfo { 14 | id: string; 15 | process: ChildProcess; 16 | outputDir: string; 17 | lastHeartbeatTime: number; 18 | isActive: boolean; 19 | title: string; 20 | timeoutSeconds?: number; 21 | } 22 | 23 | // Global object to keep track of active intensive chat sessions 24 | const activeSessions: Record = {}; 25 | 26 | // Start heartbeat monitoring for sessions 27 | startSessionMonitoring(); 28 | 29 | /** 30 | * Generate a unique temporary directory path for a session 31 | * @returns Path to a temporary directory 32 | */ 33 | async function createSessionDir(): Promise { 34 | const tempDir = os.tmpdir(); 35 | const sessionId = crypto.randomBytes(8).toString('hex'); 36 | const sessionDir = path.join(tempDir, `intensive-chat-${sessionId}`); 37 | 38 | // Create the session directory 39 | await fs.mkdir(sessionDir, { recursive: true }); 40 | 41 | return sessionDir; 42 | } 43 | 44 | /** 45 | * Start an intensive chat session 46 | * @param title Title for the chat session 47 | * @param timeoutSeconds Optional timeout for each question in seconds 48 | * @returns Session ID for the created session 49 | */ 50 | export async function startIntensiveChatSession( 51 | title: string, 52 | timeoutSeconds?: number, 53 | ): Promise { 54 | // Create a session directory 55 | const sessionDir = await createSessionDir(); 56 | 57 | // Generate a unique session ID 58 | const sessionId = path.basename(sessionDir).replace('intensive-chat-', ''); 59 | 60 | // Path to the UI script - Updated to use the compiled 'ui.js' filename 61 | const uiScriptPath = path.join(__dirname, 'ui.js'); 62 | 63 | // Create options payload for the UI 64 | const options = { 65 | sessionId, 66 | title, 67 | outputDir: sessionDir, 68 | timeoutSeconds, 69 | }; 70 | 71 | // Encode options as base64 payload 72 | const payload = Buffer.from(JSON.stringify(options)).toString('base64'); 73 | 74 | // Platform-specific spawning 75 | const platform = os.platform(); 76 | let childProcess: ChildProcess; 77 | 78 | if (platform === 'darwin') { 79 | // macOS 80 | // Escape potential special characters in paths/payload for the shell command 81 | // For the shell command executed by 'do script', we primarily need to handle spaces 82 | // or other characters that might break the command if paths aren't quoted. 83 | // The `${...}` interpolation within backticks handles basic variable insertion. 84 | // Quoting the paths within nodeCommand handles spaces. 85 | const escapedScriptPath = uiScriptPath; // Keep original path, rely on quotes below 86 | const escapedPayload = payload; // Keep original payload, rely on quotes below 87 | 88 | // Construct the command string directly for the shell. Quotes handle paths with spaces. 89 | const nodeCommand = `exec node "${escapedScriptPath}" "${escapedPayload}"; exit 0`; 90 | 91 | // Escape the node command for osascript's AppleScript string: 92 | // 1. Escape existing backslashes (\ -> \\) 93 | // 2. Escape double quotes (" -> \") 94 | const escapedNodeCommand = nodeCommand 95 | // Escape backslashes first 96 | .replace(/\\/g, '\\\\') // Using /\\/g instead of /\/g 97 | // Then escape double quotes 98 | .replace(/"/g, '\\"'); 99 | 100 | // Activate Terminal first, then do script with exec 101 | const command = `osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapedNodeCommand}"'`; 102 | const commandArgs: string[] = []; // No args needed when command is a single string for shell 103 | 104 | childProcess = spawn(command, commandArgs, { 105 | stdio: ['ignore', 'ignore', 'ignore'], 106 | shell: true, 107 | detached: true, 108 | }); 109 | } else if (platform === 'win32') { 110 | // Windows 111 | childProcess = spawn('node', [uiScriptPath, payload], { 112 | stdio: ['ignore', 'ignore', 'ignore'], 113 | shell: true, 114 | detached: true, 115 | windowsHide: false, 116 | }); 117 | } else { 118 | // Linux or other - use original method (might not pop up window) 119 | childProcess = spawn('node', [uiScriptPath, payload], { 120 | stdio: ['ignore', 'ignore', 'ignore'], 121 | shell: true, 122 | detached: true, 123 | }); 124 | } 125 | 126 | // Unref the process so it can run independently 127 | childProcess.unref(); 128 | 129 | // Store session info 130 | activeSessions[sessionId] = { 131 | id: sessionId, 132 | process: childProcess, // Use the conditionally spawned process 133 | outputDir: sessionDir, 134 | lastHeartbeatTime: Date.now(), 135 | isActive: true, 136 | title, 137 | timeoutSeconds, 138 | }; 139 | 140 | // Wait a bit to ensure the UI has started 141 | await new Promise((resolve) => setTimeout(resolve, 500)); 142 | 143 | return sessionId; 144 | } 145 | 146 | /** 147 | * Ask a new question in an existing intensive chat session 148 | * @param sessionId ID of the session to ask in 149 | * @param question The question text to ask 150 | * @param predefinedOptions Optional predefined options for the question 151 | * @returns The user's response or null if session is not active 152 | */ 153 | export async function askQuestionInSession( 154 | sessionId: string, 155 | question: string, 156 | predefinedOptions?: string[], 157 | ): Promise { 158 | const session = activeSessions[sessionId]; 159 | 160 | if (!session || !session.isActive) { 161 | return null; // Session doesn't exist or is not active 162 | } 163 | 164 | // Generate a unique ID for this question-answer pair 165 | const questionId = crypto.randomUUID(); 166 | 167 | // Create the input data object 168 | const inputData: { id: string; text: string; options?: string[] } = { 169 | id: questionId, 170 | text: question, 171 | }; 172 | 173 | if (predefinedOptions && predefinedOptions.length > 0) { 174 | inputData.options = predefinedOptions; 175 | } 176 | 177 | // Write the combined input data to a session-specific JSON file 178 | const inputFilePath = path.join(session.outputDir, `${sessionId}.json`); 179 | await fs.writeFile(inputFilePath, JSON.stringify(inputData), 'utf8'); 180 | 181 | // Wait for the response file corresponding to the generated ID 182 | const responseFilePath = path.join( 183 | session.outputDir, 184 | `response-${questionId}.txt`, 185 | ); 186 | 187 | // Wait for response with timeout 188 | const maxWaitTime = (session.timeoutSeconds ?? 60) * 1000; // Use session timeout or default to 60s 189 | const pollInterval = 100; // 100ms polling interval 190 | const startTime = Date.now(); 191 | 192 | while (Date.now() - startTime < maxWaitTime) { 193 | try { 194 | // Check if the response file exists 195 | await fs.access(responseFilePath); 196 | 197 | // Read the response 198 | const response = await fs.readFile(responseFilePath, 'utf8'); 199 | 200 | // Clean up the response file 201 | await fs.unlink(responseFilePath).catch(() => {}); 202 | 203 | return response; 204 | } catch { 205 | // Response file doesn't exist yet, check session status 206 | if (!(await isSessionActive(sessionId))) { 207 | return null; // Session has ended 208 | } 209 | 210 | // Wait before polling again 211 | await new Promise((resolve) => setTimeout(resolve, pollInterval)); 212 | } 213 | } 214 | 215 | // Timeout reached 216 | return 'User closed intensive chat session'; 217 | } 218 | 219 | /** 220 | * Stop an active intensive chat session 221 | * @param sessionId ID of the session to stop 222 | * @returns True if session was stopped, false otherwise 223 | */ 224 | export async function stopIntensiveChatSession( 225 | sessionId: string, 226 | ): Promise { 227 | const session = activeSessions[sessionId]; 228 | 229 | if (!session || !session.isActive) { 230 | return false; // Session doesn't exist or is already inactive 231 | } 232 | 233 | // Write close signal file 234 | const closeFilePath = path.join(session.outputDir, 'close-session.txt'); 235 | await fs.writeFile(closeFilePath, '', 'utf8'); 236 | 237 | // Give the process some time to exit gracefully 238 | await new Promise((resolve) => setTimeout(resolve, 500)); 239 | 240 | try { 241 | // Force kill the process if it's still running 242 | if (!session.process.killed) { 243 | // Kill process group on Unix-like systems, standard kill on Windows 244 | try { 245 | if (os.platform() !== 'win32') { 246 | process.kill(-session.process.pid!, 'SIGTERM'); 247 | } else { 248 | process.kill(session.process.pid!, 'SIGTERM'); 249 | } 250 | } catch { 251 | // console.error("Error killing process:", killError); 252 | // Fallback or ignore if process already exited or group kill failed 253 | } 254 | } 255 | } catch { 256 | // Process might have already exited 257 | } 258 | 259 | // Mark session as inactive 260 | session.isActive = false; 261 | 262 | // Clean up session directory after a delay 263 | setTimeout(() => { 264 | // Use void to mark intentionally unhandled promise 265 | void (async () => { 266 | try { 267 | await fs.rm(session.outputDir, { recursive: true, force: true }); 268 | } catch { 269 | // Ignore errors during cleanup 270 | } 271 | 272 | // Remove from active sessions 273 | delete activeSessions[sessionId]; 274 | })(); 275 | }, 2000); 276 | 277 | return true; 278 | } 279 | 280 | /** 281 | * Check if a session is still active 282 | * @param sessionId ID of the session to check 283 | * @returns True if session is active, false otherwise 284 | */ 285 | export async function isSessionActive(sessionId: string): Promise { 286 | const session = activeSessions[sessionId]; 287 | 288 | if (!session) { 289 | return false; // Session doesn't exist 290 | } 291 | 292 | if (!session.isActive) { 293 | return false; // Session was manually marked as inactive 294 | } 295 | 296 | try { 297 | // Check the heartbeat file 298 | const heartbeatPath = path.join(session.outputDir, 'heartbeat.txt'); 299 | const stats = await fs.stat(heartbeatPath); 300 | 301 | // Check if heartbeat was updated recently (within last 2 seconds) 302 | const heartbeatAge = Date.now() - stats.mtime.getTime(); 303 | if (heartbeatAge > 2000) { 304 | // Heartbeat is too old, session is likely dead 305 | session.isActive = false; 306 | return false; 307 | } 308 | 309 | return true; 310 | } catch (err: unknown) { 311 | // If error is ENOENT (file not found), assume session is still starting 312 | // Check if err is an object and has a code property before accessing it 313 | if ( 314 | err && 315 | typeof err === 'object' && 316 | 'code' in err && 317 | err.code === 'ENOENT' 318 | ) { 319 | // Optional: Could add a check here to see if the session is very new 320 | // e.g., if (Date.now() - session.startTime < 2000) return true; 321 | // For now, let's assume ENOENT means it's possibly still starting. 322 | return true; 323 | } 324 | // Handle cases where err is not an object with a code property or other errors 325 | logger.error( 326 | `Error checking heartbeat for session ${sessionId}:`, 327 | err instanceof Error ? err.message : String(err), 328 | ); 329 | session.isActive = false; 330 | return false; 331 | } 332 | } 333 | 334 | /** 335 | * Start background monitoring of all active sessions 336 | */ 337 | function startSessionMonitoring() { 338 | // Remove async from setInterval callback 339 | setInterval(() => { 340 | // Use void to mark intentionally unhandled promise 341 | void (async () => { 342 | for (const sessionId of Object.keys(activeSessions)) { 343 | const isActive = await isSessionActive(sessionId); 344 | 345 | if (!isActive && activeSessions[sessionId]) { 346 | // Clean up inactive session 347 | try { 348 | // Kill process if it's somehow still running 349 | if (!activeSessions[sessionId].process.killed) { 350 | try { 351 | if (os.platform() !== 'win32') { 352 | process.kill( 353 | -activeSessions[sessionId].process.pid!, 354 | 'SIGTERM', 355 | ); 356 | } else { 357 | process.kill( 358 | activeSessions[sessionId].process.pid!, 359 | 'SIGTERM', 360 | ); 361 | } 362 | } catch { 363 | // console.error("Error killing process:", killError); 364 | // Ignore errors during cleanup 365 | } 366 | } 367 | } catch { 368 | // Ignore errors during cleanup 369 | } 370 | 371 | // Clean up session directory 372 | try { 373 | await fs.rm(activeSessions[sessionId].outputDir, { 374 | recursive: true, 375 | force: true, 376 | }); 377 | } catch { 378 | // Ignore errors during cleanup 379 | } 380 | 381 | // Remove from active sessions 382 | delete activeSessions[sessionId]; 383 | } 384 | } 385 | })(); 386 | }, 5000); // Check every 5 seconds 387 | } 388 | -------------------------------------------------------------------------------- /src/commands/intensive-chat/ui.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useEffect, useRef } from 'react'; 2 | import { render, Box, Text, useApp } from 'ink'; 3 | import { ProgressBar } from '@inkjs/ui'; 4 | import fs from 'fs/promises'; 5 | import path from 'path'; 6 | import crypto from 'crypto'; 7 | import { InteractiveInput } from '@/components/InteractiveInput.js'; 8 | import { USER_INPUT_TIMEOUT_SECONDS } from '@/constants.js'; // Import the constant 9 | import logger from '../../utils/logger.js'; 10 | 11 | // Interface for chat message 12 | interface ChatMessage { 13 | text: string; 14 | isQuestion: boolean; 15 | answer?: string; 16 | } 17 | 18 | // Parse command line arguments from a single JSON-encoded argument 19 | const parseArgs = () => { 20 | const args = process.argv.slice(2); 21 | const defaults = { 22 | sessionId: crypto.randomUUID(), 23 | title: 'Interactive Chat Session', 24 | outputDir: undefined as string | undefined, 25 | timeoutSeconds: USER_INPUT_TIMEOUT_SECONDS, 26 | }; 27 | 28 | if (args[0]) { 29 | try { 30 | // Decode base64-encoded JSON payload to avoid quoting issues 31 | const decoded = Buffer.from(args[0], 'base64').toString('utf8'); 32 | const parsed = JSON.parse(decoded); 33 | return { ...defaults, ...parsed }; 34 | } catch (e) { 35 | logger.error('Invalid input options payload, using defaults.', e); 36 | } 37 | } 38 | return defaults; 39 | }; 40 | 41 | // Get command line arguments 42 | const options = parseArgs(); 43 | 44 | // Function to write response to output file 45 | const writeResponseToFile = async (questionId: string, response: string) => { 46 | if (!options.outputDir) return; 47 | 48 | // Create response file path 49 | const responseFilePath = path.join( 50 | options.outputDir, 51 | `response-${questionId}.txt`, 52 | ); 53 | 54 | // Write file in UTF-8 format 55 | await fs.writeFile(responseFilePath, response, 'utf8'); 56 | 57 | //wait 500 ms 58 | await new Promise((resolve) => setTimeout(resolve, 500)); 59 | }; 60 | 61 | // Create a heartbeat file to indicate the session is still active 62 | const updateHeartbeat = async () => { 63 | if (!options.outputDir) return; 64 | 65 | const heartbeatPath = path.join(options.outputDir, 'heartbeat.txt'); 66 | try { 67 | const dir = path.dirname(heartbeatPath); 68 | await fs.mkdir(dir, { recursive: true }); // Ensure directory exists 69 | await fs.writeFile(heartbeatPath, Date.now().toString(), 'utf8'); 70 | } catch (writeError) { 71 | // Log the specific error but allow the poll cycle to continue 72 | logger.error( 73 | `Failed to write heartbeat file ${heartbeatPath}:`, 74 | writeError, 75 | ); 76 | } 77 | }; 78 | 79 | // Register process termination handlers 80 | const handleExit = () => { 81 | if (options.outputDir) { 82 | // Write exit file to indicate session has ended 83 | fs.writeFile(path.join(options.outputDir, 'session-closed.txt'), '', 'utf8') 84 | .then(() => process.exit(0)) 85 | .catch((error) => { 86 | logger.error('Failed to write exit file:', error); 87 | process.exit(1); 88 | }); 89 | } else { 90 | process.exit(0); 91 | } 92 | }; 93 | 94 | // Listen for termination signals 95 | process.on('SIGINT', handleExit); 96 | process.on('SIGTERM', handleExit); 97 | process.on('beforeExit', handleExit); 98 | 99 | interface AppProps { 100 | sessionId: string; 101 | title: string; 102 | outputDir?: string; 103 | timeoutSeconds: number; 104 | } 105 | 106 | const App: FC = ({ sessionId, title, outputDir, timeoutSeconds }) => { 107 | // console.clear(); // Clear console before rendering UI - Removed from here 108 | const { exit: appExit } = useApp(); 109 | const [chatHistory, setChatHistory] = useState([]); 110 | const [currentQuestionId, setCurrentQuestionId] = useState( 111 | null, 112 | ); 113 | const [currentPredefinedOptions, setCurrentPredefinedOptions] = useState< 114 | string[] | undefined 115 | >(undefined); 116 | const [timeLeft, setTimeLeft] = useState(null); // State for countdown timer 117 | const timerRef = useRef(null); // Ref to hold timer ID 118 | 119 | // Clear console only once on mount 120 | useEffect(() => { 121 | console.clear(); 122 | }, []); // Empty dependency array ensures this runs only once 123 | 124 | // Check for new questions periodically 125 | useEffect(() => { 126 | // Set up polling for new inputs 127 | const questionPoller = setInterval(async () => { 128 | if (!outputDir) return; 129 | 130 | try { 131 | // Update heartbeat to indicate we're still running 132 | await updateHeartbeat(); 133 | 134 | // Look for the session-specific input file 135 | const inputFilePath = path.join(outputDir, `${sessionId}.json`); 136 | 137 | // Check if new input file exists 138 | try { 139 | const inputExists = await fs.stat(inputFilePath); 140 | 141 | if (inputExists) { 142 | // Read input file content 143 | const inputFileContent = await fs.readFile(inputFilePath, 'utf8'); 144 | let questionId: string | null = null; 145 | let questionText: string | null = null; 146 | let options: string[] | undefined = undefined; 147 | 148 | try { 149 | // Parse input file content as JSON { id: string, text: string, options?: string[] } 150 | const inputData = JSON.parse(inputFileContent); 151 | if ( 152 | typeof inputData === 'object' && 153 | inputData !== null && 154 | typeof inputData.id === 'string' && 155 | typeof inputData.text === 'string' && 156 | (inputData.options === undefined || 157 | Array.isArray(inputData.options)) 158 | ) { 159 | questionId = inputData.id; 160 | questionText = inputData.text; 161 | // Ensure options are strings if they exist 162 | options = Array.isArray(inputData.options) 163 | ? inputData.options.map(String) 164 | : undefined; 165 | } else { 166 | logger.error( 167 | `Invalid format in ${sessionId}.json. Expected JSON with id (string), text (string), and optional options (array).`, 168 | ); 169 | } 170 | } catch (parseError) { 171 | logger.error( 172 | `Error parsing ${sessionId}.json as JSON:`, 173 | parseError, 174 | ); 175 | } 176 | 177 | // Proceed only if we successfully parsed the question ID and text 178 | if (questionId && questionText) { 179 | // Add question to chat using the ID and options from the file 180 | addNewQuestion(questionId, questionText, options); 181 | 182 | // Delete the input file 183 | await fs.unlink(inputFilePath); 184 | } else { 185 | // If parsing failed or format was invalid, delete the problematic file 186 | logger.error(`Deleting invalid input file: ${inputFilePath}`); 187 | await fs.unlink(inputFilePath); 188 | } 189 | } 190 | } catch (e: unknown) { 191 | // Type guard to check if it's an error with a code property 192 | if ( 193 | typeof e === 'object' && 194 | e !== null && 195 | 'code' in e && 196 | (e as { code: unknown }).code !== 'ENOENT' 197 | ) { 198 | logger.error( 199 | `Error checking/reading input file ${inputFilePath}:`, 200 | e, 201 | ); 202 | } 203 | // If it's not an error with a code or the code is ENOENT, we ignore it silently. 204 | } 205 | 206 | // Check if we should exit 207 | const closeFilePath = path.join(outputDir, 'close-session.txt'); 208 | try { 209 | await fs.stat(closeFilePath); 210 | // If close file exists, exit the process 211 | handleExit(); 212 | } catch (_e) { 213 | // No close request 214 | } 215 | } catch (error) { 216 | logger.error('Error in poll cycle:', error); 217 | } 218 | }, 100); 219 | 220 | return () => clearInterval(questionPoller); 221 | }, [outputDir, sessionId]); 222 | 223 | // Countdown timer effect 224 | useEffect(() => { 225 | if (timeLeft === null || timeLeft <= 0 || !currentQuestionId) { 226 | if (timerRef.current) { 227 | clearInterval(timerRef.current); 228 | timerRef.current = null; 229 | } 230 | return; // No timer needed or timer expired 231 | } 232 | 233 | // Start timer if not already running 234 | if (!timerRef.current) { 235 | timerRef.current = setInterval(() => { 236 | setTimeLeft((prev) => (prev !== null ? prev - 1 : null)); 237 | }, 1000); 238 | } 239 | 240 | // Check if timer reached zero 241 | if (timeLeft <= 0 && timerRef.current) { 242 | clearInterval(timerRef.current); 243 | timerRef.current = null; 244 | // Auto-submit timeout indicator on timeout 245 | handleSubmit(currentQuestionId, '__TIMEOUT__'); 246 | } 247 | 248 | // Cleanup function to clear interval on unmount or when dependencies change 249 | return () => { 250 | if (timerRef.current) { 251 | clearInterval(timerRef.current); 252 | timerRef.current = null; 253 | } 254 | }; 255 | }, [timeLeft, currentQuestionId]); // Rerun effect when timeLeft or currentQuestionId changes 256 | 257 | // Add a new question to the chat 258 | const addNewQuestion = ( 259 | questionId: string, 260 | questionText: string, 261 | options?: string[], 262 | ) => { 263 | console.clear(); // Clear console before displaying new question 264 | // Clear existing timer before starting new one 265 | if (timerRef.current) { 266 | clearInterval(timerRef.current); 267 | timerRef.current = null; 268 | } 269 | 270 | setChatHistory((prev) => [ 271 | ...prev, 272 | { 273 | text: questionText, 274 | isQuestion: true, 275 | }, 276 | ]); 277 | 278 | setCurrentQuestionId(questionId); 279 | setCurrentPredefinedOptions(options); 280 | setTimeLeft(timeoutSeconds); // Use timeout from props 281 | }; 282 | 283 | // Handle user submitting an answer 284 | const handleSubmit = async (questionId: string, value: string) => { 285 | // Clear the timer 286 | if (timerRef.current) { 287 | clearInterval(timerRef.current); 288 | timerRef.current = null; 289 | } 290 | setTimeLeft(null); // Reset timer state 291 | 292 | // Update the chat history with the answer 293 | setChatHistory((prev) => 294 | prev.map((msg) => { 295 | // Find the last question in history that matches the ID and doesn't have an answer yet 296 | // Use slice().reverse().find() for broader compatibility instead of findLast() 297 | if ( 298 | msg.isQuestion && 299 | !msg.answer && 300 | msg === 301 | prev 302 | .slice() 303 | .reverse() 304 | .find((m: ChatMessage) => m.isQuestion && !m.answer) 305 | ) { 306 | return { ...msg, answer: value }; 307 | } 308 | return msg; 309 | }), 310 | ); 311 | 312 | // Reset current question state 313 | setCurrentQuestionId(null); 314 | setCurrentPredefinedOptions(undefined); 315 | 316 | // Write response to file 317 | if (outputDir) { 318 | await writeResponseToFile(questionId, value); 319 | } 320 | }; 321 | 322 | // Calculate progress bar value (moved slightly down, renamed to percentage) 323 | const percentage = timeLeft !== null ? (timeLeft / timeoutSeconds) * 100 : 0; // Use timeout from props 324 | 325 | return ( 326 | 332 | 333 | 334 | {title} 335 | 336 | Session ID: {sessionId} 337 | Press Ctrl+C to exit the chat session 338 | 339 | 340 | 341 | {/* Chat history */} 342 | {chatHistory.map((msg, i) => ( 343 | 344 | {msg.isQuestion ? ( 345 | 346 | Q: {msg.text} 347 | 348 | ) : null} 349 | {msg.answer ? ( 350 | 351 | A: {msg.answer} 352 | 353 | ) : null} 354 | 355 | ))} 356 | 357 | 358 | {/* Current question input */} 359 | {currentQuestionId && ( 360 | 367 | m.isQuestion && !m.answer)?.text || '' 374 | } 375 | questionId={currentQuestionId} 376 | predefinedOptions={currentPredefinedOptions} 377 | onSubmit={handleSubmit} 378 | /> 379 | {/* Countdown Timer and Progress Bar */} 380 | {timeLeft !== null && ( 381 | 382 | 383 | Time remaining: {timeLeft}s 384 | 385 | 386 | 387 | )} 388 | 389 | )} 390 | 391 | ); 392 | }; 393 | 394 | // Render the app 395 | render(); 396 | -------------------------------------------------------------------------------- /src/components/InteractiveInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useEffect } from 'react'; 2 | import { Box, Text, useInput } from 'ink'; 3 | import { TextInput } from '@inkjs/ui'; 4 | import logger from '@/utils/logger.js'; 5 | 6 | interface InteractiveInputProps { 7 | question: string; 8 | questionId: string; 9 | predefinedOptions?: string[]; 10 | onSubmit: (questionId: string, value: string) => void; 11 | } 12 | 13 | export const InteractiveInput: FC = ({ 14 | question, 15 | questionId, 16 | predefinedOptions = [], 17 | onSubmit, 18 | }) => { 19 | const [mode, setMode] = useState<'option' | 'input'>( 20 | predefinedOptions.length > 0 ? 'option' : 'input', 21 | ); 22 | const [selectedIndex, setSelectedIndex] = useState(0); 23 | const [inputValue, setInputValue] = useState(''); 24 | 25 | useInput((input, key) => { 26 | if (predefinedOptions.length > 0) { 27 | if (key.upArrow) { 28 | setMode('option'); 29 | setSelectedIndex( 30 | (prev) => 31 | (prev - 1 + predefinedOptions.length) % predefinedOptions.length, 32 | ); 33 | return; 34 | } 35 | 36 | if (key.downArrow) { 37 | setMode('option'); 38 | setSelectedIndex((prev) => (prev + 1) % predefinedOptions.length); 39 | return; 40 | } 41 | } 42 | 43 | if (key.return) { 44 | if (mode === 'option' && predefinedOptions.length > 0) { 45 | onSubmit(questionId, predefinedOptions[selectedIndex]); 46 | } else { 47 | onSubmit(questionId, inputValue); 48 | } 49 | return; 50 | } 51 | 52 | // Any other key press switches to input mode 53 | if ( 54 | !key.ctrl && 55 | !key.meta && 56 | !key.escape && 57 | !key.tab && 58 | !key.shift && 59 | !key.leftArrow && 60 | !key.rightArrow && 61 | input 62 | ) { 63 | setMode('input'); 64 | // Update inputValue only if switching to input mode via typing 65 | // TextInput's onChange will handle subsequent typing 66 | if (mode === 'option') { 67 | setInputValue(input); // Start input with the typed character 68 | } 69 | } 70 | }); 71 | 72 | const handleInputChange = (value: string) => { 73 | if (value !== inputValue) { 74 | setInputValue(value); 75 | // If user starts typing, switch to input mode 76 | if (value.length > 0 && mode === 'option') { 77 | setMode('input'); 78 | } else if (value.length === 0 && predefinedOptions.length > 0) { 79 | // Optionally switch back to option mode if input is cleared 80 | // setMode('option'); 81 | } 82 | } 83 | }; 84 | 85 | const handleSubmit = (value: string) => { 86 | // The primary submit logic is now handled in useInput via Enter key 87 | // This might still be called by TextInput's internal onSubmit, ensure consistency 88 | if (mode === 'option' && predefinedOptions.length > 0) { 89 | onSubmit(questionId, predefinedOptions[selectedIndex]); 90 | } else { 91 | onSubmit(questionId, value); // Use the value from TextInput in case it triggered submit 92 | } 93 | }; 94 | 95 | return ( 96 | <> 97 | 98 | 99 | {question} 100 | 101 | 102 | 103 | {predefinedOptions.length > 0 && ( 104 | 105 | 106 | Use ↑/↓ to select options, type for custom input, Enter to submit 107 | 108 | {predefinedOptions.map((opt, i) => ( 109 | 117 | {i === selectedIndex && mode === 'option' ? '› ' : ' '} 118 | {opt} 119 | 120 | ))} 121 | 122 | )} 123 | 124 | 125 | 126 | {mode === 'input' ? '✎ ' : '› '} 127 | 0 130 | ? 'Type or select an option...' 131 | : 'Type your answer...' 132 | } 133 | onChange={handleInputChange} 134 | onSubmit={handleSubmit} 135 | /> 136 | 137 | 138 | 139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared constants for the application. 3 | */ 4 | 5 | /** 6 | * Timeout duration in seconds for waiting for user input in both single-input and intensive chat modes. 7 | * This aligns with the default timeout expected by the MCP tool. 8 | */ 9 | export const USER_INPUT_TIMEOUT_SECONDS = 60; 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 4 | import { z } from 'zod'; 5 | import notifier from 'node-notifier'; 6 | import yargs from 'yargs'; 7 | import { hideBin } from 'yargs/helpers'; 8 | import { getCmdWindowInput } from './commands/input/index.js'; 9 | import { 10 | startIntensiveChatSession, 11 | askQuestionInSession, 12 | stopIntensiveChatSession, 13 | } from './commands/intensive-chat/index.js'; 14 | import { USER_INPUT_TIMEOUT_SECONDS } from './constants.js'; 15 | 16 | // Import tool definitions using the new structure 17 | import { requestUserInputTool } from './tool-definitions/request-user-input.js'; 18 | import { messageCompleteNotificationTool } from './tool-definitions/message-complete-notification.js'; 19 | import { intensiveChatTools } from './tool-definitions/intensive-chat.js'; 20 | // Import the types for better type checking 21 | import { ToolCapabilityInfo } from './tool-definitions/types.js'; 22 | 23 | // --- Define Type for Tool Capabilities --- (Adjusted to use ToolCapabilityInfo) 24 | type ToolCapabilitiesStructure = Record; 25 | // --- End Define Type --- 26 | 27 | // --- Define Full Tool Capabilities from Imports --- (Simplified construction) 28 | const allToolCapabilities = { 29 | request_user_input: requestUserInputTool.capability, 30 | message_complete_notification: messageCompleteNotificationTool.capability, 31 | start_intensive_chat: intensiveChatTools.start.capability, 32 | ask_intensive_chat: intensiveChatTools.ask.capability, 33 | stop_intensive_chat: intensiveChatTools.stop.capability, 34 | } satisfies ToolCapabilitiesStructure; 35 | // --- End Define Full Tool Capabilities from Imports --- 36 | 37 | // Parse command-line arguments for global timeout 38 | const argv = yargs(hideBin(process.argv)) 39 | .option('timeout', { 40 | alias: 't', 41 | type: 'number', 42 | description: 'Default timeout for user input prompts in seconds', 43 | default: USER_INPUT_TIMEOUT_SECONDS, 44 | }) 45 | .option('disable-tools', { 46 | alias: 'd', 47 | type: 'string', 48 | description: 49 | 'Comma-separated list of tool names to disable. Available options: request_user_input, message_complete_notification, intensive_chat (disables all intensive chat tools).', 50 | default: '', 51 | }) 52 | .help() 53 | .alias('help', 'h') 54 | .parseSync(); 55 | 56 | const globalTimeoutSeconds = argv.timeout; 57 | const disabledTools = argv['disable-tools'] 58 | .split(',') 59 | .map((tool) => tool.trim()) 60 | .filter(Boolean); 61 | 62 | // Store active intensive chat sessions 63 | const activeChatSessions = new Map(); 64 | 65 | // --- Filter Capabilities Based on Args --- 66 | // Helper function to check if a tool is effectively disabled (directly or via group) 67 | const isToolDisabled = (toolName: string): boolean => { 68 | if (disabledTools.includes(toolName)) { 69 | return true; 70 | } 71 | if ( 72 | [ 73 | // Check if tool belongs to the intensive_chat group and the group is disabled 74 | 'start_intensive_chat', 75 | 'ask_intensive_chat', 76 | 'stop_intensive_chat', 77 | ].includes(toolName) && 78 | disabledTools.includes('intensive_chat') 79 | ) { 80 | return true; 81 | } 82 | return false; 83 | }; 84 | 85 | // Create a new object with only the enabled tool capabilities 86 | const enabledToolCapabilities = Object.fromEntries( 87 | Object.entries(allToolCapabilities).filter(([toolName]) => { 88 | return !isToolDisabled(toolName); 89 | }), 90 | ) as ToolCapabilitiesStructure; // Assert type after filtering 91 | 92 | // --- End Filter Capabilities Based on Args --- 93 | 94 | // Helper function to check if a tool should be registered (used later) 95 | const isToolEnabled = (toolName: string): boolean => { 96 | // A tool is enabled if it's present in the filtered capabilities 97 | return toolName in enabledToolCapabilities; 98 | }; 99 | 100 | // Initialize MCP server with FILTERED capabilities 101 | const server = new McpServer({ 102 | name: 'Interactive MCP', 103 | version: '1.0.0', 104 | capabilities: { 105 | tools: enabledToolCapabilities, // Use the filtered capabilities 106 | }, 107 | }); 108 | 109 | // Conditionally register tools based on command-line arguments 110 | 111 | if (isToolEnabled('request_user_input')) { 112 | // Use properties from the imported tool object 113 | server.tool( 114 | 'request_user_input', 115 | // Need to handle description potentially being a function 116 | typeof requestUserInputTool.description === 'function' 117 | ? requestUserInputTool.description(globalTimeoutSeconds) 118 | : requestUserInputTool.description, 119 | requestUserInputTool.schema, // Use schema property 120 | async (args) => { 121 | // Use inferred args type 122 | const { projectName, message, predefinedOptions } = args; 123 | const promptMessage = `${projectName}: ${message}`; 124 | const answer = await getCmdWindowInput( 125 | projectName, 126 | promptMessage, 127 | globalTimeoutSeconds, 128 | true, 129 | predefinedOptions, 130 | ); 131 | 132 | // Check for the specific timeout indicator 133 | if (answer === '__TIMEOUT__') { 134 | return { 135 | content: [ 136 | { type: 'text', text: 'User did not reply: Timeout occurred.' }, 137 | ], 138 | }; 139 | } 140 | // Empty string means user submitted empty input, non-empty is actual reply 141 | else if (answer === '') { 142 | return { 143 | content: [{ type: 'text', text: 'User replied with empty input.' }], 144 | }; 145 | } else { 146 | const reply = `User replied: ${answer}`; 147 | return { content: [{ type: 'text', text: reply }] }; 148 | } 149 | }, 150 | ); 151 | } 152 | 153 | if (isToolEnabled('message_complete_notification')) { 154 | // Use properties from the imported tool object 155 | server.tool( 156 | 'message_complete_notification', 157 | // Description is a string here, but handle consistently 158 | typeof messageCompleteNotificationTool.description === 'function' 159 | ? messageCompleteNotificationTool.description(globalTimeoutSeconds) // Should not happen based on definition, but safe 160 | : messageCompleteNotificationTool.description, 161 | messageCompleteNotificationTool.schema, // Use schema property 162 | (args) => { 163 | // Use inferred args type 164 | const { projectName, message } = args; 165 | notifier.notify({ title: projectName, message }); 166 | return { 167 | content: [ 168 | { 169 | type: 'text', 170 | text: 'Notification sent. You can now wait for user input.', 171 | }, 172 | ], 173 | }; 174 | }, 175 | ); 176 | } 177 | 178 | // --- Intensive Chat Tool Registrations --- 179 | // Each tool must be checked individually based on filtered capabilities 180 | if (isToolEnabled('start_intensive_chat')) { 181 | // Use properties from the imported intensiveChatTools object 182 | server.tool( 183 | 'start_intensive_chat', 184 | // Description is a function here 185 | typeof intensiveChatTools.start.description === 'function' 186 | ? intensiveChatTools.start.description(globalTimeoutSeconds) 187 | : intensiveChatTools.start.description, 188 | intensiveChatTools.start.schema, // Use schema property 189 | async (args) => { 190 | // Use inferred args type 191 | const { sessionTitle } = args; 192 | try { 193 | // Start a new intensive chat session, passing global timeout 194 | const sessionId = await startIntensiveChatSession( 195 | sessionTitle, 196 | globalTimeoutSeconds, 197 | ); 198 | 199 | // Track this session for the client 200 | activeChatSessions.set(sessionId, sessionTitle); 201 | 202 | return { 203 | content: [ 204 | { 205 | type: 'text', 206 | text: `Intensive chat session started successfully. Session ID: ${sessionId}`, 207 | }, 208 | ], 209 | }; 210 | } catch (error: unknown) { 211 | let errorMessage = 'Failed to start intensive chat session.'; 212 | if (error instanceof Error) { 213 | errorMessage = `Failed to start intensive chat session: ${error.message}`; 214 | } else if (typeof error === 'string') { 215 | errorMessage = `Failed to start intensive chat session: ${error}`; 216 | } 217 | return { 218 | content: [ 219 | { 220 | type: 'text', 221 | text: errorMessage, 222 | }, 223 | ], 224 | }; 225 | } 226 | }, 227 | ); 228 | } 229 | 230 | if (isToolEnabled('ask_intensive_chat')) { 231 | // Use properties from the imported intensiveChatTools object 232 | server.tool( 233 | 'ask_intensive_chat', 234 | // Description is a string here 235 | typeof intensiveChatTools.ask.description === 'function' 236 | ? intensiveChatTools.ask.description(globalTimeoutSeconds) // Should not happen, but safe 237 | : intensiveChatTools.ask.description, 238 | intensiveChatTools.ask.schema, // Use schema property 239 | async (args) => { 240 | // Use inferred args type 241 | const { sessionId, question, predefinedOptions } = args; 242 | // Check if session exists 243 | if (!activeChatSessions.has(sessionId)) { 244 | return { 245 | content: [ 246 | { type: 'text', text: 'Error: Invalid or expired session ID.' }, 247 | ], 248 | }; 249 | } 250 | 251 | try { 252 | // Ask the question in the session 253 | const answer = await askQuestionInSession( 254 | sessionId, 255 | question, 256 | predefinedOptions, 257 | ); 258 | 259 | // Check for the specific timeout indicator 260 | if (answer === '__TIMEOUT__') { 261 | return { 262 | content: [ 263 | { 264 | type: 'text', 265 | text: 'User did not reply to question in intensive chat: Timeout occurred.', 266 | }, 267 | ], 268 | }; 269 | } 270 | // Empty string means user submitted empty input, non-empty is actual reply 271 | else if (answer === '') { 272 | return { 273 | content: [ 274 | { 275 | type: 'text', 276 | text: 'User replied with empty input in intensive chat.', 277 | }, 278 | ], 279 | }; 280 | } else { 281 | return { 282 | content: [{ type: 'text', text: `User replied: ${answer}` }], 283 | }; 284 | } 285 | } catch (error: unknown) { 286 | let errorMessage = 'Failed to ask question in session.'; 287 | if (error instanceof Error) { 288 | errorMessage = `Failed to ask question in session: ${error.message}`; 289 | } else if (typeof error === 'string') { 290 | errorMessage = `Failed to ask question in session: ${error}`; 291 | } 292 | return { 293 | content: [ 294 | { 295 | type: 'text', 296 | text: errorMessage, 297 | }, 298 | ], 299 | }; 300 | } 301 | }, 302 | ); 303 | } 304 | 305 | if (isToolEnabled('stop_intensive_chat')) { 306 | // Use properties from the imported intensiveChatTools object 307 | server.tool( 308 | 'stop_intensive_chat', 309 | // Description is a string here 310 | typeof intensiveChatTools.stop.description === 'function' 311 | ? intensiveChatTools.stop.description(globalTimeoutSeconds) // Should not happen, but safe 312 | : intensiveChatTools.stop.description, 313 | intensiveChatTools.stop.schema, // Use schema property 314 | async (args) => { 315 | // Use inferred args type 316 | const { sessionId } = args; 317 | // Check if session exists 318 | if (!activeChatSessions.has(sessionId)) { 319 | return { 320 | content: [ 321 | { type: 'text', text: 'Error: Invalid or expired session ID.' }, 322 | ], 323 | }; 324 | } 325 | 326 | try { 327 | // Stop the session 328 | const success = await stopIntensiveChatSession(sessionId); 329 | // Remove session from map if successful 330 | if (success) { 331 | activeChatSessions.delete(sessionId); 332 | } 333 | const message = success 334 | ? 'Session stopped successfully.' 335 | : 'Session not found or already stopped.'; 336 | return { content: [{ type: 'text', text: message }] }; 337 | } catch (error: unknown) { 338 | let errorMessage = 'Failed to stop intensive chat session.'; 339 | if (error instanceof Error) { 340 | errorMessage = `Failed to stop intensive chat session: ${error.message}`; 341 | } else if (typeof error === 'string') { 342 | errorMessage = `Failed to stop intensive chat session: ${error}`; 343 | } 344 | return { content: [{ type: 'text', text: errorMessage }] }; 345 | } 346 | }, 347 | ); 348 | } 349 | // --- End Intensive Chat Tool Registrations --- 350 | 351 | // Run the server over stdio 352 | const transport = new StdioServerTransport(); 353 | await server.connect(transport); 354 | -------------------------------------------------------------------------------- /src/tool-definitions/intensive-chat.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodRawShape } from 'zod'; 2 | import { 3 | ToolDefinition, 4 | IntensiveChatToolDefinitions, 5 | ToolCapabilityInfo, 6 | ToolRegistrationDescription, 7 | } from './types.js'; 8 | 9 | // === Start Intensive Chat Definition === 10 | 11 | const startCapability: ToolCapabilityInfo = { 12 | description: 13 | 'Start an intensive chat session for gathering multiple answers quickly.', 14 | parameters: { 15 | type: 'object', 16 | properties: { 17 | sessionTitle: { 18 | type: 'string', 19 | description: 'Title for the intensive chat session', 20 | }, 21 | }, 22 | required: ['sessionTitle'], 23 | }, 24 | }; 25 | 26 | const startDescription: ToolRegistrationDescription = ( 27 | globalTimeoutSeconds: number, 28 | ) => ` 29 | Start an intensive chat session for gathering multiple answers quickly from the user. 30 | **Highly recommended** for scenarios requiring a sequence of related inputs or confirmations. 31 | Very useful for gathering multiple answers from the user in a short period of time. 32 | Especially useful for brainstorming ideas or discussing complex topics with the user. 33 | 34 | 35 | 36 | - (!important!) Opens a persistent console window that stays open for multiple questions. 37 | - (!important!) Returns a session ID that **must** be used for subsequent questions via 'ask_intensive_chat'. 38 | - (!important!) **Must** be closed with 'stop_intensive_chat' when finished gathering all inputs. 39 | - (!important!) After starting a session, **immediately** continue asking all necessary questions using 'ask_intensive_chat' within the **same response message**. Do not end the response until the chat is closed with 'stop_intensive_chat'. This creates a seamless conversational flow for the user. 40 | 41 | 42 | 43 | - When you need to collect a series of quick answers from the user (more than 2-3 questions) 44 | - When setting up a project with multiple configuration options 45 | - When guiding a user through a multi-step process requiring input at each stage 46 | - When gathering sequential user preferences 47 | - When you want to maintain context between multiple related questions efficiently 48 | - When brainstorming ideas with the user interactively 49 | 50 | 51 | 52 | - Opens a persistent console window for continuous interaction 53 | - Supports starting with an initial question 54 | - Configurable timeout for each question (set via -t/--timeout, defaults to ${globalTimeoutSeconds} seconds) 55 | - Returns a session ID for subsequent interactions 56 | - Keeps full chat history visible to the user 57 | - Maintains state between questions 58 | 59 | 60 | 61 | - Use a descriptive session title related to the task 62 | - Start with a clear initial question when possible 63 | - Do not ask the question if you have another tool that can answer the question 64 | - e.g. when you searching file in the current repository, do not ask the question "Do you want to search for a file in the current repository?" 65 | - e.g. prefer to use other tools to find the answer (Cursor tools or other MCP Server tools) 66 | - Always store the returned session ID for later use 67 | - Always close the session when you're done with stop_intensive_chat 68 | 69 | 70 | 71 | - sessionTitle: Title for the intensive chat session (appears at the top of the console) 72 | 73 | 74 | 75 | - Start session for project setup: { "sessionTitle": "Project Configuration" } 76 | `; 77 | 78 | const startSchema: ZodRawShape = { 79 | sessionTitle: z.string().describe('Title for the intensive chat session'), 80 | }; 81 | 82 | const startToolDefinition: ToolDefinition = { 83 | capability: startCapability, 84 | description: startDescription, 85 | schema: startSchema, 86 | }; 87 | 88 | // === Ask Intensive Chat Definition === 89 | 90 | const askCapability: ToolCapabilityInfo = { 91 | description: 'Ask a question in an active intensive chat session.', 92 | parameters: { 93 | type: 'object', 94 | properties: { 95 | sessionId: { 96 | type: 'string', 97 | description: 'ID of the intensive chat session', 98 | }, 99 | question: { 100 | type: 'string', 101 | description: 'Question to ask the user', 102 | }, 103 | predefinedOptions: { 104 | type: 'array', 105 | items: { type: 'string' }, 106 | optional: true, 107 | description: 108 | 'Predefined options for the user to choose from (optional)', 109 | }, 110 | }, 111 | required: ['sessionId', 'question'], 112 | }, 113 | }; 114 | 115 | const askDescription: ToolRegistrationDescription = ` 116 | Ask a new question in an active intensive chat session previously started with 'start_intensive_chat'. 117 | 118 | 119 | 120 | - (!important!) Requires a valid session ID from 'start_intensive_chat'. 121 | - (!important!) Supports predefined options for quick selection. 122 | - (!important!) Returns the user's answer or indicates if they didn't respond. 123 | - (!important!) **Use this repeatedly within the same response message** after 'start_intensive_chat' until all questions are asked. 124 | 125 | 126 | 127 | - When continuing a series of questions in an intensive chat session. 128 | - When you need the next piece of information in a multi-step process initiated via 'start_intensive_chat'. 129 | - When offering multiple choice options to the user within the session. 130 | - When gathering sequential information from the user within the session. 131 | 132 | 133 | 134 | - Adds a new question to an existing chat session 135 | - Supports predefined options for quick selection 136 | - Returns the user's response 137 | - Maintains the chat history in the console 138 | 139 | 140 | 141 | - Ask one clear question at a time 142 | - Provide predefined options when applicable 143 | - Don't ask overly complex questions 144 | - Keep questions focused on a single piece of information 145 | 146 | 147 | 148 | - sessionId: ID of the intensive chat session (from start_intensive_chat) 149 | - question: The question text to display to the user 150 | - predefinedOptions: Array of predefined options for the user to choose from (optional) 151 | 152 | 153 | 154 | - Simple question: { "sessionId": "abcd1234", "question": "What is your project named?" } 155 | - With predefined options: { "sessionId": "abcd1234", "question": "Would you like to use TypeScript?", "predefinedOptions": ["Yes", "No"] } 156 | `; 157 | 158 | const askSchema: ZodRawShape = { 159 | sessionId: z.string().describe('ID of the intensive chat session'), 160 | question: z.string().describe('Question to ask the user'), 161 | predefinedOptions: z 162 | .array(z.string()) 163 | .optional() 164 | .describe('Predefined options for the user to choose from (optional)'), 165 | }; 166 | 167 | const askToolDefinition: ToolDefinition = { 168 | capability: askCapability, 169 | description: askDescription, 170 | schema: askSchema, 171 | }; 172 | 173 | // === Stop Intensive Chat Definition === 174 | 175 | const stopCapability: ToolCapabilityInfo = { 176 | description: 'Stop and close an active intensive chat session.', 177 | parameters: { 178 | type: 'object', 179 | properties: { 180 | sessionId: { 181 | type: 'string', 182 | description: 'ID of the intensive chat session to stop', 183 | }, 184 | }, 185 | required: ['sessionId'], 186 | }, 187 | }; 188 | 189 | const stopDescription: ToolRegistrationDescription = ` 190 | Stop and close an active intensive chat session. **Must be called** after all questions have been asked using 'ask_intensive_chat'. 191 | 192 | 193 | 194 | - (!important!) Closes the console window for the intensive chat. 195 | - (!important!) Frees up system resources. 196 | - (!important!) **Should always be called** as the final step when finished with an intensive chat session, typically at the end of the response message where 'start_intensive_chat' was called. 197 | 198 | 199 | 200 | - When you've completed gathering all needed information via 'ask_intensive_chat'. 201 | - When the multi-step process requiring intensive chat is complete. 202 | - When you're ready to move on to processing the collected information. 203 | - When the user indicates they want to end the session (if applicable). 204 | - As the final action related to the intensive chat flow within a single response message. 205 | 206 | 207 | 208 | - Gracefully closes the console window 209 | - Cleans up system resources 210 | - Marks the session as complete 211 | 212 | 213 | 214 | - Always stop sessions when you're done to free resources 215 | - Provide a summary of the information collected before stopping 216 | 217 | 218 | 219 | - sessionId: ID of the intensive chat session to stop 220 | 221 | 222 | 223 | - { "sessionId": "abcd1234" } 224 | `; 225 | 226 | const stopSchema: ZodRawShape = { 227 | sessionId: z.string().describe('ID of the intensive chat session to stop'), 228 | }; 229 | 230 | const stopToolDefinition: ToolDefinition = { 231 | capability: stopCapability, 232 | description: stopDescription, 233 | schema: stopSchema, 234 | }; 235 | 236 | // === Export Combined Intensive Chat Definitions === 237 | 238 | export const intensiveChatTools: IntensiveChatToolDefinitions = { 239 | start: startToolDefinition, 240 | ask: askToolDefinition, 241 | stop: stopToolDefinition, 242 | }; 243 | -------------------------------------------------------------------------------- /src/tool-definitions/message-complete-notification.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodRawShape } from 'zod'; 2 | import { 3 | ToolDefinition, 4 | ToolCapabilityInfo, 5 | ToolRegistrationDescription, 6 | } from './types.js'; // Import the types 7 | 8 | // Define capability conforming to ToolCapabilityInfo 9 | const capabilityInfo: ToolCapabilityInfo = { 10 | description: 'Notify when a response has completed via OS notification.', 11 | parameters: { 12 | type: 'object', 13 | properties: { 14 | projectName: { 15 | type: 'string', 16 | description: 17 | 'Identifies the context/project making the notification (appears in notification title)', 18 | }, 19 | message: { 20 | type: 'string', 21 | description: 'The specific notification text (appears in the body)', 22 | }, 23 | }, 24 | required: ['projectName', 'message'], 25 | }, 26 | }; 27 | 28 | // Define description conforming to ToolRegistrationDescription 29 | const registrationDescription: ToolRegistrationDescription = ` 30 | Notify when a response has completed. Use this tool **once** at the end of **each and every** message to signal completion to the user. 31 | 32 | 33 | 34 | - (!important!) **MANDATORY:** ONLY use this tool exactly once per message to signal completion. **Do not forget this step.** 35 | 36 | 37 | 38 | - When you've completed answering a user's query 39 | - When you've finished executing a task or a sequence of tool calls 40 | - When a multi-step process is complete 41 | - When you want to provide a summary of completed actions just before ending the response 42 | 43 | 44 | 45 | - Cross-platform OS notifications (Windows, macOS, Linux) 46 | - Reusable tool to signal end of message 47 | - Should be called exactly once per LLM response 48 | 49 | 50 | 51 | - Keep messages concise 52 | - Use projectName consistently to group notifications by context 53 | 54 | 55 | 56 | - projectName: Identifies the context/project making the notification (appears in notification title) 57 | - message: The specific notification text (appears in the body) 58 | 59 | 60 | 61 | - { "projectName": "MyApp", "message": "Feature implementation complete. All tests passing." } 62 | - { "projectName": "MyLib", "message": "Analysis complete: 3 issues found and fixed." } 63 | `; 64 | 65 | // Define the Zod schema (as a raw shape object) 66 | const rawSchema: ZodRawShape = { 67 | projectName: z.string().describe('Notification title'), 68 | message: z.string().describe('Notification body'), 69 | }; 70 | 71 | // Combine into a single ToolDefinition object 72 | export const messageCompleteNotificationTool: ToolDefinition = { 73 | capability: capabilityInfo, 74 | description: registrationDescription, 75 | schema: rawSchema, // Use the raw shape here 76 | }; 77 | -------------------------------------------------------------------------------- /src/tool-definitions/request-user-input.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { 3 | ToolDefinition, 4 | ToolCapabilityInfo, 5 | ToolRegistrationDescription, 6 | } from './types.js'; // Import the types 7 | 8 | // Define capability conforming to ToolCapabilityInfo 9 | const capabilityInfo: ToolCapabilityInfo = { 10 | description: 11 | 'Send a question to the user via a pop-up command prompt and await their reply.', 12 | parameters: { 13 | type: 'object', 14 | properties: { 15 | projectName: { 16 | type: 'string', 17 | description: 18 | 'Identifies the context/project making the request (used in prompt formatting)', 19 | }, 20 | message: { 21 | type: 'string', 22 | description: 23 | 'The specific question for the user (appears in the prompt)', 24 | }, 25 | predefinedOptions: { 26 | type: 'array', 27 | items: { type: 'string' }, 28 | optional: true, // Mark as optional here too for consistency 29 | description: 30 | 'Predefined options for the user to choose from (optional)', 31 | }, 32 | }, 33 | required: ['projectName', 'message'], 34 | }, 35 | }; 36 | 37 | // Define description conforming to ToolRegistrationDescription 38 | const registrationDescription: ToolRegistrationDescription = ( 39 | globalTimeoutSeconds: number, 40 | ) => ` 41 | Send a question to the user via a pop-up command prompt. **Crucial for clarifying requirements, confirming plans, or resolving ambiguity.** 42 | You should call this tool whenever it has **any** uncertainty or needs clarification or confirmation, even for trivial or silly questions. 43 | Feel free to ask anything! **Proactive questioning is preferred over making assumptions.** 44 | 45 | 46 | 47 | - (!important!) **Use this tool FREQUENTLY** for any question that requires user input or confirmation. 48 | - (!important!) Continue to generate existing messages after user answers. 49 | - (!important!) Provide predefined options for quick selection if applicable. 50 | - (!important!) **Essential for validating assumptions before proceeding with significant actions (e.g., code edits, running commands).** 51 | 52 | 53 | 54 | - When you need clarification on user requirements or preferences 55 | - When multiple implementation approaches are possible and user input is needed 56 | - **Before making potentially impactful changes (code edits, file operations, complex commands)** 57 | - When you need to confirm assumptions before proceeding 58 | - When you need additional information not available in the current context 59 | - When validating potential solutions before implementation 60 | - When facing ambiguous instructions that require clarification 61 | - When seeking feedback on generated code or solutions 62 | - When needing permission to modify critical files or functionality 63 | - **Whenever you feel even slightly unsure about the user's intent or the correct next step.** 64 | 65 | 66 | 67 | - Pop-up command prompt display for user input 68 | - Returns user response or timeout notification (timeout defaults to ${globalTimeoutSeconds} seconds)) 69 | - Maintains context across user interactions 70 | - Handles empty responses gracefully 71 | - Properly formats prompt with project context 72 | 73 | 74 | 75 | - Keep questions concise and specific 76 | - Provide clear options when applicable 77 | - Do not ask the question if you have another tool that can answer the question 78 | - e.g. when you searching file in the current repository, do not ask the question "Do you want to search for a file in the current repository?" 79 | - e.g. prefer to use other tools to find the answer (Cursor tools or other MCP Server tools) 80 | - Limit questions to only what's necessary **to resolve the uncertainty** 81 | - Format complex questions into simple choices 82 | - Reference specific code or files when relevant 83 | - Indicate why the information is needed 84 | - Use appropriate urgency based on importance 85 | 86 | 87 | 88 | - projectName: Identifies the context/project making the request (used in prompt formatting) 89 | - message: The specific question for the user (appears in the prompt) 90 | - predefinedOptions: Predefined options for the user to choose from (optional) 91 | 92 | 93 | 94 | - "Should I implement the authentication using JWT or OAuth?" 95 | - "Do you want to use TypeScript interfaces or type aliases for this component?" 96 | - "I found three potential bugs. Should I fix them all or focus on the critical one first?" 97 | - "Can I refactor the database connection code to use connection pooling?" 98 | - "Is it acceptable to add React Router as a dependency?" 99 | - "I plan to modify function X in file Y. Is that correct?" 100 | `; 101 | 102 | // Define the Zod schema (as a raw shape object) 103 | const rawSchema: z.ZodRawShape = { 104 | projectName: z 105 | .string() 106 | .describe( 107 | 'Identifies the context/project making the request (used in prompt formatting)', 108 | ), 109 | message: z 110 | .string() 111 | .describe('The specific question for the user (appears in the prompt)'), 112 | predefinedOptions: z 113 | .array(z.string()) 114 | .optional() 115 | .describe('Predefined options for the user to choose from (optional)'), 116 | }; 117 | 118 | // Combine into a single ToolDefinition object 119 | export const requestUserInputTool: ToolDefinition = { 120 | capability: capabilityInfo, 121 | description: registrationDescription, 122 | schema: rawSchema, // Use the raw shape here 123 | }; 124 | -------------------------------------------------------------------------------- /src/tool-definitions/types.ts: -------------------------------------------------------------------------------- 1 | import { z, ZodRawShape } from 'zod'; 2 | 3 | // Type for the 'capability' part as used in McpServer capabilities 4 | export interface ToolCapabilityInfo { 5 | description: string; 6 | parameters: object; // Use z.ZodTypeAny or Record if parameters have a known structure 7 | } 8 | 9 | // Type for the 'description' part provided to server.tool() 10 | // It can be a simple string or a function that might use configuration (like timeout) 11 | export type ToolRegistrationDescription = 12 | | string 13 | | ((timeout: number) => string); 14 | 15 | // Define the combined structure for a single tool's definition 16 | export interface ToolDefinition { 17 | capability: ToolCapabilityInfo; 18 | description: ToolRegistrationDescription; 19 | schema: ZodRawShape; 20 | } 21 | 22 | // Special structure for intensive chat tools, as they are grouped 23 | export interface IntensiveChatToolDefinitions { 24 | start: ToolDefinition; 25 | ask: ToolDefinition; 26 | stop: ToolDefinition; 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/interactive-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useEffect } from 'react'; 2 | import { Box, Text } from 'ink'; 3 | import { useInput } from 'ink'; 4 | 5 | // Interface for shared input component props 6 | export interface InteractiveInputProps { 7 | question: string; // Question text to display 8 | questionId?: string; // Optional ID for the question (used in intensive chat) 9 | predefinedOptions?: string[]; // Optional list of choices 10 | onSubmit: (value: string, questionId?: string) => void; // Callback on submission 11 | } 12 | 13 | // Shared input component using Ink 14 | export const InteractiveInput: FC = ({ 15 | question, 16 | questionId, 17 | predefinedOptions, 18 | onSubmit, 19 | }) => { 20 | const [mode, setMode] = useState<'option' | 'custom'>('option'); 21 | const [selectedIndex, setSelectedIndex] = useState(0); 22 | const [customValue, setCustomValue] = useState(''); 23 | const [cursorPosition, setCursorPosition] = useState(0); 24 | // Get the character under cursor, if any 25 | const charUnderCursor = customValue[cursorPosition] || null; 26 | 27 | // If there are no predefined options, default to custom input mode 28 | useEffect(() => { 29 | if (!predefinedOptions || predefinedOptions.length === 0) { 30 | setMode('custom'); 31 | } else { 32 | // Ensure mode is 'option' if options become available 33 | setMode('option'); 34 | setSelectedIndex(0); // Reset selection 35 | } 36 | }, [predefinedOptions]); 37 | 38 | // Capture key presses 39 | useInput((input, key) => { 40 | if ((key.upArrow || key.downArrow) && predefinedOptions?.length) { 41 | // cycle selection among predefined options 42 | setSelectedIndex((prev) => { 43 | if (key.upArrow) { 44 | return prev > 0 ? prev - 1 : predefinedOptions.length - 1; 45 | } else { 46 | return prev < predefinedOptions.length - 1 ? prev + 1 : 0; 47 | } 48 | }); 49 | setMode('option'); 50 | } else if (key.leftArrow) { 51 | if (mode === 'custom') { 52 | // Move cursor left if possible 53 | setCursorPosition((prev) => Math.max(0, prev - 1)); 54 | } else { 55 | // If in option mode, just switch to custom mode but keep cursor at 0 56 | setMode('custom'); 57 | setCursorPosition(0); 58 | } 59 | } else if (key.rightArrow) { 60 | if (mode === 'custom') { 61 | // Move cursor right if possible 62 | setCursorPosition((prev) => Math.min(customValue.length, prev + 1)); 63 | } else { 64 | // If in option mode, switch to custom mode with cursor at end of text 65 | setMode('custom'); 66 | setCursorPosition(customValue.length); 67 | } 68 | } else if (key.return) { 69 | const value = 70 | mode === 'custom' 71 | ? customValue 72 | : (predefinedOptions && predefinedOptions[selectedIndex]) || ''; 73 | onSubmit(value, questionId); // Pass questionId back if it exists 74 | } else if (key.backspace || key.delete) { 75 | if (mode === 'custom') { 76 | if (key.delete && cursorPosition < customValue.length) { 77 | // Delete: remove character at cursor position 78 | setCustomValue( 79 | (prev) => 80 | prev.slice(0, cursorPosition) + prev.slice(cursorPosition + 1), 81 | ); 82 | } else if (key.backspace && cursorPosition > 0) { 83 | // Backspace: remove character before cursor and move cursor left 84 | setCustomValue( 85 | (prev) => 86 | prev.slice(0, cursorPosition - 1) + prev.slice(cursorPosition), 87 | ); 88 | setCursorPosition((prev) => prev - 1); 89 | } 90 | } 91 | } else if (input && input.length === 1 && !key.ctrl && !key.meta) { 92 | // Any other non-modifier key appends to custom input 93 | setMode('custom'); 94 | // Insert at cursor position instead of appending 95 | setCustomValue( 96 | (prev) => 97 | prev.slice(0, cursorPosition) + input + prev.slice(cursorPosition), 98 | ); 99 | setCursorPosition((prev) => prev + 1); 100 | } 101 | }); 102 | 103 | return ( 104 | <> 105 | {/* Display the question */} 106 | 107 | 108 | {question} 109 | 110 | 111 | 112 | {/* Display predefined options if available */} 113 | {predefinedOptions && predefinedOptions.length > 0 && ( 114 | 115 | 116 | Use ↑/↓ to select options, type any key for custom input, Enter to 117 | submit 118 | 119 | {predefinedOptions.map((opt, i) => ( 120 | 130 | {i === selectedIndex ? (mode === 'option' ? '› ' : ' ') : ' '} 131 | {opt} 132 | 133 | ))} 134 | 135 | )} 136 | 137 | {/* Custom input line with cursor visualization */} 138 | 139 | 140 | 0 || mode === 'custom' 143 | ? mode === 'custom' 144 | ? 'greenBright' 145 | : 'green' 146 | : undefined 147 | } 148 | > 149 | {customValue.length > 0 && mode === 'custom' ? '✎ ' : ' '} 150 | {/* Only show "Custom: " label when there are predefined options */} 151 | {predefinedOptions && predefinedOptions.length > 0 152 | ? 'Custom: ' 153 | : ''} 154 | {customValue.slice(0, cursorPosition)} 155 | 156 | {/* Cursor character simulation */} 157 | {charUnderCursor ? ( 158 | 159 | {charUnderCursor} 160 | 161 | ) : ( 162 | // Display block cursor only in custom mode or when options are present (to show where custom input would go) 163 | (mode === 'custom' || 164 | (predefinedOptions && predefinedOptions.length > 0)) && ( 165 | 166 | ) 167 | )} 168 | 0 || mode === 'custom' 171 | ? mode === 'custom' 172 | ? 'greenBright' 173 | : 'green' 174 | : undefined 175 | } 176 | > 177 | {customValue.slice(cursorPosition + 1)} 178 | 179 | 180 | 181 | 182 | ); 183 | }; 184 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pino, 3 | Logger, 4 | TransportSingleOptions, 5 | TransportMultiOptions, 6 | TransportPipelineOptions, 7 | } from 'pino'; 8 | import path from 'path'; 9 | import fs from 'fs'; 10 | import os from 'os'; 11 | 12 | const logDir = path.resolve(os.tmpdir(), 'interactive-mcp-logs'); 13 | const logFile = path.join(logDir, 'dev.log'); 14 | 15 | // Ensure log directory exists 16 | if (process.env.NODE_ENV === 'development' && !fs.existsSync(logDir)) { 17 | try { 18 | fs.mkdirSync(logDir, { recursive: true }); 19 | } catch (error) { 20 | console.error('Failed to create log directory:', error); // Use console here as logger isn't initialized yet 21 | // Consider fallback behavior or exiting if logging is critical 22 | } 23 | } 24 | 25 | const isDevelopment = process.env.NODE_ENV === 'development'; 26 | 27 | const loggerOptions: pino.LoggerOptions = { 28 | level: isDevelopment ? 'trace' : 'silent', // Default level 29 | }; 30 | 31 | if (isDevelopment) { 32 | let devTransportConfig: 33 | | TransportSingleOptions 34 | | TransportMultiOptions 35 | | TransportPipelineOptions; 36 | try { 37 | // Attempt to open the file in append mode to check writability before setting up transport 38 | const fd = fs.openSync(logFile, 'a'); 39 | fs.closeSync(fd); 40 | devTransportConfig = { 41 | targets: [ 42 | { 43 | target: 'pino-pretty', // Log to console with pretty printing 44 | options: { 45 | colorize: true, 46 | sync: false, // Use async logging 47 | translateTime: 'SYS:yyyy-mm-dd HH:MM:ss', 48 | ignore: 'pid,hostname', 49 | }, 50 | level: 'trace', // Log all levels to the console in dev 51 | }, 52 | { 53 | target: 'pino/file', // Log to file 54 | options: { destination: logFile, mkdir: true }, // Specify file path and ensure directory exists 55 | level: 'trace', // Log all levels to the file in dev 56 | }, 57 | ], 58 | }; 59 | } catch (error) { 60 | console.error( 61 | `Failed to setup file transport for ${logFile}. Falling back to console-only logging. Error:`, 62 | error, 63 | ); 64 | // Fallback transport to console only using pino-pretty if file access fails 65 | devTransportConfig = { 66 | target: 'pino-pretty', 67 | options: { 68 | colorize: true, 69 | sync: false, 70 | translateTime: 'SYS:yyyy-mm-dd HH:MM:ss', 71 | ignore: 'pid,hostname', 72 | }, 73 | level: 'trace', 74 | }; 75 | } 76 | // Add transport to logger options only in development 77 | loggerOptions.transport = devTransportConfig; 78 | } 79 | 80 | const logger: Logger = pino(loggerOptions); 81 | 82 | export default logger; 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "lib": [ 7 | "es2022" 8 | ], 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": [ 12 | "src/*" 13 | ] 14 | }, 15 | "rootDir": "./src", 16 | "outDir": "./dist", 17 | "esModuleInterop": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "jsx": "react", 22 | "allowSyntheticDefaultImports": true 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } --------------------------------------------------------------------------------