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/
|
||||
|
||||
# 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
|
||||
|
||||
### Core Communication
|
||||
- 💬 **Two-Way Communication** - Send messages to Claude, get responses back
|
||||
- ❓ **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
|
||||
- 🌐 **HTTP API** - Easy integration from any app/project
|
||||
- 🚀 **Background Service** - Runs independently, always available
|
||||
- 🔧 **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
|
||||
|
||||
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
|
||||
|
||||
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_get_messages` - Check for messages from you
|
||||
- `telegram_reply` - Reply to your messages
|
||||
- `telegram_check_health` - Check bridge status
|
||||
- `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications)
|
||||
- `telegram_check_queue` - Check for queued messages on startup
|
||||
|
||||
**View detailed tool info:**
|
||||
```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:
|
||||
|
||||
**Recommended: Install Globally (works in all projects)**
|
||||
```bash
|
||||
# From the innervoice directory
|
||||
cd /path/to/innervoice
|
||||
./scripts/install-hook.sh --global
|
||||
```
|
||||
|
||||
**Or install per-project:**
|
||||
```bash
|
||||
# Install in a specific 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
|
||||
> 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
|
||||
|
||||
@@ -294,6 +320,206 @@ Restart Claude Code, then tell Claude:
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
- 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)
|
||||
|
||||
If you want to push this to your own Git repository:
|
||||
@@ -436,32 +681,37 @@ await fetch('http://localhost:3456/notify', {
|
||||
- `error` - ❌ Error occurred
|
||||
- `question` - ❓ Needs your input
|
||||
|
||||
## Bot Commands
|
||||
## How Communication Works
|
||||
|
||||
Type these in Telegram to control the bridge:
|
||||
|
||||
- `/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
|
||||
### Basic Message Flow
|
||||
1. Send any message to the bot in Telegram
|
||||
2. Bot acknowledges with "💬 Message received - responding..."
|
||||
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)
|
||||
Claude sends you updates via the `/notify` API endpoint with different priorities
|
||||
### Targeted Messages
|
||||
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)
|
||||
1. Claude sends a question via `/ask` API
|
||||
### Notifications
|
||||
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
|
||||
3. Your next message is automatically treated as the answer
|
||||
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
|
||||
|
||||
```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
|
||||
|
||||
Add this helper to your ESO-MCP project:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Install the InnerVoice permission notification hook in a project
|
||||
# Install the InnerVoice permission notification hook globally or per-project
|
||||
|
||||
set -e
|
||||
|
||||
@@ -7,12 +7,22 @@ HOOK_NAME="PermissionRequest.sh"
|
||||
INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME"
|
||||
|
||||
# Get target project directory (default to current directory)
|
||||
TARGET_DIR="${1:-.}"
|
||||
TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks"
|
||||
# Check for --global flag
|
||||
if [ "$1" = "--global" ] || [ "$1" = "-g" ]; then
|
||||
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 ""
|
||||
echo "Scope: $SCOPE"
|
||||
echo "Source: $SOURCE_HOOK"
|
||||
echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME"
|
||||
echo ""
|
||||
@@ -35,4 +45,4 @@ echo ""
|
||||
echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications"
|
||||
echo " whenever Claude requests permission for a tool."
|
||||
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);
|
||||
}
|
||||
1081
src/index.ts
1081
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;
|
||||
|
||||
// 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
|
||||
async function isBridgeRunning(): Promise<boolean> {
|
||||
try {
|
||||
@@ -174,6 +218,14 @@ const TOOLS: Tool[] = [
|
||||
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
|
||||
@@ -208,7 +260,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const response = await fetch(`${BRIDGE_URL}/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, priority }),
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
priority,
|
||||
sessionId: getSessionId()
|
||||
}),
|
||||
});
|
||||
|
||||
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:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
@@ -395,6 +489,9 @@ async function main() {
|
||||
// Ensure the bridge is running before starting the MCP server
|
||||
await ensureBridge();
|
||||
|
||||
// Register this Claude session
|
||||
await registerSession();
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
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