Compare commits
15 Commits
6dcb4a888c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd67c06c6 | ||
|
|
df1bbdcc8b | ||
|
|
b3fdd383ac | ||
|
|
18fcc06626 | ||
|
|
d589e3128d | ||
|
|
dc92c7fcf6 | ||
|
|
e26d94dcc2 | ||
|
|
93d65d9a34 | ||
|
|
0ce65d7120 | ||
|
|
0d277e4ae2 | ||
|
|
abde877912 | ||
|
|
bdfe51fedf | ||
|
|
fd750d9b50 | ||
|
|
82f46c4569 | ||
|
|
0584ac6c5f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# PM2
|
# PM2
|
||||||
.pm2/
|
.pm2/
|
||||||
|
|
||||||
|
# Stale project copy
|
||||||
|
innervoice/
|
||||||
|
|||||||
61
PHASE1-TESTING.md
Normal file
61
PHASE1-TESTING.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Phase 1 Testing Guide
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
✅ Multi-project session tracking with project context display
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Auto-generate unique session IDs for each Claude instance
|
||||||
|
- Track project name and path from working directory
|
||||||
|
- Show project context in all Telegram messages: `📁 ProjectName [#shortId]`
|
||||||
|
- `/sessions` command to list all active Claude instances
|
||||||
|
- Auto-expire inactive sessions after 30 minutes
|
||||||
|
|
||||||
|
## How to Test
|
||||||
|
|
||||||
|
### 1. Restart Claude Code
|
||||||
|
**Important:** You need to restart Claude Code to load the new MCP server code.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exit your current Claude Code sessions
|
||||||
|
# Then restart in your project
|
||||||
|
cd /path/to/your/project
|
||||||
|
claude
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Project Context
|
||||||
|
Send a notification and you should see the project name:
|
||||||
|
|
||||||
|
```
|
||||||
|
📁 ESO-MCP [#1a2b3c4]
|
||||||
|
ℹ️ Your message here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Multi-Project Sessions
|
||||||
|
1. Open Claude in **ESO-MCP** project
|
||||||
|
2. Open another terminal and start Claude in **innervoice** project
|
||||||
|
3. In Telegram, type `/sessions`
|
||||||
|
4. You should see both projects listed:
|
||||||
|
|
||||||
|
```
|
||||||
|
Active Claude Sessions (2)
|
||||||
|
|
||||||
|
1. 🟢 ESO-MCP [#1a2b3c4]
|
||||||
|
Last active: 0m ago
|
||||||
|
|
||||||
|
2. 🟢 innervoice [#5d6e7f8]
|
||||||
|
Last active: 2m ago
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test Session Auto-Expire
|
||||||
|
Wait 30 minutes of inactivity, then run `/sessions` again.
|
||||||
|
Inactive sessions should be removed automatically.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- You must restart Claude Code for changes to take effect
|
||||||
|
- Old MCP server processes won't pick up new code automatically
|
||||||
|
|
||||||
|
## Next: Phase 2
|
||||||
|
|
||||||
|
Message queue system for offline/inactive projects coming next!
|
||||||
505
README.md
505
README.md
@@ -24,14 +24,33 @@ After trying email, SMS, and Google Chat integrations, Telegram emerged as the b
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Core Communication
|
||||||
- 💬 **Two-Way Communication** - Send messages to Claude, get responses back
|
- 💬 **Two-Way Communication** - Send messages to Claude, get responses back
|
||||||
- ❓ **Question/Answer Flow** - Claude can ask you questions and wait for answers
|
- ❓ **Question/Answer Flow** - Claude can ask you questions and wait for answers
|
||||||
- 📬 **Message Queue** - Messages queue up when Claude is busy, get answered ASAP
|
|
||||||
- 🔔 **Priority Notifications** - Different icons for info, success, warning, error, question
|
- 🔔 **Priority Notifications** - Different icons for info, success, warning, error, question
|
||||||
- 🌐 **HTTP API** - Easy integration from any app/project
|
- 🌐 **HTTP API** - Easy integration from any app/project
|
||||||
- 🚀 **Background Service** - Runs independently, always available
|
- 🚀 **Background Service** - Runs independently, always available
|
||||||
- 🔧 **MCP Protocol** - Works as a standard MCP server in any Claude project
|
- 🔧 **MCP Protocol** - Works as a standard MCP server in any Claude project
|
||||||
|
|
||||||
|
### Multi-Project Support
|
||||||
|
- 📁 **Project Context** - All messages show which project they're from
|
||||||
|
- 🎯 **Targeted Messages** - Send messages to specific projects: `ProjectName: your message`
|
||||||
|
- 📊 **Session Tracking** - Monitor active Claude sessions across projects
|
||||||
|
- 🔄 **Auto-Session Registration** - Projects auto-register when Claude starts
|
||||||
|
|
||||||
|
### Message Queue System
|
||||||
|
- 📬 **Offline Queuing** - Messages queue when projects are offline
|
||||||
|
- 📥 **Persistent Storage** - Queued messages survive restarts
|
||||||
|
- ⏰ **Auto-Delivery** - Messages delivered when Claude starts in that project
|
||||||
|
- 🧹 **Auto-Cleanup** - Old messages expire automatically
|
||||||
|
|
||||||
|
### Remote Claude Spawner
|
||||||
|
- 🚀 **Remote Spawning** - Start Claude in any project from Telegram
|
||||||
|
- 📝 **Project Registry** - Register projects for easy remote access
|
||||||
|
- 🔄 **Auto-Spawn** - Optional auto-start when messages arrive
|
||||||
|
- 💀 **Process Management** - Track and kill spawned Claude instances
|
||||||
|
- 🎯 **Initial Prompts** - Start Claude with a specific task
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
This is a **standard MCP server** that works like any other MCP tool. Once installed and configured:
|
This is a **standard MCP server** that works like any other MCP tool. Once installed and configured:
|
||||||
@@ -195,12 +214,13 @@ cd
|
|||||||
### 6. Available Tools
|
### 6. Available Tools
|
||||||
|
|
||||||
Once configured, Claude can automatically use:
|
Once configured, Claude can automatically use:
|
||||||
- `telegram_notify` - Send notifications
|
- `telegram_notify` - Send notifications with project context
|
||||||
- `telegram_ask` - Ask questions and wait for answers
|
- `telegram_ask` - Ask questions and wait for answers
|
||||||
- `telegram_get_messages` - Check for messages from you
|
- `telegram_get_messages` - Check for messages from you
|
||||||
- `telegram_reply` - Reply to your messages
|
- `telegram_reply` - Reply to your messages
|
||||||
- `telegram_check_health` - Check bridge status
|
- `telegram_check_health` - Check bridge status
|
||||||
- `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications)
|
- `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications)
|
||||||
|
- `telegram_check_queue` - Check for queued messages on startup
|
||||||
|
|
||||||
**View detailed tool info:**
|
**View detailed tool info:**
|
||||||
```bash
|
```bash
|
||||||
@@ -228,10 +248,14 @@ The toggle state is preserved while the bridge is running, and you'll get a Tele
|
|||||||
|
|
||||||
By default, AFK mode only sends notifications when Claude explicitly uses notification tools. If you want to receive Telegram alerts when **permission prompts** appear (so you know Claude is waiting for approval), install the permission hook:
|
By default, AFK mode only sends notifications when Claude explicitly uses notification tools. If you want to receive Telegram alerts when **permission prompts** appear (so you know Claude is waiting for approval), install the permission hook:
|
||||||
|
|
||||||
|
**Recommended: Install Globally (works in all projects)**
|
||||||
```bash
|
```bash
|
||||||
# From the innervoice directory
|
|
||||||
cd /path/to/innervoice
|
cd /path/to/innervoice
|
||||||
|
./scripts/install-hook.sh --global
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or install per-project:**
|
||||||
|
```bash
|
||||||
# Install in a specific project
|
# Install in a specific project
|
||||||
./scripts/install-hook.sh /path/to/your/project
|
./scripts/install-hook.sh /path/to/your/project
|
||||||
|
|
||||||
@@ -246,7 +270,9 @@ This will send you a Telegram message like:
|
|||||||
> **Action:** Check scraped sets files
|
> **Action:** Check scraped sets files
|
||||||
> Check your terminal to approve or deny.
|
> Check your terminal to approve or deny.
|
||||||
|
|
||||||
**To uninstall:** Simply delete `.claude/hooks/PermissionRequest.sh` from your project.
|
**To uninstall:**
|
||||||
|
- Global: `rm ~/.claude/hooks/PermissionRequest.sh`
|
||||||
|
- Per-project: `rm .claude/hooks/PermissionRequest.sh`
|
||||||
|
|
||||||
### 8. Verify Global Setup
|
### 8. Verify Global Setup
|
||||||
|
|
||||||
@@ -294,6 +320,206 @@ Restart Claude Code, then tell Claude:
|
|||||||
|
|
||||||
Claude will automatically discover and use the `telegram_notify` tool!
|
Claude will automatically discover and use the `telegram_notify` tool!
|
||||||
|
|
||||||
|
## Usage Scenarios
|
||||||
|
|
||||||
|
InnerVoice supports multiple usage patterns depending on your workflow:
|
||||||
|
|
||||||
|
### Scenario 1: Single Active Project
|
||||||
|
**Use Case:** You're actively working in one project
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Start Claude Code in your project
|
||||||
|
2. Claude auto-registers its session
|
||||||
|
3. All messages go to the active session
|
||||||
|
4. Messages show project context: `📁 MyProject [#abc1234]`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
You in Telegram: "Check the test status"
|
||||||
|
Bot: 💬 Message received - responding...
|
||||||
|
Claude: "Running tests... ✅ All 42 tests passed!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Multiple Active Projects
|
||||||
|
**Use Case:** Working across multiple projects simultaneously
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Start Claude in multiple projects (each auto-registers)
|
||||||
|
2. Send targeted messages: `ProjectName: your message`
|
||||||
|
3. View active sessions with `/sessions`
|
||||||
|
4. Each response shows its project context
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
You: "/sessions"
|
||||||
|
Bot: Active Claude Sessions (3)
|
||||||
|
1. 🟢 ESO-MCP [#abc1234]
|
||||||
|
Last active: 2m ago
|
||||||
|
2. 🟢 InnerVoice [#def5678]
|
||||||
|
Last active: 5m ago
|
||||||
|
3. 🟢 MyApp [#ghi9012]
|
||||||
|
Last active: 1m ago
|
||||||
|
|
||||||
|
You: "ESO-MCP: run the scraper"
|
||||||
|
Bot: 💬 Message sent to active session: ESO-MCP
|
||||||
|
Claude in ESO-MCP: 📁 ESO-MCP [#abc1234]
|
||||||
|
✅ Scraper started...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Offline Project Queuing
|
||||||
|
**Use Case:** Send work to a project before Claude is running
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Send: `ProjectName: your task`
|
||||||
|
2. If project offline, message queues automatically
|
||||||
|
3. Start Claude in that project
|
||||||
|
4. Claude checks queue on startup and processes tasks
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
You: "MyApp: fix the login bug"
|
||||||
|
Bot: 📥 Message queued for MyApp (offline)
|
||||||
|
It will be delivered when Claude starts in that project.
|
||||||
|
|
||||||
|
[Later, you start Claude in MyApp]
|
||||||
|
Claude: 📬 You have 1 queued message:
|
||||||
|
1. From Richard (2:30 PM)
|
||||||
|
fix the login bug
|
||||||
|
|
||||||
|
These messages were sent while you were offline.
|
||||||
|
[Claude proceeds to work on the task]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: Remote Claude Spawning
|
||||||
|
**Use Case:** Start work remotely without opening your terminal
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
# Register your projects once
|
||||||
|
You: "/register MyApp ~/code/myapp"
|
||||||
|
Bot: ✅ Project registered successfully!
|
||||||
|
📁 MyApp
|
||||||
|
📍 /Users/you/code/myapp
|
||||||
|
⏸️ Manual spawn only
|
||||||
|
|
||||||
|
Spawn with: /spawn MyApp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Daily Usage:**
|
||||||
|
```
|
||||||
|
You: "/spawn MyApp Fix the login bug"
|
||||||
|
Bot: ⏳ Starting Claude in MyApp...
|
||||||
|
✅ Claude started in MyApp with prompt: "Fix the login bug"
|
||||||
|
PID: 12345
|
||||||
|
|
||||||
|
You can now send messages to it: MyApp: your message
|
||||||
|
|
||||||
|
[Claude automatically starts working on the bug]
|
||||||
|
Claude: 📁 MyApp [#abc1234]
|
||||||
|
🔍 Analyzing login flow...
|
||||||
|
✅ Bug fixed! The session timeout was too short.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 5: Auto-Spawn Projects
|
||||||
|
**Use Case:** Projects that should start automatically when messaged
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
You: "/register MyApp ~/code/myapp --auto-spawn"
|
||||||
|
Bot: ✅ Project registered successfully!
|
||||||
|
📁 MyApp
|
||||||
|
📍 /Users/you/code/myapp
|
||||||
|
🔄 Auto-spawn enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Daily Usage:**
|
||||||
|
```
|
||||||
|
You: "MyApp: run the tests"
|
||||||
|
Bot: 🚀 Auto-spawning Claude in MyApp...
|
||||||
|
✅ Claude started in MyApp
|
||||||
|
PID: 12345
|
||||||
|
|
||||||
|
[Claude auto-starts and processes the message]
|
||||||
|
Claude: 📁 MyApp [#abc1234]
|
||||||
|
🧪 Running test suite...
|
||||||
|
✅ All 42 tests passed!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 6: Managing Multiple Projects
|
||||||
|
**View all projects:**
|
||||||
|
```
|
||||||
|
You: "/projects"
|
||||||
|
Bot: Registered Projects (4)
|
||||||
|
|
||||||
|
1. 🟢 ESO-MCP 🔄
|
||||||
|
📍 /Users/you/code/eso-mcp
|
||||||
|
🕐 Last: 12/23/2025
|
||||||
|
|
||||||
|
2. ⚪ InnerVoice ⏸️
|
||||||
|
📍 /Users/you/code/innervoice
|
||||||
|
🕐 Last: 12/22/2025
|
||||||
|
|
||||||
|
3. 🟢 MyApp 🔄
|
||||||
|
📍 /Users/you/code/myapp
|
||||||
|
🕐 Last: 12/23/2025
|
||||||
|
|
||||||
|
4. ⚪ TestProject ⏸️
|
||||||
|
📍 /Users/you/code/test
|
||||||
|
🕐 Last: 12/20/2025
|
||||||
|
|
||||||
|
🟢 Running ⚪ Offline 🔄 Auto-spawn ⏸️ Manual
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check running processes:**
|
||||||
|
```
|
||||||
|
You: "/spawned"
|
||||||
|
Bot: Spawned Claude Processes (2)
|
||||||
|
|
||||||
|
1. ESO-MCP
|
||||||
|
🆔 PID: 12345
|
||||||
|
⏱️ Running: 15m
|
||||||
|
💬 "run the scraper"
|
||||||
|
|
||||||
|
2. MyApp
|
||||||
|
🆔 PID: 12346
|
||||||
|
⏱️ Running: 5m
|
||||||
|
|
||||||
|
Kill with: /kill ProjectName
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stop a project:**
|
||||||
|
```
|
||||||
|
You: "/kill MyApp"
|
||||||
|
Bot: 🛑 ✅ Claude process terminated in MyApp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telegram Bot Commands
|
||||||
|
|
||||||
|
Complete list of available bot commands:
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- `/sessions` - List all active Claude sessions with status
|
||||||
|
- `/queue` - View queued messages for offline projects
|
||||||
|
|
||||||
|
### Project Management
|
||||||
|
- `/projects` - List all registered projects with status
|
||||||
|
- `/register ProjectName /path [--auto-spawn]` - Register a new project
|
||||||
|
- `/unregister ProjectName` - Remove a project from registry
|
||||||
|
- `/spawn ProjectName [prompt]` - Start Claude in a project
|
||||||
|
- `/spawned` - List all running spawned Claude processes
|
||||||
|
- `/kill ProjectName` - Terminate a spawned Claude process
|
||||||
|
|
||||||
|
### Bot Control
|
||||||
|
- `/start` - Initialize bot and save your chat ID
|
||||||
|
- `/help` - Show all available commands
|
||||||
|
- `/status` - Check bridge health and status
|
||||||
|
- `/test` - Send a test notification
|
||||||
|
|
||||||
|
### Message Syntax
|
||||||
|
- Regular message: Goes to active Claude (if only one running)
|
||||||
|
- `ProjectName: message` - Send to specific project
|
||||||
|
- If project offline, message automatically queues
|
||||||
|
|
||||||
## MCP Tools Reference
|
## MCP Tools Reference
|
||||||
|
|
||||||
Once configured, Claude can automatically use these tools:
|
Once configured, Claude can automatically use these tools:
|
||||||
@@ -359,6 +585,25 @@ Toggle AFK (Away From Keyboard) mode - enables or disables Telegram notification
|
|||||||
- Disable when actively working (avoid interruptions)
|
- Disable when actively working (avoid interruptions)
|
||||||
- State is preserved while the bridge is running
|
- State is preserved while the bridge is running
|
||||||
|
|
||||||
|
### `telegram_check_queue`
|
||||||
|
Check if there are queued messages for this project from when Claude was offline.
|
||||||
|
|
||||||
|
**No parameters required**
|
||||||
|
|
||||||
|
**Example Claude Usage:**
|
||||||
|
> "On startup, let me check for any queued messages."
|
||||||
|
> *Claude uses: `telegram_check_queue({})`*
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- List of messages sent while Claude was offline
|
||||||
|
- Includes sender, timestamp, and message content
|
||||||
|
- Messages are marked as delivered after retrieval
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- On startup to catch up on offline messages
|
||||||
|
- Proactively check for pending work
|
||||||
|
- After long idle periods
|
||||||
|
|
||||||
## Git Setup (For Sharing)
|
## Git Setup (For Sharing)
|
||||||
|
|
||||||
If you want to push this to your own Git repository:
|
If you want to push this to your own Git repository:
|
||||||
@@ -436,32 +681,37 @@ await fetch('http://localhost:3456/notify', {
|
|||||||
- `error` - ❌ Error occurred
|
- `error` - ❌ Error occurred
|
||||||
- `question` - ❓ Needs your input
|
- `question` - ❓ Needs your input
|
||||||
|
|
||||||
## Bot Commands
|
## How Communication Works
|
||||||
|
|
||||||
Type these in Telegram to control the bridge:
|
### Basic Message Flow
|
||||||
|
|
||||||
- `/start` - Initialize connection and save your chat ID
|
|
||||||
- `/help` - Show all available commands and how to use the bridge
|
|
||||||
- `/status` - Check bridge status (enabled, unread messages, pending questions)
|
|
||||||
- `/test` - Send a test notification to verify it's working
|
|
||||||
|
|
||||||
## How Two-Way Communication Works
|
|
||||||
|
|
||||||
### You → Claude
|
|
||||||
1. Send any message to the bot in Telegram
|
1. Send any message to the bot in Telegram
|
||||||
2. Bot acknowledges with "💬 Message received - responding..."
|
2. Bot acknowledges with "💬 Message received - responding..."
|
||||||
3. Claude checks messages and responds when available
|
3. Claude checks messages and responds when available
|
||||||
4. You get the response in Telegram
|
4. You get the response in Telegram with project context
|
||||||
|
|
||||||
### Claude → You (Notifications)
|
### Targeted Messages
|
||||||
Claude sends you updates via the `/notify` API endpoint with different priorities
|
Send `ProjectName: your message` to communicate with a specific project:
|
||||||
|
- If project is running: Message delivered immediately
|
||||||
|
- If project is offline: Message queues automatically
|
||||||
|
|
||||||
### Claude → You (Questions)
|
### Notifications
|
||||||
1. Claude sends a question via `/ask` API
|
Claude sends you updates via the `telegram_notify` tool with:
|
||||||
|
- Project context: `📁 ProjectName [#abc1234]`
|
||||||
|
- Priority icons: ℹ️ ✅ ⚠️ ❌ ❓
|
||||||
|
- Markdown formatting support
|
||||||
|
|
||||||
|
### Questions
|
||||||
|
1. Claude sends a question via `telegram_ask`
|
||||||
2. You see "❓ [question]" in Telegram
|
2. You see "❓ [question]" in Telegram
|
||||||
3. Your next message is automatically treated as the answer
|
3. Your next message is automatically treated as the answer
|
||||||
4. Claude receives your answer and continues
|
4. Claude receives your answer and continues
|
||||||
|
|
||||||
|
### Queued Messages
|
||||||
|
1. Send message to offline project
|
||||||
|
2. Message queues persistently
|
||||||
|
3. When Claude starts in that project, it checks the queue
|
||||||
|
4. Queued messages are delivered and processed
|
||||||
|
|
||||||
## Running as Background Service
|
## Running as Background Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -609,6 +859,221 @@ Get current notification state
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Session Management Endpoints
|
||||||
|
|
||||||
|
#### POST /session/register
|
||||||
|
Register or update a Claude session
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "unique-session-id",
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"projectPath": "/path/to/project"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"sessionId": "unique-session-id",
|
||||||
|
"projectName": "MyProject"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /sessions
|
||||||
|
List all active Claude sessions
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "1234567-abc",
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"projectPath": "/path/to/project",
|
||||||
|
"startTime": "2025-11-23T10:00:00.000Z",
|
||||||
|
"lastActivity": "2025-11-23T10:30:00.000Z",
|
||||||
|
"status": "active",
|
||||||
|
"idleMinutes": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queue Management Endpoints
|
||||||
|
|
||||||
|
#### GET /queue/:projectName
|
||||||
|
Get pending messages for a project
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "task-123",
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"message": "Fix the bug",
|
||||||
|
"from": "Richard",
|
||||||
|
"timestamp": "2025-11-23T09:00:00.000Z",
|
||||||
|
"priority": "normal",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /queue/summary
|
||||||
|
Get summary of all queued messages
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"pending": 2,
|
||||||
|
"delivered": 5,
|
||||||
|
"total": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalProjects": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Registry Endpoints
|
||||||
|
|
||||||
|
#### GET /projects
|
||||||
|
List all registered projects
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "MyProject",
|
||||||
|
"path": "/path/to/project",
|
||||||
|
"lastAccessed": "2025-11-23T10:00:00.000Z",
|
||||||
|
"autoSpawn": false,
|
||||||
|
"metadata": {
|
||||||
|
"description": "My project description",
|
||||||
|
"tags": ["web", "api"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /projects/register
|
||||||
|
Register a new project
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "MyProject",
|
||||||
|
"path": "/path/to/project",
|
||||||
|
"autoSpawn": false,
|
||||||
|
"description": "Optional description",
|
||||||
|
"tags": ["tag1", "tag2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"project": {
|
||||||
|
"name": "MyProject",
|
||||||
|
"path": "/path/to/project",
|
||||||
|
"lastAccessed": "2025-11-23T10:00:00.000Z",
|
||||||
|
"autoSpawn": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE /projects/:name
|
||||||
|
Unregister a project
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Project MyProject unregistered"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Spawner Endpoints
|
||||||
|
|
||||||
|
#### POST /spawn
|
||||||
|
Spawn Claude in a registered project
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"initialPrompt": "Optional initial task"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "✅ Claude started in MyProject",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /kill/:projectName
|
||||||
|
Terminate a spawned Claude process
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "✅ Claude process terminated in MyProject"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /spawned
|
||||||
|
List all spawned Claude processes
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"pid": 12345,
|
||||||
|
"startTime": "2025-11-23T10:00:00.000Z",
|
||||||
|
"initialPrompt": "Fix the bug",
|
||||||
|
"runningMinutes": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /spawned/:projectName
|
||||||
|
Check if Claude is running in a project
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"running": true,
|
||||||
|
"process": {
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"pid": 12345,
|
||||||
|
"runningMinutes": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Integration with ESO-MCP
|
## Integration with ESO-MCP
|
||||||
|
|
||||||
Add this helper to your ESO-MCP project:
|
Add this helper to your ESO-MCP project:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Install the InnerVoice permission notification hook in a project
|
# Install the InnerVoice permission notification hook globally or per-project
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -7,12 +7,22 @@ HOOK_NAME="PermissionRequest.sh"
|
|||||||
INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME"
|
SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME"
|
||||||
|
|
||||||
# Get target project directory (default to current directory)
|
# Check for --global flag
|
||||||
TARGET_DIR="${1:-.}"
|
if [ "$1" = "--global" ] || [ "$1" = "-g" ]; then
|
||||||
TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks"
|
TARGET_HOOK_DIR="$HOME/.claude/hooks"
|
||||||
|
SCOPE="globally (all projects)"
|
||||||
|
UNINSTALL_CMD="rm ~/.claude/hooks/$HOOK_NAME"
|
||||||
|
else
|
||||||
|
# Get target project directory (default to current directory)
|
||||||
|
TARGET_DIR="${1:-.}"
|
||||||
|
TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks"
|
||||||
|
SCOPE="in project: $TARGET_DIR"
|
||||||
|
UNINSTALL_CMD="rm $TARGET_HOOK_DIR/$HOOK_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "📦 Installing InnerVoice Permission Notification Hook"
|
echo "📦 Installing InnerVoice Permission Notification Hook"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Scope: $SCOPE"
|
||||||
echo "Source: $SOURCE_HOOK"
|
echo "Source: $SOURCE_HOOK"
|
||||||
echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME"
|
echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -35,4 +45,4 @@ echo ""
|
|||||||
echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications"
|
echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications"
|
||||||
echo " whenever Claude requests permission for a tool."
|
echo " whenever Claude requests permission for a tool."
|
||||||
echo ""
|
echo ""
|
||||||
echo "To uninstall: rm $TARGET_HOOK_DIR/$HOOK_NAME"
|
echo "To uninstall: $UNINSTALL_CMD"
|
||||||
|
|||||||
326
src/chatbot.ts
Normal file
326
src/chatbot.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
|
|
||||||
|
interface ChatbotSession {
|
||||||
|
sessionId: string;
|
||||||
|
lastActivity: number;
|
||||||
|
messageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeJsonResponse {
|
||||||
|
type: string;
|
||||||
|
subtype: string;
|
||||||
|
cost_usd: number;
|
||||||
|
is_error: boolean;
|
||||||
|
duration_ms: number;
|
||||||
|
num_turns: number;
|
||||||
|
result: string;
|
||||||
|
session_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
|
||||||
|
const CHATBOT_ENABLED = () => process.env.CHATBOT_ENABLED !== 'false';
|
||||||
|
const CHATBOT_CWD = () => process.env.CHATBOT_CWD || '/home/fitcrm';
|
||||||
|
const CHATBOT_MAX_TURNS = () => process.env.CHATBOT_MAX_TURNS || '3';
|
||||||
|
const CHATBOT_TIMEOUT = () => parseInt(process.env.CHATBOT_TIMEOUT || '120000');
|
||||||
|
const CHATBOT_SESSION_TIMEOUT = () => parseInt(process.env.CHATBOT_SESSION_TIMEOUT || '1800000');
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
|
||||||
|
const chatbotSessions = new Map<string, ChatbotSession>();
|
||||||
|
const activeLocks = new Set<string>();
|
||||||
|
let cleanupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// --- Public API ---
|
||||||
|
|
||||||
|
export function isChatbotEnabled(): boolean {
|
||||||
|
return CHATBOT_ENABLED();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initChatbot(): void {
|
||||||
|
if (cleanupTimer) clearInterval(cleanupTimer);
|
||||||
|
|
||||||
|
cleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeout = CHATBOT_SESSION_TIMEOUT();
|
||||||
|
for (const [chatId, session] of chatbotSessions.entries()) {
|
||||||
|
if (now - session.lastActivity > timeout) {
|
||||||
|
chatbotSessions.delete(chatId);
|
||||||
|
console.log(`[Chatbot] Expired session for chat ${chatId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
console.log(`[Chatbot] Initialized (enabled: ${CHATBOT_ENABLED()}, cwd: ${CHATBOT_CWD()}, maxTurns: ${CHATBOT_MAX_TURNS()})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleChatbotMessage(
|
||||||
|
chatId: string,
|
||||||
|
message: string,
|
||||||
|
sendTyping: () => Promise<void>,
|
||||||
|
sendReply: (text: string, parseMode?: string) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (activeLocks.has(chatId)) {
|
||||||
|
await sendReply('⏳ Подождите, обрабатываю предыдущее сообщение...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeLocks.add(chatId);
|
||||||
|
|
||||||
|
const typingInterval = setInterval(async () => {
|
||||||
|
try { await sendTyping(); } catch { /* ignore */ }
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
// Send "working on it" notification if Claude takes longer than 15 seconds
|
||||||
|
let longTaskNotified = false;
|
||||||
|
const longTaskTimer = setTimeout(async () => {
|
||||||
|
longTaskNotified = true;
|
||||||
|
try {
|
||||||
|
await safeSendReply(sendReply, '⏳ Задача принята, работаю... Ответ может занять несколько минут.');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendTyping();
|
||||||
|
|
||||||
|
const session = chatbotSessions.get(chatId);
|
||||||
|
const isExpired = session && (Date.now() - session.lastActivity > CHATBOT_SESSION_TIMEOUT());
|
||||||
|
const resumeId = session && !isExpired ? session.sessionId : undefined;
|
||||||
|
|
||||||
|
const response = await executeWithRetry(chatId, message, resumeId);
|
||||||
|
clearTimeout(longTaskTimer);
|
||||||
|
|
||||||
|
chatbotSessions.set(chatId, {
|
||||||
|
sessionId: response.session_id,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
messageCount: (session && !isExpired ? session.messageCount : 0) + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resultText = response.result?.trim();
|
||||||
|
if (!resultText) {
|
||||||
|
if (response.subtype === 'error_max_turns') {
|
||||||
|
resultText = `✅ Принято. Выполнил ${response.num_turns} действий, но не уложился в лимит ходов для текстового ответа. Переформулируйте вопрос или уточните, что именно нужно.`;
|
||||||
|
} else {
|
||||||
|
resultText = '✅ Принято.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chunks = splitMessage(resultText);
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await safeSendReply(sendReply, chunk);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
clearTimeout(longTaskTimer);
|
||||||
|
console.error('[Chatbot] Error:', error.message);
|
||||||
|
const isTimeout = error.message?.includes('exit code 143') || error.message?.includes('Timeout');
|
||||||
|
const hint = isTimeout ? '\n\n💡 Используйте /task для долгих задач без таймаута.' : '';
|
||||||
|
await safeSendReply(sendReply, `❌ Ошибка: ${error.message}${hint}`);
|
||||||
|
} finally {
|
||||||
|
clearInterval(typingInterval);
|
||||||
|
activeLocks.delete(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetChatbotSession(chatId: string): boolean {
|
||||||
|
return chatbotSessions.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatbotStatus(chatId: string): {
|
||||||
|
enabled: boolean;
|
||||||
|
hasSession: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
sessionAge: number | null;
|
||||||
|
} {
|
||||||
|
const session = chatbotSessions.get(chatId);
|
||||||
|
return {
|
||||||
|
enabled: CHATBOT_ENABLED(),
|
||||||
|
hasSession: !!session,
|
||||||
|
messageCount: session?.messageCount || 0,
|
||||||
|
sessionAge: session ? Math.floor((Date.now() - session.lastActivity) / 60000) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal ---
|
||||||
|
|
||||||
|
function executeClaudeCli(message: string, resumeSessionId?: string): Promise<ClaudeJsonResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args: string[] = [
|
||||||
|
'-p', message,
|
||||||
|
'--output-format', 'json',
|
||||||
|
'--max-turns', CHATBOT_MAX_TURNS(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (resumeSessionId) {
|
||||||
|
args.push('--resume', resumeSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean env: remove all Claude Code session vars
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.CLAUDECODE;
|
||||||
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
||||||
|
delete env.CLAUDE_SPAWNED;
|
||||||
|
delete env.INNERVOICE_SPAWNED;
|
||||||
|
|
||||||
|
console.log(`[Chatbot] Spawning: claude ${args.map((a, i) => i === 1 ? `"${a.substring(0, 40)}..."` : a).join(' ')}`);
|
||||||
|
|
||||||
|
const child = spawn('claude', args, {
|
||||||
|
cwd: CHATBOT_CWD(),
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout handler
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.error(`[Chatbot] Timeout after ${CHATBOT_TIMEOUT()}ms, killing process`);
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => child.kill('SIGKILL'), 5000);
|
||||||
|
}, CHATBOT_TIMEOUT());
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
console.error(`[Chatbot] Spawn error: ${error.message}`);
|
||||||
|
reject(new Error(`Failed to start claude: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
console.log(`[Chatbot] Process exited with code ${code}`);
|
||||||
|
if (stderr.trim()) {
|
||||||
|
console.error(`[Chatbot] stderr: ${stderr.substring(0, 500)}`);
|
||||||
|
}
|
||||||
|
if (stdout.trim()) {
|
||||||
|
console.log(`[Chatbot] stdout (first 300): ${stdout.substring(0, 300)}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Chatbot] stdout: (empty)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse response regardless of exit code
|
||||||
|
if (stdout.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = parseClaudeResponse(stdout);
|
||||||
|
if (parsed.is_error) {
|
||||||
|
reject(new Error(parsed.result || 'Claude returned an error'));
|
||||||
|
} else {
|
||||||
|
resolve(parsed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (parseErr: any) {
|
||||||
|
console.error(`[Chatbot] Parse error: ${parseErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
|
||||||
|
reject(new Error(`Claude CLI failed: ${errDetail.substring(0, 200)}`));
|
||||||
|
} else {
|
||||||
|
reject(new Error('Empty response from Claude CLI'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeWithRetry(
|
||||||
|
chatId: string,
|
||||||
|
message: string,
|
||||||
|
resumeId?: string
|
||||||
|
): Promise<ClaudeJsonResponse> {
|
||||||
|
try {
|
||||||
|
return await executeClaudeCli(message, resumeId);
|
||||||
|
} catch (error) {
|
||||||
|
if (resumeId) {
|
||||||
|
console.log(`[Chatbot] Resume failed for ${chatId}, retrying fresh`);
|
||||||
|
chatbotSessions.delete(chatId);
|
||||||
|
return await executeClaudeCli(message, undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseClaudeResponse(stdout: string): ClaudeJsonResponse {
|
||||||
|
const trimmed = stdout.trim();
|
||||||
|
|
||||||
|
// Try the whole output as a single JSON
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch { /* continue */ }
|
||||||
|
|
||||||
|
// Try each line (may have streaming JSON lines)
|
||||||
|
const lines = trimmed.split('\n');
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
if (parsed.type === 'result') return parsed;
|
||||||
|
} catch { continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: find result JSON anywhere in output
|
||||||
|
const match = trimmed.match(/\{[^]*?"type"\s*:\s*"result"[^]*?\}/);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[0]);
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No valid JSON result in output (${trimmed.substring(0, 150)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitMessage(text: string, maxLength: number = 4000): string[] {
|
||||||
|
if (text.length <= maxLength) return [text];
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
if (remaining.length <= maxLength) {
|
||||||
|
chunks.push(remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let splitIdx = remaining.lastIndexOf('\n\n', maxLength);
|
||||||
|
if (splitIdx === -1 || splitIdx < maxLength * 0.3) {
|
||||||
|
splitIdx = remaining.lastIndexOf('\n', maxLength);
|
||||||
|
}
|
||||||
|
if (splitIdx === -1 || splitIdx < maxLength * 0.3) {
|
||||||
|
splitIdx = remaining.lastIndexOf(' ', maxLength);
|
||||||
|
}
|
||||||
|
if (splitIdx === -1 || splitIdx < maxLength * 0.3) {
|
||||||
|
splitIdx = maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(remaining.substring(0, splitIdx));
|
||||||
|
remaining = remaining.substring(splitIdx).trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeSendReply(
|
||||||
|
sendReply: (text: string, parseMode?: string) => Promise<void>,
|
||||||
|
text: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendReply(text, 'Markdown');
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await sendReply(text);
|
||||||
|
} catch (innerError) {
|
||||||
|
console.error('[Chatbot] Failed to send reply:', innerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
src/claude-spawner.ts
Normal file
216
src/claude-spawner.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// Claude Spawner for InnerVoice
|
||||||
|
// Spawns Claude Code instances remotely from Telegram
|
||||||
|
|
||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import { findProject, touchProject } from './project-registry.js';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// Load .env file from a directory and return as object
|
||||||
|
function loadEnvFile(dirPath: string): Record<string, string> {
|
||||||
|
const envPath = join(dirPath, '.env');
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(envPath, 'utf-8');
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
|
||||||
|
const eqIndex = trimmed.indexOf('=');
|
||||||
|
if (eqIndex > 0) {
|
||||||
|
const key = trimmed.substring(0, eqIndex).trim();
|
||||||
|
let value = trimmed.substring(eqIndex + 1).trim();
|
||||||
|
// Remove quotes if present
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
envVars[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[SPAWN] Loaded ${Object.keys(envVars).length} env vars from ${envPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SPAWN] Failed to load .env from ${envPath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return envVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpawnedProcess {
|
||||||
|
projectName: string;
|
||||||
|
process: ChildProcess;
|
||||||
|
startTime: Date;
|
||||||
|
initialPrompt?: string;
|
||||||
|
onOutput?: (data: string, isError: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProcesses = new Map<string, SpawnedProcess>();
|
||||||
|
|
||||||
|
// Spawn Claude in a project
|
||||||
|
export async function spawnClaude(
|
||||||
|
projectName: string,
|
||||||
|
initialPrompt?: string,
|
||||||
|
onOutput?: (data: string, isError: boolean) => void
|
||||||
|
): Promise<{ success: boolean; message: string; pid?: number }> {
|
||||||
|
// Check if already running
|
||||||
|
if (activeProcesses.has(projectName)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Claude is already running in ${projectName}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find project in registry
|
||||||
|
const project = await findProject(projectName);
|
||||||
|
if (!project) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Project "${projectName}" not found in registry. Register it first with: /register ProjectName /path/to/project`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load project's .env file to pass to Claude
|
||||||
|
const projectEnv = loadEnvFile(project.path);
|
||||||
|
|
||||||
|
// Spawn Claude Code
|
||||||
|
// Use -p flag to pass prompt, otherwise Claude expects stdin
|
||||||
|
const args = initialPrompt ? ['-p', initialPrompt] : [];
|
||||||
|
const claudeProcess = spawn('claude', args, {
|
||||||
|
cwd: project.path,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...projectEnv, // Include project's .env variables
|
||||||
|
INNERVOICE_SPAWNED: '1' // Mark as spawned by InnerVoice
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store process
|
||||||
|
activeProcesses.set(projectName, {
|
||||||
|
projectName,
|
||||||
|
process: claudeProcess,
|
||||||
|
startTime: new Date(),
|
||||||
|
initialPrompt,
|
||||||
|
onOutput
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update last accessed
|
||||||
|
await touchProject(projectName);
|
||||||
|
|
||||||
|
// Handle output - log and optionally send to callback
|
||||||
|
if (claudeProcess.stdout) {
|
||||||
|
claudeProcess.stdout.on('data', (data) => {
|
||||||
|
const output = data.toString().trim();
|
||||||
|
console.log(`[${projectName}] ${output}`);
|
||||||
|
|
||||||
|
// Send to callback if provided
|
||||||
|
if (onOutput) {
|
||||||
|
console.log(`[DEBUG] Invoking onOutput callback for stdout in ${projectName}`);
|
||||||
|
try {
|
||||||
|
onOutput(output, false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ERROR] onOutput callback failed for ${projectName}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[WARN] No onOutput callback provided for ${projectName}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claudeProcess.stderr) {
|
||||||
|
claudeProcess.stderr.on('data', (data) => {
|
||||||
|
const output = data.toString().trim();
|
||||||
|
console.error(`[${projectName}] ${output}`);
|
||||||
|
|
||||||
|
// Send errors to callback if provided
|
||||||
|
if (onOutput) {
|
||||||
|
console.log(`[DEBUG] Invoking onOutput callback for stderr in ${projectName}`);
|
||||||
|
try {
|
||||||
|
onOutput(output, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ERROR] onOutput callback failed for ${projectName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exit
|
||||||
|
claudeProcess.on('exit', (code) => {
|
||||||
|
console.log(`🛑 Claude exited in ${projectName} (code: ${code})`);
|
||||||
|
activeProcesses.delete(projectName);
|
||||||
|
});
|
||||||
|
|
||||||
|
claudeProcess.on('error', (error) => {
|
||||||
|
console.error(`❌ Error spawning Claude in ${projectName}:`, error);
|
||||||
|
activeProcesses.delete(projectName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unref so it doesn't keep Node running
|
||||||
|
claudeProcess.unref();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `✅ Claude started in ${projectName}${initialPrompt ? ` with prompt: "${initialPrompt}"` : ''}`,
|
||||||
|
pid: claudeProcess.pid
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to spawn Claude: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill a spawned Claude process
|
||||||
|
export function killClaude(projectName: string): { success: boolean; message: string } {
|
||||||
|
const spawned = activeProcesses.get(projectName);
|
||||||
|
|
||||||
|
if (!spawned) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `No active Claude process found for ${projectName}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
spawned.process.kill('SIGTERM');
|
||||||
|
activeProcesses.delete(projectName);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `✅ Claude process terminated in ${projectName}`
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to kill process: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all spawned processes
|
||||||
|
export function listSpawnedProcesses(): Array<{
|
||||||
|
projectName: string;
|
||||||
|
pid?: number;
|
||||||
|
startTime: Date;
|
||||||
|
initialPrompt?: string;
|
||||||
|
runningMinutes: number;
|
||||||
|
}> {
|
||||||
|
return Array.from(activeProcesses.values()).map(sp => ({
|
||||||
|
projectName: sp.projectName,
|
||||||
|
pid: sp.process.pid,
|
||||||
|
startTime: sp.startTime,
|
||||||
|
initialPrompt: sp.initialPrompt,
|
||||||
|
runningMinutes: Math.floor((Date.now() - sp.startTime.getTime()) / 60000)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Claude is running in a project
|
||||||
|
export function isClaudeRunning(projectName: string): boolean {
|
||||||
|
return activeProcesses.has(projectName);
|
||||||
|
}
|
||||||
1077
src/index.ts
1077
src/index.ts
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,50 @@ const BRIDGE_HOST = new URL(BRIDGE_URL).hostname || 'localhost';
|
|||||||
|
|
||||||
let bridgeProcess: ChildProcess | null = null;
|
let bridgeProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
let currentSessionId: string | null = null;
|
||||||
|
let currentProjectName: string | null = null;
|
||||||
|
let currentProjectPath: string | null = null;
|
||||||
|
|
||||||
|
// Get or create session ID
|
||||||
|
function getSessionId(): string {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
currentSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
return currentSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project name from current working directory
|
||||||
|
function getProjectInfo(): { name: string; path: string } {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const name = cwd.split('/').pop() || 'Unknown';
|
||||||
|
return { name, path: cwd };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register this session with the bridge
|
||||||
|
async function registerSession(): Promise<void> {
|
||||||
|
const sessionId = getSessionId();
|
||||||
|
const { name, path } = getProjectInfo();
|
||||||
|
|
||||||
|
currentProjectName = name;
|
||||||
|
currentProjectPath = path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${BRIDGE_URL}/session/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
projectName: name,
|
||||||
|
projectPath: path
|
||||||
|
})
|
||||||
|
});
|
||||||
|
console.error(`✅ Session registered: ${name} [${sessionId.substring(0, 7)}]`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ Failed to register session:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the bridge is running
|
// Check if the bridge is running
|
||||||
async function isBridgeRunning(): Promise<boolean> {
|
async function isBridgeRunning(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -174,6 +218,14 @@ const TOOLS: Tool[] = [
|
|||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'telegram_check_queue',
|
||||||
|
description: 'Check if there are any queued messages for this project. Use this on startup to see if the user sent any messages while Claude was offline.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create the MCP server
|
// Create the MCP server
|
||||||
@@ -208,7 +260,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
const response = await fetch(`${BRIDGE_URL}/notify`, {
|
const response = await fetch(`${BRIDGE_URL}/notify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, priority }),
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
priority,
|
||||||
|
sessionId: getSessionId()
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -352,6 +408,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'telegram_check_queue': {
|
||||||
|
const { name: projectName } = getProjectInfo();
|
||||||
|
|
||||||
|
const response = await fetch(`${BRIDGE_URL}/queue/${encodeURIComponent(projectName)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to check queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = await response.json();
|
||||||
|
const tasks = result.tasks || [];
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '📭 No queued messages for this project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskList = tasks.map((t: any, i: number) => {
|
||||||
|
const timestamp = new Date(t.timestamp).toLocaleString();
|
||||||
|
return `${i + 1}. From *${t.from}* (${timestamp})\n ${t.message}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `📬 You have ${tasks.length} queued message(s):\n\n${taskList}\n\n_These messages were sent while you were offline._`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
}
|
}
|
||||||
@@ -395,6 +489,9 @@ async function main() {
|
|||||||
// Ensure the bridge is running before starting the MCP server
|
// Ensure the bridge is running before starting the MCP server
|
||||||
await ensureBridge();
|
await ensureBridge();
|
||||||
|
|
||||||
|
// Register this Claude session
|
||||||
|
await registerSession();
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
console.error('🚀 Telegram MCP server running on stdio');
|
console.error('🚀 Telegram MCP server running on stdio');
|
||||||
|
|||||||
148
src/project-registry.ts
Normal file
148
src/project-registry.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// Project Registry for InnerVoice
|
||||||
|
// Manages known projects and their locations for remote spawning
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
const REGISTRY_PATH = path.join(process.env.HOME || '~', '.innervoice', 'projects.json');
|
||||||
|
|
||||||
|
export interface RegisteredProject {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
lastAccessed: Date;
|
||||||
|
autoSpawn: boolean; // Auto-spawn when message received
|
||||||
|
metadata?: {
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure registry file exists
|
||||||
|
async function ensureRegistry(): Promise<void> {
|
||||||
|
const dir = path.dirname(REGISTRY_PATH);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(REGISTRY_PATH)) {
|
||||||
|
await fs.writeFile(REGISTRY_PATH, JSON.stringify([], null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all registered projects
|
||||||
|
export async function loadProjects(): Promise<RegisteredProject[]> {
|
||||||
|
await ensureRegistry();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(REGISTRY_PATH, 'utf-8');
|
||||||
|
const projects = JSON.parse(content);
|
||||||
|
// Convert date strings back to Date objects
|
||||||
|
return projects.map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
lastAccessed: new Date(p.lastAccessed)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading project registry:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save projects to registry
|
||||||
|
export async function saveProjects(projects: RegisteredProject[]): Promise<void> {
|
||||||
|
await ensureRegistry();
|
||||||
|
await fs.writeFile(REGISTRY_PATH, JSON.stringify(projects, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a new project
|
||||||
|
export async function registerProject(
|
||||||
|
name: string,
|
||||||
|
projectPath: string,
|
||||||
|
options?: { autoSpawn?: boolean; description?: string; tags?: string[] }
|
||||||
|
): Promise<RegisteredProject> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
const existing = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
existing.path = projectPath;
|
||||||
|
existing.lastAccessed = new Date();
|
||||||
|
if (options?.autoSpawn !== undefined) existing.autoSpawn = options.autoSpawn;
|
||||||
|
if (options?.description) {
|
||||||
|
existing.metadata = existing.metadata || {};
|
||||||
|
existing.metadata.description = options.description;
|
||||||
|
}
|
||||||
|
if (options?.tags) {
|
||||||
|
existing.metadata = existing.metadata || {};
|
||||||
|
existing.metadata.tags = options.tags;
|
||||||
|
}
|
||||||
|
await saveProjects(projects);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new
|
||||||
|
const newProject: RegisteredProject = {
|
||||||
|
name,
|
||||||
|
path: projectPath,
|
||||||
|
lastAccessed: new Date(),
|
||||||
|
autoSpawn: options?.autoSpawn ?? false,
|
||||||
|
metadata: {
|
||||||
|
description: options?.description,
|
||||||
|
tags: options?.tags
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
projects.push(newProject);
|
||||||
|
await saveProjects(projects);
|
||||||
|
|
||||||
|
console.log(`📝 Registered project: ${name} at ${projectPath}`);
|
||||||
|
return newProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister a project
|
||||||
|
export async function unregisterProject(name: string): Promise<boolean> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
const filtered = projects.filter(p => p.name.toLowerCase() !== name.toLowerCase());
|
||||||
|
|
||||||
|
if (filtered.length === projects.length) {
|
||||||
|
return false; // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveProjects(filtered);
|
||||||
|
console.log(`🗑️ Unregistered project: ${name}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a project by name
|
||||||
|
export async function findProject(name: string): Promise<RegisteredProject | null> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
return projects.find(p => p.name.toLowerCase() === name.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last accessed time
|
||||||
|
export async function touchProject(name: string): Promise<void> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
const project = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
project.lastAccessed = new Date();
|
||||||
|
await saveProjects(projects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get projects eligible for auto-spawn
|
||||||
|
export async function getAutoSpawnProjects(): Promise<RegisteredProject[]> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
return projects.filter(p => p.autoSpawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a project path exists
|
||||||
|
export async function validateProjectPath(projectPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(projectPath);
|
||||||
|
return stats.isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/queue-manager.ts
Normal file
145
src/queue-manager.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Message Queue Manager for InnerVoice
|
||||||
|
// Stores messages for offline/inactive projects
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
const QUEUE_DIR = path.join(process.env.HOME || '~', '.innervoice', 'queues');
|
||||||
|
|
||||||
|
export interface QueuedTask {
|
||||||
|
id: string;
|
||||||
|
projectName: string;
|
||||||
|
projectPath: string;
|
||||||
|
message: string;
|
||||||
|
from: string;
|
||||||
|
timestamp: Date;
|
||||||
|
priority: 'low' | 'normal' | 'high';
|
||||||
|
status: 'pending' | 'delivered' | 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure queue directory exists
|
||||||
|
async function ensureQueueDir(): Promise<void> {
|
||||||
|
if (!existsSync(QUEUE_DIR)) {
|
||||||
|
await fs.mkdir(QUEUE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue file path for a project
|
||||||
|
function getQueuePath(projectName: string): string {
|
||||||
|
const safeName = projectName.replace(/[^a-z0-9-_]/gi, '_').toLowerCase();
|
||||||
|
return path.join(QUEUE_DIR, `${safeName}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load queue for a project
|
||||||
|
export async function loadQueue(projectName: string): Promise<QueuedTask[]> {
|
||||||
|
await ensureQueueDir();
|
||||||
|
const queuePath = getQueuePath(projectName);
|
||||||
|
|
||||||
|
if (!existsSync(queuePath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(queuePath, 'utf-8');
|
||||||
|
const tasks = JSON.parse(content);
|
||||||
|
// Convert timestamp strings back to Date objects
|
||||||
|
return tasks.map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
timestamp: new Date(t.timestamp)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading queue for ${projectName}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save queue for a project
|
||||||
|
export async function saveQueue(projectName: string, tasks: QueuedTask[]): Promise<void> {
|
||||||
|
await ensureQueueDir();
|
||||||
|
const queuePath = getQueuePath(projectName);
|
||||||
|
await fs.writeFile(queuePath, JSON.stringify(tasks, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a task to the queue
|
||||||
|
export async function enqueueTask(task: Omit<QueuedTask, 'id' | 'status'>): Promise<QueuedTask> {
|
||||||
|
const queue = await loadQueue(task.projectName);
|
||||||
|
|
||||||
|
const newTask: QueuedTask = {
|
||||||
|
...task,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
status: 'pending'
|
||||||
|
};
|
||||||
|
|
||||||
|
queue.push(newTask);
|
||||||
|
await saveQueue(task.projectName, queue);
|
||||||
|
|
||||||
|
console.log(`📥 Queued task for ${task.projectName}: ${task.message.substring(0, 50)}...`);
|
||||||
|
return newTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending tasks for a project
|
||||||
|
export async function getPendingTasks(projectName: string): Promise<QueuedTask[]> {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
return queue.filter(t => t.status === 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a task as delivered
|
||||||
|
export async function markTaskDelivered(projectName: string, taskId: string): Promise<void> {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
const task = queue.find(t => t.id === taskId);
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
task.status = 'delivered';
|
||||||
|
await saveQueue(projectName, queue);
|
||||||
|
console.log(`✅ Task delivered: ${taskId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear delivered tasks older than N days
|
||||||
|
export async function cleanupOldTasks(projectName: string, daysOld: number = 7): Promise<number> {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - daysOld);
|
||||||
|
|
||||||
|
const filtered = queue.filter(t => {
|
||||||
|
if (t.status === 'delivered' && t.timestamp < cutoff) {
|
||||||
|
return false; // Remove old delivered tasks
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = queue.length - filtered.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
await saveQueue(projectName, filtered);
|
||||||
|
console.log(`🧹 Cleaned up ${removed} old tasks for ${projectName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all projects with queued tasks
|
||||||
|
export async function listProjectsWithQueues(): Promise<string[]> {
|
||||||
|
await ensureQueueDir();
|
||||||
|
const files = await fs.readdir(QUEUE_DIR);
|
||||||
|
return files
|
||||||
|
.filter(f => f.endsWith('.json'))
|
||||||
|
.map(f => f.replace('.json', '').replace(/_/g, '-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue summary for all projects
|
||||||
|
export async function getQueueSummary(): Promise<{ projectName: string; pending: number; total: number }[]> {
|
||||||
|
const projects = await listProjectsWithQueues();
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
projects.map(async (projectName) => {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
return {
|
||||||
|
projectName,
|
||||||
|
pending: queue.filter(t => t.status === 'pending').length,
|
||||||
|
total: queue.length
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return summaries.filter(s => s.total > 0);
|
||||||
|
}
|
||||||
347
src/task-manager.ts
Normal file
347
src/task-manager.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { parseClaudeResponse, splitMessage } from './chatbot.js';
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: number;
|
||||||
|
chatId: string;
|
||||||
|
message: string;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
createdAt: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
numTurns?: number;
|
||||||
|
costUsd?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskStore {
|
||||||
|
nextId: number;
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
|
||||||
|
const TASK_MAX_TURNS = () => process.env.TASK_MAX_TURNS || '50';
|
||||||
|
const TASK_CWD = () => process.env.TASK_CWD || process.env.CHATBOT_CWD || '/home/fitcrm';
|
||||||
|
const TASKS_FILE = path.join(process.env.HOME || '~', '.innervoice', 'tasks.json');
|
||||||
|
const MAX_STORED_TASKS = 50;
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
|
||||||
|
let workerTimer: NodeJS.Timeout | null = null;
|
||||||
|
let currentProcess: ChildProcess | null = null;
|
||||||
|
let sendNotification: ((chatId: string, text: string) => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
// --- Persistence ---
|
||||||
|
|
||||||
|
async function ensureDir(): Promise<void> {
|
||||||
|
const dir = path.dirname(TASKS_FILE);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStore(): Promise<TaskStore> {
|
||||||
|
await ensureDir();
|
||||||
|
if (!existsSync(TASKS_FILE)) {
|
||||||
|
return { nextId: 1, tasks: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(TASKS_FILE, 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return { nextId: 1, tasks: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStore(store: TaskStore): Promise<void> {
|
||||||
|
await ensureDir();
|
||||||
|
// Keep only last MAX_STORED_TASKS
|
||||||
|
if (store.tasks.length > MAX_STORED_TASKS) {
|
||||||
|
const active = store.tasks.filter(t => t.status === 'pending' || t.status === 'running');
|
||||||
|
const finished = store.tasks
|
||||||
|
.filter(t => t.status !== 'pending' && t.status !== 'running')
|
||||||
|
.slice(-MAX_STORED_TASKS);
|
||||||
|
store.tasks = [...active, ...finished].slice(-MAX_STORED_TASKS);
|
||||||
|
}
|
||||||
|
await fs.writeFile(TASKS_FILE, JSON.stringify(store, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public API ---
|
||||||
|
|
||||||
|
export function initTaskManager(
|
||||||
|
notifyFn: (chatId: string, text: string) => Promise<void>
|
||||||
|
): void {
|
||||||
|
sendNotification = notifyFn;
|
||||||
|
|
||||||
|
// Recovery: mark stuck 'running' tasks as failed
|
||||||
|
loadStore().then(async (store) => {
|
||||||
|
let changed = false;
|
||||||
|
for (const task of store.tasks) {
|
||||||
|
if (task.status === 'running') {
|
||||||
|
task.status = 'failed';
|
||||||
|
task.error = 'Process restarted';
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
changed = true;
|
||||||
|
console.log(`[TaskManager] Recovered stuck task #${task.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) await saveStore(store);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start worker loop
|
||||||
|
if (workerTimer) clearInterval(workerTimer);
|
||||||
|
workerTimer = setInterval(workerLoop, 5000);
|
||||||
|
|
||||||
|
console.log(`[TaskManager] Initialized (maxTurns: ${TASK_MAX_TURNS()}, cwd: ${TASK_CWD()})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTask(chatId: string, message: string): Promise<Task> {
|
||||||
|
const store = await loadStore();
|
||||||
|
const task: Task = {
|
||||||
|
id: store.nextId++,
|
||||||
|
chatId,
|
||||||
|
message,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
store.tasks.push(task);
|
||||||
|
await saveStore(store);
|
||||||
|
console.log(`[TaskManager] Created task #${task.id}: "${message.substring(0, 50)}..."`);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTask(id: number): Promise<Task | undefined> {
|
||||||
|
const store = await loadStore();
|
||||||
|
return store.tasks.find(t => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTasks(): Promise<Task[]> {
|
||||||
|
const store = await loadStore();
|
||||||
|
return store.tasks.slice(-10).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelTask(id: number): Promise<boolean> {
|
||||||
|
const store = await loadStore();
|
||||||
|
const task = store.tasks.find(t => t.id === id);
|
||||||
|
if (!task) return false;
|
||||||
|
|
||||||
|
if (task.status === 'pending') {
|
||||||
|
task.status = 'cancelled';
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
await saveStore(store);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.status === 'running' && currentProcess) {
|
||||||
|
currentProcess.kill('SIGTERM');
|
||||||
|
setTimeout(() => { currentProcess?.kill('SIGKILL'); }, 5000);
|
||||||
|
task.status = 'cancelled';
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
await saveStore(store);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTaskManager(): void {
|
||||||
|
if (workerTimer) {
|
||||||
|
clearInterval(workerTimer);
|
||||||
|
workerTimer = null;
|
||||||
|
}
|
||||||
|
if (currentProcess) {
|
||||||
|
currentProcess.kill('SIGTERM');
|
||||||
|
currentProcess = null;
|
||||||
|
}
|
||||||
|
console.log('[TaskManager] Stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Worker ---
|
||||||
|
|
||||||
|
let workerBusy = false;
|
||||||
|
|
||||||
|
async function workerLoop(): Promise<void> {
|
||||||
|
if (workerBusy) return;
|
||||||
|
|
||||||
|
const store = await loadStore();
|
||||||
|
const pending = store.tasks.find(t => t.status === 'pending');
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
workerBusy = true;
|
||||||
|
console.log(`[TaskManager] Starting task #${pending.id}: "${pending.message.substring(0, 50)}..."`);
|
||||||
|
|
||||||
|
// Mark as running
|
||||||
|
pending.status = 'running';
|
||||||
|
pending.startedAt = new Date().toISOString();
|
||||||
|
await saveStore(store);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await executeTaskCli(pending.message);
|
||||||
|
|
||||||
|
// Update task
|
||||||
|
const freshStore = await loadStore();
|
||||||
|
const task = freshStore.tasks.find(t => t.id === pending.id);
|
||||||
|
if (task) {
|
||||||
|
task.status = 'completed';
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
task.sessionId = response.session_id;
|
||||||
|
task.numTurns = response.num_turns;
|
||||||
|
task.costUsd = response.cost_usd;
|
||||||
|
task.durationMs = response.duration_ms;
|
||||||
|
|
||||||
|
let resultText = response.result?.trim();
|
||||||
|
if (!resultText) {
|
||||||
|
if (response.subtype === 'error_max_turns') {
|
||||||
|
resultText = `Выполнил ${response.num_turns} действий, но не уложился в лимит ходов.`;
|
||||||
|
} else {
|
||||||
|
resultText = 'Задача выполнена (без текстового ответа).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.result = resultText;
|
||||||
|
await saveStore(freshStore);
|
||||||
|
|
||||||
|
// Notify
|
||||||
|
const duration = formatDuration(task.durationMs || 0);
|
||||||
|
const header = `*Задача #${task.id} завершена* (${duration})`;
|
||||||
|
const body = resultText.length > 3500 ? resultText.substring(0, 3500) + '...' : resultText;
|
||||||
|
await notify(task.chatId, `${header}\n\n${body}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const freshStore = await loadStore();
|
||||||
|
const task = freshStore.tasks.find(t => t.id === pending.id);
|
||||||
|
if (task && task.status === 'running') {
|
||||||
|
task.status = 'failed';
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
task.error = error.message;
|
||||||
|
await saveStore(freshStore);
|
||||||
|
|
||||||
|
await notify(task.chatId, `*Задача #${task.id} провалилась*\n\n${error.message}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
workerBusy = false;
|
||||||
|
currentProcess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI Execution ---
|
||||||
|
|
||||||
|
function executeTaskCli(message: string): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args: string[] = [
|
||||||
|
'-p', message,
|
||||||
|
'--output-format', 'json',
|
||||||
|
'--max-turns', TASK_MAX_TURNS(),
|
||||||
|
];
|
||||||
|
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.CLAUDECODE;
|
||||||
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
||||||
|
delete env.CLAUDE_SPAWNED;
|
||||||
|
delete env.INNERVOICE_SPAWNED;
|
||||||
|
|
||||||
|
console.log(`[TaskManager] Spawning: claude ${args.map((a, i) => i === 1 ? `"${a.substring(0, 40)}..."` : a).join(' ')}`);
|
||||||
|
|
||||||
|
const child = spawn('claude', args, {
|
||||||
|
cwd: TASK_CWD(),
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
currentProcess = child;
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// No timeout — tasks run until completion
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(`[TaskManager] Spawn error: ${error.message}`);
|
||||||
|
reject(new Error(`Failed to start claude: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
currentProcess = null;
|
||||||
|
console.log(`[TaskManager] Process exited with code ${code}`);
|
||||||
|
|
||||||
|
if (stdout.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = parseClaudeResponse(stdout);
|
||||||
|
if (parsed.is_error) {
|
||||||
|
reject(new Error(parsed.result || 'Claude returned an error'));
|
||||||
|
} else {
|
||||||
|
resolve(parsed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (parseErr: any) {
|
||||||
|
console.error(`[TaskManager] Parse error: ${parseErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const errDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
|
||||||
|
reject(new Error(`Claude CLI failed: ${errDetail.substring(0, 200)}`));
|
||||||
|
} else {
|
||||||
|
reject(new Error('Empty response from Claude CLI'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const sec = Math.floor(ms / 1000);
|
||||||
|
if (sec < 60) return `${sec} сек`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const remainSec = sec % 60;
|
||||||
|
return `${min} мин ${remainSec} сек`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notify(chatId: string, text: string): Promise<void> {
|
||||||
|
if (!sendNotification) return;
|
||||||
|
try {
|
||||||
|
await sendNotification(chatId, text);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TaskManager] Notification failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskStatusEmoji(status: Task['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return '⏳';
|
||||||
|
case 'running': return '🔄';
|
||||||
|
case 'completed': return '✅';
|
||||||
|
case 'failed': return '❌';
|
||||||
|
case 'cancelled': return '🚫';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTaskShort(task: Task): string {
|
||||||
|
const emoji = getTaskStatusEmoji(task.status);
|
||||||
|
const msg = task.message.length > 40 ? task.message.substring(0, 40) + '...' : task.message;
|
||||||
|
let line = `${emoji} #${task.id} ${msg}`;
|
||||||
|
if (task.status === 'completed' && task.durationMs) {
|
||||||
|
line += ` (${formatDuration(task.durationMs)})`;
|
||||||
|
}
|
||||||
|
if (task.status === 'failed' && task.error) {
|
||||||
|
line += ` — ${task.error.substring(0, 50)}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user