├── .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 | [](https://www.npmjs.com/package/interactive-mcp) [](https://www.npmjs.com/package/interactive-mcp) [](https://smithery.ai/server/@ttommyth/interactive-mcp) [](https://github.com/ttommyth/interactive-mcp/blob/main/LICENSE) [](https://github.com/prettier/prettier) [](https://github.com/ttommyth/interactive-mcp) [](https://github.com/ttommyth/interactive-mcp/commits/main)
4 |
5 | [](https://cursor.com/install-mcp?name=interactive&config=eyJjb21tYW5kIjoibnB4IC15IGludGVyYWN0aXZlLW1jcCJ9)
6 |
7 | 
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 |
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 | |  |  |
40 |
41 | | Intensive Chat Start | Intensive Chat End |
42 | | :------------------------------------------------------------------: | :--------------------------------------------------------------: |
43 | |  |  |
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 | }
--------------------------------------------------------------------------------