Compare commits

...

15 Commits

Author SHA1 Message Date
root
5cd67c06c6 chore: добавить innervoice/ в .gitignore (старая копия проекта)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:25:45 +00:00
root
df1bbdcc8b feat: контекст чата — бот помнит последние N сообщений
Бот подхватывает последние 20 (настраивается) сообщений из чата
как контекст при ответе. Команда /context для просмотра и настройки.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:11:26 +00:00
root
b3fdd383ac feat: task manager — фоновые задачи без таймаута через /task
Новый модуль task-manager.ts: очередь задач с файловым хранением,
worker loop каждые 5 сек, Claude CLI с --max-turns 50 без таймаута,
Telegram-уведомления по завершении. Команды /task, /task list,
/task N, /task cancel N. HTTP API /tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:03:32 +00:00
root
18fcc06626 fix: осмысленный fallback вместо «пустой ответ», chatbot только по @mention/reply
- Пустой result при error_max_turns → «Принято. Выполнил N действий...»
- Пустой result в остальных случаях → «Принято.»
- Chatbot отвечает только при @упоминании или reply на сообщение бота

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:24:22 +00:00
root
d589e3128d feat: chatbot отвечает только при @упоминании или reply на его сообщение
Бот больше не реагирует на все сообщения в чате.
Отвечает только когда:
- упомянут через @username в тексте
- сообщение является ответом (reply) на сообщение бота

@mention автоматически вырезается из текста перед отправкой в Claude.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:55:29 +00:00
root
dc92c7fcf6 feat: chatbot mode — Claude CLI отвечает на сообщения в Telegram
Добавлен режим чат-бота: каждое текстовое сообщение в Telegram
обрабатывается через `claude -p` CLI с контекстом проекта (CLAUDE.md).
Поддержка продолжения диалога через --resume session_id.

Новое:
- src/chatbot.ts: модуль чат-бота (spawn, сессии, retry, split)
- Команды /chatbot (статус) и /chatreset (сброс диалога)
- Конфиг через CHATBOT_* переменные в .env
- Typing-индикатор, блокировка конкурентных запросов
- Безопасная отправка Markdown с fallback на plain text

Изменения в index.ts:
- Интеграция chatbot в bot.on('text') fallback
- handlerTimeout увеличен до 5 мин
- Очистка stale-сессий при отсутствии запущенного Claude

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:43:53 +00:00
RichardDillman
e26d94dcc2 Fix: Multi-line messages now captured in project routing
Added 's' flag (dotall) to regex so . matches newlines.
Previously only first line of multi-line messages was captured.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 16:31:22 -05:00
RichardDillman
93d65d9a34 Fix: Add -p flag when spawning Claude with a prompt
Without -p flag, Claude expects input from stdin and fails with:
"Input must be provided either through stdin or as a prompt argument"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 16:21:36 -05:00
RichardDillman
0ce65d7120 fix: load project .env vars and cleanup stale sessions
- Load project's .env file when spawning Claude so API credentials work
- Check isClaudeRunning() before routing messages to sessions
- Auto-cleanup stale sessions when Claude has exited
- Fixes ESO Logs API access from spawned Claude instances

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:22:46 -05:00
RichardDillman
0d277e4ae2 fix: add comprehensive debugging for Claude spawner Telegram output
Added detailed logging throughout the Claude spawning and output callback chain to diagnose why spawned Claude process output is logged but not sent to Telegram.

Changes:
- claude-spawner.ts: Added debug logs when onOutput callback is invoked, with try-catch error handling
- claude-spawner.ts: Added warning when no callback is provided
- index.ts: Added debug logs in all three spawn locations (/spawn command, auto-spawn, HTTP /spawn endpoint)
- index.ts: Added logging to show chatId availability and callback creation
- index.ts: Added detailed logging inside callbacks to trace Telegram sendMessage calls

This will help identify if the issue is:
1. Callback not being passed to spawner
2. Callback not being invoked by spawner
3. chatId not being set
4. Telegram API call failing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 17:22:28 -05:00
RichardDillman
abde877912 docs: comprehensive documentation for all three phases
Added complete documentation covering all InnerVoice features:

Feature Documentation:
- Core communication features
- Multi-project support and session tracking
- Message queue system for offline projects
- Remote Claude spawner and project registry

Usage Scenarios (6 detailed examples):
1. Single Active Project - Basic workflow
2. Multiple Active Projects - Cross-project messaging
3. Offline Project Queuing - Send work before Claude starts
4. Remote Claude Spawning - Start work remotely
5. Auto-Spawn Projects - Automatic project startup
6. Managing Multiple Projects - Full lifecycle management

Bot Commands:
- Complete reference for all Telegram commands
- Session management (/sessions, /queue)
- Project management (/register, /spawn, /kill, etc.)
- Bot control commands

MCP Tools Reference:
- Added telegram_check_queue documentation
- Updated all tool descriptions with project context

API Endpoints:
- Session management endpoints
- Queue management endpoints
- Project registry endpoints
- Claude spawner endpoints
- Complete request/response examples

Communication Patterns:
- Basic message flow
- Targeted messages to specific projects
- Notifications with project context
- Question/answer flow
- Queued message delivery

Updated Sections:
- Features section with categorized capabilities
- How It Works with multi-project context
- All usage scenarios with concrete examples
- Complete API reference with all new endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:37:20 -05:00
RichardDillman
bdfe51fedf feat(phase3): add remote Claude spawner and project registry
Phase 3 adds the ability to spawn Claude Code instances remotely from Telegram in registered projects.

New Features:
- Project registry system (~/.innervoice/projects.json)
- Remote Claude spawner with process management
- Auto-spawn capability for projects
- Full CRUD operations for projects via Telegram

Telegram Bot Commands:
- /projects - List all registered projects
- /register ProjectName /path [--auto-spawn]
- /unregister ProjectName
- /spawn ProjectName [initial prompt]
- /spawned - List running spawned processes
- /kill ProjectName

API Endpoints:
- GET /projects - List registered projects
- POST /projects/register - Register new project
- DELETE /projects/:name - Unregister project
- GET /projects/:name - Get project details
- POST /spawn - Spawn Claude in project
- POST /kill/:projectName - Kill spawned process
- GET /spawned - List all spawned processes
- GET /spawned/:projectName - Check if project running

Files Added:
- src/project-registry.ts - Project registry management
- src/claude-spawner.ts - Claude process spawning

Files Modified:
- src/index.ts - Added spawner integration, bot commands, API endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:33:30 -05:00
RichardDillman
fd750d9b50 feat(phase2): add message queue system for offline projects
Phase 2 Complete: Message queuing for offline/inactive projects

Queue Storage:
- File-based queue in ~/.innervoice/queues/
- Separate JSON file per project
- Persistent storage survives restarts
- Auto-cleanup of old delivered tasks (7 days)

Queue Manager (queue-manager.ts):
- enqueueTask() - Add task to project queue
- getPendingTasks() - Get undelivered tasks
- markTaskDelivered() - Mark task as complete
- getQueueSummary() - Get overview of all queues
- cleanupOldTasks() - Remove old delivered tasks

API Endpoints:
- POST /queue/add - Queue message for project
- GET /queue/:projectName - Get pending tasks
- POST /queue/:projectName/mark-delivered - Mark delivered
- GET /queue/summary - Get all project summaries

Telegram Bot Features:
- /queue command - Show all queued messages
- Project-targeted messages: "ProjectName: message"
- Auto-detect if project is online or offline
- Queue for offline, deliver immediately if online

MCP Tool:
- telegram_check_queue - Check for queued messages on startup
- Shows pending messages with timestamps
- Perfect for checking what happened while offline

Usage Scenarios:

1. Send to offline project:
   You: "ESO-MCP: Continue with roadmap"
   Bot: "📥 Message queued for ESO-MCP (offline)"

2. Open Claude in ESO-MCP:
   Claude auto-checks queue on startup
   Shows: "📬 You have 1 queued message: Continue with roadmap"

3. Check queue status:
   You: "/queue"
   Bot: Shows all projects with pending messages

This solves the "no one listening" problem - messages are stored
and delivered when Claude opens in that project.

Next: Phase 3 (remote Claude spawner)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:27:24 -05:00
RichardDillman
82f46c4569 feat(phase1): add project context and session tracking
Phase 1 Complete: Multi-project session management with context display

Session Tracking:
- Auto-generate unique session IDs for each Claude instance
- Track project name and path from working directory
- Register sessions automatically on MCP server startup
- Expire inactive sessions after 30 minutes
- Session heartbeat to track activity

Project Context in Messages:
- All notifications now show: 📁 ProjectName [#shortId]
- Session ID automatically included in all notify calls
- Project context helps identify which Claude sent the message
- Short session ID (first 7 chars) for easy reference

New API Endpoints:
- POST /session/register - Register/update a Claude session
- POST /session/heartbeat - Update session activity
- GET /sessions - List all active sessions with idle times

Telegram Bot Commands:
- /sessions - View all active Claude sessions
- Shows project name, session ID, and idle time
- Status indicators: 🟢 active, 🟡 idle
- Updated /help with new multi-project features

MCP Server Changes:
- getSessionId() - Generate/retrieve session ID
- getProjectInfo() - Extract project name from cwd
- registerSession() - Auto-register on startup
- Include sessionId in all notify API calls

This enables working with multiple Claude instances across different
projects simultaneously, with clear visual indication of which project
each message comes from.

Next: Phase 2 (message queuing) and Phase 3 (remote spawner)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:47:16 -05:00
RichardDillman
0584ac6c5f feat: make /afk command and permission hook globally available
Changed from per-project to global installation by default, making
InnerVoice features available across all projects automatically.

Global Installation:
- /afk slash command now in ~/.claude/commands/afk.md
- Permission hook now in ~/.claude/hooks/PermissionRequest.sh
- Both work in ALL projects without per-project setup

Install Script Updates:
- Added --global flag for global installation (recommended)
- Still supports per-project installation if needed
- Clear scope indication in output messages
- Updated uninstall instructions for both scopes

Documentation:
- Updated README to recommend global installation
- Added examples for both global and per-project setup
- Clearer uninstall instructions for each scope

Benefits:
- Users install once, works everywhere
- No per-project configuration needed
- Easier maintenance and updates
- Consistent behavior across all projects

Breaking Changes: None
- Per-project installation still supported
- Existing installations continue to work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:13:01 -05:00
11 changed files with 2897 additions and 54 deletions

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ Thumbs.db
# PM2
.pm2/
# Stale project copy
innervoice/

61
PHASE1-TESTING.md Normal file
View 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
View File

@@ -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:

View File

@@ -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
View 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
View 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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;
}