Compare commits
10 Commits
6dcb4a888c
...
dc92c7fcf6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc92c7fcf6 | ||
|
|
e26d94dcc2 | ||
|
|
93d65d9a34 | ||
|
|
0ce65d7120 | ||
|
|
0d277e4ae2 | ||
|
|
abde877912 | ||
|
|
bdfe51fedf | ||
|
|
fd750d9b50 | ||
|
|
82f46c4569 | ||
|
|
0584ac6c5f |
61
PHASE1-TESTING.md
Normal file
61
PHASE1-TESTING.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Phase 1 Testing Guide
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
✅ Multi-project session tracking with project context display
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Auto-generate unique session IDs for each Claude instance
|
||||||
|
- Track project name and path from working directory
|
||||||
|
- Show project context in all Telegram messages: `📁 ProjectName [#shortId]`
|
||||||
|
- `/sessions` command to list all active Claude instances
|
||||||
|
- Auto-expire inactive sessions after 30 minutes
|
||||||
|
|
||||||
|
## How to Test
|
||||||
|
|
||||||
|
### 1. Restart Claude Code
|
||||||
|
**Important:** You need to restart Claude Code to load the new MCP server code.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exit your current Claude Code sessions
|
||||||
|
# Then restart in your project
|
||||||
|
cd /path/to/your/project
|
||||||
|
claude
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Project Context
|
||||||
|
Send a notification and you should see the project name:
|
||||||
|
|
||||||
|
```
|
||||||
|
📁 ESO-MCP [#1a2b3c4]
|
||||||
|
ℹ️ Your message here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Multi-Project Sessions
|
||||||
|
1. Open Claude in **ESO-MCP** project
|
||||||
|
2. Open another terminal and start Claude in **innervoice** project
|
||||||
|
3. In Telegram, type `/sessions`
|
||||||
|
4. You should see both projects listed:
|
||||||
|
|
||||||
|
```
|
||||||
|
Active Claude Sessions (2)
|
||||||
|
|
||||||
|
1. 🟢 ESO-MCP [#1a2b3c4]
|
||||||
|
Last active: 0m ago
|
||||||
|
|
||||||
|
2. 🟢 innervoice [#5d6e7f8]
|
||||||
|
Last active: 2m ago
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test Session Auto-Expire
|
||||||
|
Wait 30 minutes of inactivity, then run `/sessions` again.
|
||||||
|
Inactive sessions should be removed automatically.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- You must restart Claude Code for changes to take effect
|
||||||
|
- Old MCP server processes won't pick up new code automatically
|
||||||
|
|
||||||
|
## Next: Phase 2
|
||||||
|
|
||||||
|
Message queue system for offline/inactive projects coming next!
|
||||||
505
README.md
505
README.md
@@ -24,14 +24,33 @@ After trying email, SMS, and Google Chat integrations, Telegram emerged as the b
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Core Communication
|
||||||
- 💬 **Two-Way Communication** - Send messages to Claude, get responses back
|
- 💬 **Two-Way Communication** - Send messages to Claude, get responses back
|
||||||
- ❓ **Question/Answer Flow** - Claude can ask you questions and wait for answers
|
- ❓ **Question/Answer Flow** - Claude can ask you questions and wait for answers
|
||||||
- 📬 **Message Queue** - Messages queue up when Claude is busy, get answered ASAP
|
|
||||||
- 🔔 **Priority Notifications** - Different icons for info, success, warning, error, question
|
- 🔔 **Priority Notifications** - Different icons for info, success, warning, error, question
|
||||||
- 🌐 **HTTP API** - Easy integration from any app/project
|
- 🌐 **HTTP API** - Easy integration from any app/project
|
||||||
- 🚀 **Background Service** - Runs independently, always available
|
- 🚀 **Background Service** - Runs independently, always available
|
||||||
- 🔧 **MCP Protocol** - Works as a standard MCP server in any Claude project
|
- 🔧 **MCP Protocol** - Works as a standard MCP server in any Claude project
|
||||||
|
|
||||||
|
### Multi-Project Support
|
||||||
|
- 📁 **Project Context** - All messages show which project they're from
|
||||||
|
- 🎯 **Targeted Messages** - Send messages to specific projects: `ProjectName: your message`
|
||||||
|
- 📊 **Session Tracking** - Monitor active Claude sessions across projects
|
||||||
|
- 🔄 **Auto-Session Registration** - Projects auto-register when Claude starts
|
||||||
|
|
||||||
|
### Message Queue System
|
||||||
|
- 📬 **Offline Queuing** - Messages queue when projects are offline
|
||||||
|
- 📥 **Persistent Storage** - Queued messages survive restarts
|
||||||
|
- ⏰ **Auto-Delivery** - Messages delivered when Claude starts in that project
|
||||||
|
- 🧹 **Auto-Cleanup** - Old messages expire automatically
|
||||||
|
|
||||||
|
### Remote Claude Spawner
|
||||||
|
- 🚀 **Remote Spawning** - Start Claude in any project from Telegram
|
||||||
|
- 📝 **Project Registry** - Register projects for easy remote access
|
||||||
|
- 🔄 **Auto-Spawn** - Optional auto-start when messages arrive
|
||||||
|
- 💀 **Process Management** - Track and kill spawned Claude instances
|
||||||
|
- 🎯 **Initial Prompts** - Start Claude with a specific task
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
This is a **standard MCP server** that works like any other MCP tool. Once installed and configured:
|
This is a **standard MCP server** that works like any other MCP tool. Once installed and configured:
|
||||||
@@ -195,12 +214,13 @@ cd
|
|||||||
### 6. Available Tools
|
### 6. Available Tools
|
||||||
|
|
||||||
Once configured, Claude can automatically use:
|
Once configured, Claude can automatically use:
|
||||||
- `telegram_notify` - Send notifications
|
- `telegram_notify` - Send notifications with project context
|
||||||
- `telegram_ask` - Ask questions and wait for answers
|
- `telegram_ask` - Ask questions and wait for answers
|
||||||
- `telegram_get_messages` - Check for messages from you
|
- `telegram_get_messages` - Check for messages from you
|
||||||
- `telegram_reply` - Reply to your messages
|
- `telegram_reply` - Reply to your messages
|
||||||
- `telegram_check_health` - Check bridge status
|
- `telegram_check_health` - Check bridge status
|
||||||
- `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications)
|
- `telegram_toggle_afk` - Toggle AFK mode (enable/disable notifications)
|
||||||
|
- `telegram_check_queue` - Check for queued messages on startup
|
||||||
|
|
||||||
**View detailed tool info:**
|
**View detailed tool info:**
|
||||||
```bash
|
```bash
|
||||||
@@ -228,10 +248,14 @@ The toggle state is preserved while the bridge is running, and you'll get a Tele
|
|||||||
|
|
||||||
By default, AFK mode only sends notifications when Claude explicitly uses notification tools. If you want to receive Telegram alerts when **permission prompts** appear (so you know Claude is waiting for approval), install the permission hook:
|
By default, AFK mode only sends notifications when Claude explicitly uses notification tools. If you want to receive Telegram alerts when **permission prompts** appear (so you know Claude is waiting for approval), install the permission hook:
|
||||||
|
|
||||||
|
**Recommended: Install Globally (works in all projects)**
|
||||||
```bash
|
```bash
|
||||||
# From the innervoice directory
|
|
||||||
cd /path/to/innervoice
|
cd /path/to/innervoice
|
||||||
|
./scripts/install-hook.sh --global
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or install per-project:**
|
||||||
|
```bash
|
||||||
# Install in a specific project
|
# Install in a specific project
|
||||||
./scripts/install-hook.sh /path/to/your/project
|
./scripts/install-hook.sh /path/to/your/project
|
||||||
|
|
||||||
@@ -246,7 +270,9 @@ This will send you a Telegram message like:
|
|||||||
> **Action:** Check scraped sets files
|
> **Action:** Check scraped sets files
|
||||||
> Check your terminal to approve or deny.
|
> Check your terminal to approve or deny.
|
||||||
|
|
||||||
**To uninstall:** Simply delete `.claude/hooks/PermissionRequest.sh` from your project.
|
**To uninstall:**
|
||||||
|
- Global: `rm ~/.claude/hooks/PermissionRequest.sh`
|
||||||
|
- Per-project: `rm .claude/hooks/PermissionRequest.sh`
|
||||||
|
|
||||||
### 8. Verify Global Setup
|
### 8. Verify Global Setup
|
||||||
|
|
||||||
@@ -294,6 +320,206 @@ Restart Claude Code, then tell Claude:
|
|||||||
|
|
||||||
Claude will automatically discover and use the `telegram_notify` tool!
|
Claude will automatically discover and use the `telegram_notify` tool!
|
||||||
|
|
||||||
|
## Usage Scenarios
|
||||||
|
|
||||||
|
InnerVoice supports multiple usage patterns depending on your workflow:
|
||||||
|
|
||||||
|
### Scenario 1: Single Active Project
|
||||||
|
**Use Case:** You're actively working in one project
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Start Claude Code in your project
|
||||||
|
2. Claude auto-registers its session
|
||||||
|
3. All messages go to the active session
|
||||||
|
4. Messages show project context: `📁 MyProject [#abc1234]`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
You in Telegram: "Check the test status"
|
||||||
|
Bot: 💬 Message received - responding...
|
||||||
|
Claude: "Running tests... ✅ All 42 tests passed!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Multiple Active Projects
|
||||||
|
**Use Case:** Working across multiple projects simultaneously
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Start Claude in multiple projects (each auto-registers)
|
||||||
|
2. Send targeted messages: `ProjectName: your message`
|
||||||
|
3. View active sessions with `/sessions`
|
||||||
|
4. Each response shows its project context
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
You: "/sessions"
|
||||||
|
Bot: Active Claude Sessions (3)
|
||||||
|
1. 🟢 ESO-MCP [#abc1234]
|
||||||
|
Last active: 2m ago
|
||||||
|
2. 🟢 InnerVoice [#def5678]
|
||||||
|
Last active: 5m ago
|
||||||
|
3. 🟢 MyApp [#ghi9012]
|
||||||
|
Last active: 1m ago
|
||||||
|
|
||||||
|
You: "ESO-MCP: run the scraper"
|
||||||
|
Bot: 💬 Message sent to active session: ESO-MCP
|
||||||
|
Claude in ESO-MCP: 📁 ESO-MCP [#abc1234]
|
||||||
|
✅ Scraper started...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Offline Project Queuing
|
||||||
|
**Use Case:** Send work to a project before Claude is running
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Send: `ProjectName: your task`
|
||||||
|
2. If project offline, message queues automatically
|
||||||
|
3. Start Claude in that project
|
||||||
|
4. Claude checks queue on startup and processes tasks
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
You: "MyApp: fix the login bug"
|
||||||
|
Bot: 📥 Message queued for MyApp (offline)
|
||||||
|
It will be delivered when Claude starts in that project.
|
||||||
|
|
||||||
|
[Later, you start Claude in MyApp]
|
||||||
|
Claude: 📬 You have 1 queued message:
|
||||||
|
1. From Richard (2:30 PM)
|
||||||
|
fix the login bug
|
||||||
|
|
||||||
|
These messages were sent while you were offline.
|
||||||
|
[Claude proceeds to work on the task]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: Remote Claude Spawning
|
||||||
|
**Use Case:** Start work remotely without opening your terminal
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
# Register your projects once
|
||||||
|
You: "/register MyApp ~/code/myapp"
|
||||||
|
Bot: ✅ Project registered successfully!
|
||||||
|
📁 MyApp
|
||||||
|
📍 /Users/you/code/myapp
|
||||||
|
⏸️ Manual spawn only
|
||||||
|
|
||||||
|
Spawn with: /spawn MyApp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Daily Usage:**
|
||||||
|
```
|
||||||
|
You: "/spawn MyApp Fix the login bug"
|
||||||
|
Bot: ⏳ Starting Claude in MyApp...
|
||||||
|
✅ Claude started in MyApp with prompt: "Fix the login bug"
|
||||||
|
PID: 12345
|
||||||
|
|
||||||
|
You can now send messages to it: MyApp: your message
|
||||||
|
|
||||||
|
[Claude automatically starts working on the bug]
|
||||||
|
Claude: 📁 MyApp [#abc1234]
|
||||||
|
🔍 Analyzing login flow...
|
||||||
|
✅ Bug fixed! The session timeout was too short.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 5: Auto-Spawn Projects
|
||||||
|
**Use Case:** Projects that should start automatically when messaged
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
You: "/register MyApp ~/code/myapp --auto-spawn"
|
||||||
|
Bot: ✅ Project registered successfully!
|
||||||
|
📁 MyApp
|
||||||
|
📍 /Users/you/code/myapp
|
||||||
|
🔄 Auto-spawn enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Daily Usage:**
|
||||||
|
```
|
||||||
|
You: "MyApp: run the tests"
|
||||||
|
Bot: 🚀 Auto-spawning Claude in MyApp...
|
||||||
|
✅ Claude started in MyApp
|
||||||
|
PID: 12345
|
||||||
|
|
||||||
|
[Claude auto-starts and processes the message]
|
||||||
|
Claude: 📁 MyApp [#abc1234]
|
||||||
|
🧪 Running test suite...
|
||||||
|
✅ All 42 tests passed!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 6: Managing Multiple Projects
|
||||||
|
**View all projects:**
|
||||||
|
```
|
||||||
|
You: "/projects"
|
||||||
|
Bot: Registered Projects (4)
|
||||||
|
|
||||||
|
1. 🟢 ESO-MCP 🔄
|
||||||
|
📍 /Users/you/code/eso-mcp
|
||||||
|
🕐 Last: 12/23/2025
|
||||||
|
|
||||||
|
2. ⚪ InnerVoice ⏸️
|
||||||
|
📍 /Users/you/code/innervoice
|
||||||
|
🕐 Last: 12/22/2025
|
||||||
|
|
||||||
|
3. 🟢 MyApp 🔄
|
||||||
|
📍 /Users/you/code/myapp
|
||||||
|
🕐 Last: 12/23/2025
|
||||||
|
|
||||||
|
4. ⚪ TestProject ⏸️
|
||||||
|
📍 /Users/you/code/test
|
||||||
|
🕐 Last: 12/20/2025
|
||||||
|
|
||||||
|
🟢 Running ⚪ Offline 🔄 Auto-spawn ⏸️ Manual
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check running processes:**
|
||||||
|
```
|
||||||
|
You: "/spawned"
|
||||||
|
Bot: Spawned Claude Processes (2)
|
||||||
|
|
||||||
|
1. ESO-MCP
|
||||||
|
🆔 PID: 12345
|
||||||
|
⏱️ Running: 15m
|
||||||
|
💬 "run the scraper"
|
||||||
|
|
||||||
|
2. MyApp
|
||||||
|
🆔 PID: 12346
|
||||||
|
⏱️ Running: 5m
|
||||||
|
|
||||||
|
Kill with: /kill ProjectName
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stop a project:**
|
||||||
|
```
|
||||||
|
You: "/kill MyApp"
|
||||||
|
Bot: 🛑 ✅ Claude process terminated in MyApp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telegram Bot Commands
|
||||||
|
|
||||||
|
Complete list of available bot commands:
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- `/sessions` - List all active Claude sessions with status
|
||||||
|
- `/queue` - View queued messages for offline projects
|
||||||
|
|
||||||
|
### Project Management
|
||||||
|
- `/projects` - List all registered projects with status
|
||||||
|
- `/register ProjectName /path [--auto-spawn]` - Register a new project
|
||||||
|
- `/unregister ProjectName` - Remove a project from registry
|
||||||
|
- `/spawn ProjectName [prompt]` - Start Claude in a project
|
||||||
|
- `/spawned` - List all running spawned Claude processes
|
||||||
|
- `/kill ProjectName` - Terminate a spawned Claude process
|
||||||
|
|
||||||
|
### Bot Control
|
||||||
|
- `/start` - Initialize bot and save your chat ID
|
||||||
|
- `/help` - Show all available commands
|
||||||
|
- `/status` - Check bridge health and status
|
||||||
|
- `/test` - Send a test notification
|
||||||
|
|
||||||
|
### Message Syntax
|
||||||
|
- Regular message: Goes to active Claude (if only one running)
|
||||||
|
- `ProjectName: message` - Send to specific project
|
||||||
|
- If project offline, message automatically queues
|
||||||
|
|
||||||
## MCP Tools Reference
|
## MCP Tools Reference
|
||||||
|
|
||||||
Once configured, Claude can automatically use these tools:
|
Once configured, Claude can automatically use these tools:
|
||||||
@@ -359,6 +585,25 @@ Toggle AFK (Away From Keyboard) mode - enables or disables Telegram notification
|
|||||||
- Disable when actively working (avoid interruptions)
|
- Disable when actively working (avoid interruptions)
|
||||||
- State is preserved while the bridge is running
|
- State is preserved while the bridge is running
|
||||||
|
|
||||||
|
### `telegram_check_queue`
|
||||||
|
Check if there are queued messages for this project from when Claude was offline.
|
||||||
|
|
||||||
|
**No parameters required**
|
||||||
|
|
||||||
|
**Example Claude Usage:**
|
||||||
|
> "On startup, let me check for any queued messages."
|
||||||
|
> *Claude uses: `telegram_check_queue({})`*
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- List of messages sent while Claude was offline
|
||||||
|
- Includes sender, timestamp, and message content
|
||||||
|
- Messages are marked as delivered after retrieval
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- On startup to catch up on offline messages
|
||||||
|
- Proactively check for pending work
|
||||||
|
- After long idle periods
|
||||||
|
|
||||||
## Git Setup (For Sharing)
|
## Git Setup (For Sharing)
|
||||||
|
|
||||||
If you want to push this to your own Git repository:
|
If you want to push this to your own Git repository:
|
||||||
@@ -436,32 +681,37 @@ await fetch('http://localhost:3456/notify', {
|
|||||||
- `error` - ❌ Error occurred
|
- `error` - ❌ Error occurred
|
||||||
- `question` - ❓ Needs your input
|
- `question` - ❓ Needs your input
|
||||||
|
|
||||||
## Bot Commands
|
## How Communication Works
|
||||||
|
|
||||||
Type these in Telegram to control the bridge:
|
### Basic Message Flow
|
||||||
|
|
||||||
- `/start` - Initialize connection and save your chat ID
|
|
||||||
- `/help` - Show all available commands and how to use the bridge
|
|
||||||
- `/status` - Check bridge status (enabled, unread messages, pending questions)
|
|
||||||
- `/test` - Send a test notification to verify it's working
|
|
||||||
|
|
||||||
## How Two-Way Communication Works
|
|
||||||
|
|
||||||
### You → Claude
|
|
||||||
1. Send any message to the bot in Telegram
|
1. Send any message to the bot in Telegram
|
||||||
2. Bot acknowledges with "💬 Message received - responding..."
|
2. Bot acknowledges with "💬 Message received - responding..."
|
||||||
3. Claude checks messages and responds when available
|
3. Claude checks messages and responds when available
|
||||||
4. You get the response in Telegram
|
4. You get the response in Telegram with project context
|
||||||
|
|
||||||
### Claude → You (Notifications)
|
### Targeted Messages
|
||||||
Claude sends you updates via the `/notify` API endpoint with different priorities
|
Send `ProjectName: your message` to communicate with a specific project:
|
||||||
|
- If project is running: Message delivered immediately
|
||||||
|
- If project is offline: Message queues automatically
|
||||||
|
|
||||||
### Claude → You (Questions)
|
### Notifications
|
||||||
1. Claude sends a question via `/ask` API
|
Claude sends you updates via the `telegram_notify` tool with:
|
||||||
|
- Project context: `📁 ProjectName [#abc1234]`
|
||||||
|
- Priority icons: ℹ️ ✅ ⚠️ ❌ ❓
|
||||||
|
- Markdown formatting support
|
||||||
|
|
||||||
|
### Questions
|
||||||
|
1. Claude sends a question via `telegram_ask`
|
||||||
2. You see "❓ [question]" in Telegram
|
2. You see "❓ [question]" in Telegram
|
||||||
3. Your next message is automatically treated as the answer
|
3. Your next message is automatically treated as the answer
|
||||||
4. Claude receives your answer and continues
|
4. Claude receives your answer and continues
|
||||||
|
|
||||||
|
### Queued Messages
|
||||||
|
1. Send message to offline project
|
||||||
|
2. Message queues persistently
|
||||||
|
3. When Claude starts in that project, it checks the queue
|
||||||
|
4. Queued messages are delivered and processed
|
||||||
|
|
||||||
## Running as Background Service
|
## Running as Background Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -609,6 +859,221 @@ Get current notification state
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Session Management Endpoints
|
||||||
|
|
||||||
|
#### POST /session/register
|
||||||
|
Register or update a Claude session
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "unique-session-id",
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"projectPath": "/path/to/project"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"sessionId": "unique-session-id",
|
||||||
|
"projectName": "MyProject"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /sessions
|
||||||
|
List all active Claude sessions
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "1234567-abc",
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"projectPath": "/path/to/project",
|
||||||
|
"startTime": "2025-11-23T10:00:00.000Z",
|
||||||
|
"lastActivity": "2025-11-23T10:30:00.000Z",
|
||||||
|
"status": "active",
|
||||||
|
"idleMinutes": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queue Management Endpoints
|
||||||
|
|
||||||
|
#### GET /queue/:projectName
|
||||||
|
Get pending messages for a project
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "task-123",
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"message": "Fix the bug",
|
||||||
|
"from": "Richard",
|
||||||
|
"timestamp": "2025-11-23T09:00:00.000Z",
|
||||||
|
"priority": "normal",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /queue/summary
|
||||||
|
Get summary of all queued messages
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"pending": 2,
|
||||||
|
"delivered": 5,
|
||||||
|
"total": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalProjects": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Registry Endpoints
|
||||||
|
|
||||||
|
#### GET /projects
|
||||||
|
List all registered projects
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "MyProject",
|
||||||
|
"path": "/path/to/project",
|
||||||
|
"lastAccessed": "2025-11-23T10:00:00.000Z",
|
||||||
|
"autoSpawn": false,
|
||||||
|
"metadata": {
|
||||||
|
"description": "My project description",
|
||||||
|
"tags": ["web", "api"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /projects/register
|
||||||
|
Register a new project
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "MyProject",
|
||||||
|
"path": "/path/to/project",
|
||||||
|
"autoSpawn": false,
|
||||||
|
"description": "Optional description",
|
||||||
|
"tags": ["tag1", "tag2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"project": {
|
||||||
|
"name": "MyProject",
|
||||||
|
"path": "/path/to/project",
|
||||||
|
"lastAccessed": "2025-11-23T10:00:00.000Z",
|
||||||
|
"autoSpawn": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE /projects/:name
|
||||||
|
Unregister a project
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Project MyProject unregistered"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Spawner Endpoints
|
||||||
|
|
||||||
|
#### POST /spawn
|
||||||
|
Spawn Claude in a registered project
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"initialPrompt": "Optional initial task"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "✅ Claude started in MyProject",
|
||||||
|
"pid": 12345
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /kill/:projectName
|
||||||
|
Terminate a spawned Claude process
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "✅ Claude process terminated in MyProject"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /spawned
|
||||||
|
List all spawned Claude processes
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"pid": 12345,
|
||||||
|
"startTime": "2025-11-23T10:00:00.000Z",
|
||||||
|
"initialPrompt": "Fix the bug",
|
||||||
|
"runningMinutes": 15
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /spawned/:projectName
|
||||||
|
Check if Claude is running in a project
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"running": true,
|
||||||
|
"process": {
|
||||||
|
"projectName": "MyProject",
|
||||||
|
"pid": 12345,
|
||||||
|
"runningMinutes": 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Integration with ESO-MCP
|
## Integration with ESO-MCP
|
||||||
|
|
||||||
Add this helper to your ESO-MCP project:
|
Add this helper to your ESO-MCP project:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Install the InnerVoice permission notification hook in a project
|
# Install the InnerVoice permission notification hook globally or per-project
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -7,12 +7,22 @@ HOOK_NAME="PermissionRequest.sh"
|
|||||||
INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
INNERVOICE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME"
|
SOURCE_HOOK="$INNERVOICE_DIR/hooks/$HOOK_NAME"
|
||||||
|
|
||||||
# Get target project directory (default to current directory)
|
# Check for --global flag
|
||||||
TARGET_DIR="${1:-.}"
|
if [ "$1" = "--global" ] || [ "$1" = "-g" ]; then
|
||||||
TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks"
|
TARGET_HOOK_DIR="$HOME/.claude/hooks"
|
||||||
|
SCOPE="globally (all projects)"
|
||||||
|
UNINSTALL_CMD="rm ~/.claude/hooks/$HOOK_NAME"
|
||||||
|
else
|
||||||
|
# Get target project directory (default to current directory)
|
||||||
|
TARGET_DIR="${1:-.}"
|
||||||
|
TARGET_HOOK_DIR="$TARGET_DIR/.claude/hooks"
|
||||||
|
SCOPE="in project: $TARGET_DIR"
|
||||||
|
UNINSTALL_CMD="rm $TARGET_HOOK_DIR/$HOOK_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "📦 Installing InnerVoice Permission Notification Hook"
|
echo "📦 Installing InnerVoice Permission Notification Hook"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Scope: $SCOPE"
|
||||||
echo "Source: $SOURCE_HOOK"
|
echo "Source: $SOURCE_HOOK"
|
||||||
echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME"
|
echo "Target: $TARGET_HOOK_DIR/$HOOK_NAME"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -35,4 +45,4 @@ echo ""
|
|||||||
echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications"
|
echo "🔔 Now when you're in AFK mode, you'll get Telegram notifications"
|
||||||
echo " whenever Claude requests permission for a tool."
|
echo " whenever Claude requests permission for a tool."
|
||||||
echo ""
|
echo ""
|
||||||
echo "To uninstall: rm $TARGET_HOOK_DIR/$HOOK_NAME"
|
echo "To uninstall: $UNINSTALL_CMD"
|
||||||
|
|||||||
306
src/chatbot.ts
Normal file
306
src/chatbot.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
chatbotSessions.set(chatId, {
|
||||||
|
sessionId: response.session_id,
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
messageCount: (session && !isExpired ? session.messageCount : 0) + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultText = response.result || '(пустой ответ)';
|
||||||
|
const chunks = splitMessage(resultText);
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await safeSendReply(sendReply, chunk);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Chatbot] Error:', error.message);
|
||||||
|
await safeSendReply(sendReply, `❌ Ошибка: ${error.message}`);
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
813
src/index.ts
813
src/index.ts
@@ -3,10 +3,39 @@ import express from 'express';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
enqueueTask,
|
||||||
|
getPendingTasks,
|
||||||
|
markTaskDelivered,
|
||||||
|
getQueueSummary,
|
||||||
|
cleanupOldTasks
|
||||||
|
} from './queue-manager.js';
|
||||||
|
import {
|
||||||
|
spawnClaude,
|
||||||
|
killClaude,
|
||||||
|
listSpawnedProcesses,
|
||||||
|
isClaudeRunning
|
||||||
|
} from './claude-spawner.js';
|
||||||
|
import {
|
||||||
|
registerProject,
|
||||||
|
unregisterProject,
|
||||||
|
findProject,
|
||||||
|
loadProjects,
|
||||||
|
validateProjectPath
|
||||||
|
} from './project-registry.js';
|
||||||
|
import {
|
||||||
|
handleChatbotMessage,
|
||||||
|
initChatbot,
|
||||||
|
isChatbotEnabled,
|
||||||
|
resetChatbotSession,
|
||||||
|
getChatbotStatus
|
||||||
|
} from './chatbot.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!);
|
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!, {
|
||||||
|
handlerTimeout: 300_000, // 5 min for chatbot responses
|
||||||
|
});
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.PORT || '3456');
|
const PORT = parseInt(process.env.PORT || '3456');
|
||||||
const HOST = process.env.HOST || 'localhost';
|
const HOST = process.env.HOST || 'localhost';
|
||||||
@@ -15,16 +44,51 @@ let ENABLED = process.env.ENABLED !== 'false'; // Now mutable for runtime toggli
|
|||||||
let chatId: string | null = process.env.TELEGRAM_CHAT_ID || null;
|
let chatId: string | null = process.env.TELEGRAM_CHAT_ID || null;
|
||||||
const envPath = path.join(process.cwd(), '.env');
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Session tracking for multi-project support
|
||||||
|
interface ClaudeSession {
|
||||||
|
id: string;
|
||||||
|
projectName: string;
|
||||||
|
projectPath: string;
|
||||||
|
startTime: Date;
|
||||||
|
lastActivity: Date;
|
||||||
|
status: 'active' | 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSessions = new Map<string, ClaudeSession>();
|
||||||
|
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes of inactivity
|
||||||
|
|
||||||
// Message queue for two-way communication
|
// Message queue for two-way communication
|
||||||
interface QueuedMessage {
|
interface QueuedMessage {
|
||||||
from: string;
|
from: string;
|
||||||
message: string;
|
message: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
|
sessionId?: string; // Target session for this message
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageQueue: QueuedMessage[] = [];
|
const messageQueue: QueuedMessage[] = [];
|
||||||
const pendingQuestions = new Map<string, { resolve: (answer: string) => void; timeout: NodeJS.Timeout }>();
|
const pendingQuestions = new Map<string, {
|
||||||
|
resolve: (answer: string) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
sessionId?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Track the last session that sent a message (for auto-routing replies)
|
||||||
|
let lastMessageSession: string | null = null;
|
||||||
|
|
||||||
|
// Clean up expired sessions periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, session] of activeSessions.entries()) {
|
||||||
|
if (now - session.lastActivity.getTime() > SESSION_TIMEOUT) {
|
||||||
|
console.log(`🧹 Removing expired session: ${sessionId} (${session.projectName})`);
|
||||||
|
activeSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000); // Check every 5 minutes
|
||||||
|
|
||||||
|
// Initialize chatbot module
|
||||||
|
initChatbot();
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
@@ -73,21 +137,29 @@ bot.command('status', async (ctx) => {
|
|||||||
bot.command('help', async (ctx) => {
|
bot.command('help', async (ctx) => {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
'*Claude Telegram Bridge - Commands*\n\n' +
|
'*Claude Telegram Bridge - Commands*\n\n' +
|
||||||
'*Bot Commands:*\n' +
|
'*Chatbot:*\n' +
|
||||||
'`/start` - Initialize and connect\n' +
|
'• Send any message — Claude ответит как чат-бот\n' +
|
||||||
'`/help` - Show this help message\n' +
|
'`/chatbot` - Статус чат-бота\n' +
|
||||||
|
'`/chatreset` - Сброс диалога\n\n' +
|
||||||
|
'*Session Management:*\n' +
|
||||||
|
'`/sessions` - List active Claude sessions\n' +
|
||||||
|
'`/queue` - View queued messages\n\n' +
|
||||||
|
'*Project Management:*\n' +
|
||||||
|
'`/projects` - List registered projects\n' +
|
||||||
|
'`/register` ProjectName /path [--auto-spawn]\n' +
|
||||||
|
'`/unregister` ProjectName\n' +
|
||||||
|
'`/spawn` ProjectName [prompt]\n' +
|
||||||
|
'`/spawned` - List spawned processes\n' +
|
||||||
|
'`/kill` ProjectName\n\n' +
|
||||||
|
'*Bot Control:*\n' +
|
||||||
'`/status` - Check bridge status\n' +
|
'`/status` - Check bridge status\n' +
|
||||||
'`/test` - Send test notification\n\n' +
|
'`/test` - Send test notification\n\n' +
|
||||||
'*How it works:*\n' +
|
'*How it works:*\n' +
|
||||||
'• Send me any message - I forward it to Claude\n' +
|
'• Send any message — chatbot responds via Claude CLI\n' +
|
||||||
'• Claude processes it and replies back\n' +
|
'• Target specific project: `ProjectName: message`\n' +
|
||||||
'• When Claude asks a question, your next message answers it\n\n' +
|
'• Messages show context: 📁 ProjectName [#abc1234]\n' +
|
||||||
'*Features:*\n' +
|
'• Register projects for remote spawning\n' +
|
||||||
'✅ Two-way communication\n' +
|
'• Messages queue when projects are offline',
|
||||||
'✅ Question/Answer flow\n' +
|
|
||||||
'✅ Progress notifications\n' +
|
|
||||||
'✅ Error alerts\n\n' +
|
|
||||||
'More info: See README in bridge folder',
|
|
||||||
{ parse_mode: 'Markdown' }
|
{ parse_mode: 'Markdown' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -96,6 +168,270 @@ bot.command('test', async (ctx) => {
|
|||||||
await ctx.reply('✅ Test notification received! Bridge is working.');
|
await ctx.reply('✅ Test notification received! Bridge is working.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bot.command('chatbot', async (ctx) => {
|
||||||
|
const status = getChatbotStatus(ctx.chat.id.toString());
|
||||||
|
const enabledText = status.enabled ? '✅ Включен' : '⛔ Выключен';
|
||||||
|
const sessionText = status.hasSession
|
||||||
|
? `🗣 Активная сессия: ${status.messageCount} сообщений, последнее ${status.sessionAge} мин назад`
|
||||||
|
: '📭 Нет активной сессии';
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`*Chatbot Status*\n\n` +
|
||||||
|
`${enabledText}\n` +
|
||||||
|
`${sessionText}\n\n` +
|
||||||
|
`Сброс диалога: /chatreset`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('chatreset', async (ctx) => {
|
||||||
|
const deleted = resetChatbotSession(ctx.chat.id.toString());
|
||||||
|
if (deleted) {
|
||||||
|
await ctx.reply('🔄 Диалог сброшен. Следующее сообщение начнёт новую сессию.');
|
||||||
|
} else {
|
||||||
|
await ctx.reply('📭 Нет активной сессии для сброса.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('sessions', async (ctx) => {
|
||||||
|
const sessions = Array.from(activeSessions.values());
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
await ctx.reply('📭 No active Claude sessions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionList = sessions.map((s, i) => {
|
||||||
|
const shortId = s.id.substring(0, 7);
|
||||||
|
const idleMinutes = Math.floor((Date.now() - s.lastActivity.getTime()) / 60000);
|
||||||
|
const statusEmoji = s.status === 'active' ? '🟢' : '🟡';
|
||||||
|
return `${i + 1}. ${statusEmoji} *${s.projectName}* [#${shortId}]\n Last active: ${idleMinutes}m ago`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`*Active Claude Sessions* (${sessions.length})\n\n${sessionList}\n\n_To send message to specific project: ProjectName: your message_`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('queue', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const summary = await getQueueSummary();
|
||||||
|
|
||||||
|
if (summary.length === 0) {
|
||||||
|
await ctx.reply('📭 No queued messages');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueList = summary.map((s, i) => {
|
||||||
|
return `${i + 1}. *${s.projectName}*\n 📥 ${s.pending} pending (${s.total} total)`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`*Queued Messages* (${summary.length} projects)\n\n${queueList}`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('projects', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
await ctx.reply('📭 No registered projects\n\nRegister with: `/register ProjectName /path/to/project`', { parse_mode: 'Markdown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectList = projects.map((p, i) => {
|
||||||
|
const autoSpawnEmoji = p.autoSpawn ? '🔄' : '⏸️';
|
||||||
|
const lastAccessed = new Date(p.lastAccessed).toLocaleDateString();
|
||||||
|
const running = isClaudeRunning(p.name) ? '🟢' : '⚪';
|
||||||
|
return `${i + 1}. ${running} *${p.name}* ${autoSpawnEmoji}\n 📍 ${p.path}\n 🕐 Last: ${lastAccessed}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`*Registered Projects* (${projects.length})\n\n${projectList}\n\n🟢 Running ⚪ Offline 🔄 Auto-spawn ⏸️ Manual`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('register', async (ctx) => {
|
||||||
|
const args = ctx.message.text.split(' ').slice(1);
|
||||||
|
|
||||||
|
if (args.length < 2) {
|
||||||
|
await ctx.reply(
|
||||||
|
'📝 *Register a Project*\n\n' +
|
||||||
|
'Usage: `/register ProjectName /path/to/project [--auto-spawn]`\n\n' +
|
||||||
|
'Example: `/register MyApp ~/code/myapp --auto-spawn`\n\n' +
|
||||||
|
'Options:\n' +
|
||||||
|
'• `--auto-spawn`: Auto-start Claude when messages arrive',
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = args[0];
|
||||||
|
const projectPath = args[1].replace('~', process.env.HOME || '~');
|
||||||
|
const autoSpawn = args.includes('--auto-spawn');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate path exists
|
||||||
|
const isValid = await validateProjectPath(projectPath);
|
||||||
|
if (!isValid) {
|
||||||
|
await ctx.reply(`❌ Path does not exist or is not a directory: ${projectPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerProject(projectName, projectPath, { autoSpawn });
|
||||||
|
await ctx.reply(
|
||||||
|
`✅ Project registered successfully!\n\n` +
|
||||||
|
`📁 *${projectName}*\n` +
|
||||||
|
`📍 ${projectPath}\n` +
|
||||||
|
`${autoSpawn ? '🔄 Auto-spawn enabled' : '⏸️ Manual spawn only'}\n\n` +
|
||||||
|
`Spawn with: \`/spawn ${projectName}\``,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Registration failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('unregister', async (ctx) => {
|
||||||
|
const args = ctx.message.text.split(' ').slice(1);
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
await ctx.reply('Usage: `/unregister ProjectName`', { parse_mode: 'Markdown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = args[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await unregisterProject(projectName);
|
||||||
|
if (success) {
|
||||||
|
await ctx.reply(`✅ Project *${projectName}* unregistered`, { parse_mode: 'Markdown' });
|
||||||
|
} else {
|
||||||
|
await ctx.reply(`❌ Project *${projectName}* not found`, { parse_mode: 'Markdown' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('spawn', async (ctx) => {
|
||||||
|
const args = ctx.message.text.split(' ').slice(1);
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
await ctx.reply(
|
||||||
|
'🚀 *Spawn Claude in a Project*\n\n' +
|
||||||
|
'Usage: `/spawn ProjectName [prompt]`\n\n' +
|
||||||
|
'Example:\n' +
|
||||||
|
'`/spawn MyApp`\n' +
|
||||||
|
'`/spawn MyApp "Fix the login bug"`',
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = args[0];
|
||||||
|
const initialPrompt = args.slice(1).join(' ') || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.reply(`⏳ Starting Claude in *${projectName}*...`, { parse_mode: 'Markdown' });
|
||||||
|
|
||||||
|
// Create callback to send Claude output to Telegram
|
||||||
|
const outputCallback = async (data: string, isError: boolean) => {
|
||||||
|
console.log(`[CALLBACK] Received output for ${projectName}: ${data.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
if (!chatId) {
|
||||||
|
console.error('[CALLBACK] No chatId available, cannot send to Telegram');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emoji = isError ? '❌' : '🤖';
|
||||||
|
console.log(`[CALLBACK] Sending to Telegram chatId: ${chatId}`);
|
||||||
|
await bot.telegram.sendMessage(
|
||||||
|
chatId,
|
||||||
|
`📁 *${projectName}*\n${emoji} ${data}`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
console.log(`[CALLBACK] Successfully sent to Telegram`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CALLBACK] Failed to send Claude output to Telegram:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[SPAWN] Creating callback for ${projectName}, chatId: ${chatId}`);
|
||||||
|
const result = await spawnClaude(projectName, initialPrompt, outputCallback);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await ctx.reply(
|
||||||
|
`${result.message}\n\n` +
|
||||||
|
`PID: ${result.pid}\n\n` +
|
||||||
|
`You can now send messages to it: \`${projectName}: your message\``,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ctx.reply(`❌ ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Spawn failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('kill', async (ctx) => {
|
||||||
|
const args = ctx.message.text.split(' ').slice(1);
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
await ctx.reply('Usage: `/kill ProjectName`', { parse_mode: 'Markdown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = args[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = killClaude(projectName);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await ctx.reply(`🛑 ${result.message}`);
|
||||||
|
} else {
|
||||||
|
await ctx.reply(`❌ ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command('spawned', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const spawned = listSpawnedProcesses();
|
||||||
|
|
||||||
|
if (spawned.length === 0) {
|
||||||
|
await ctx.reply('📭 No spawned Claude processes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnedList = spawned.map((s, i) => {
|
||||||
|
const prompt = s.initialPrompt ? `\n 💬 "${s.initialPrompt.substring(0, 50)}${s.initialPrompt.length > 50 ? '...' : ''}"` : '';
|
||||||
|
return `${i + 1}. *${s.projectName}*\n 🆔 PID: ${s.pid}\n ⏱️ Running: ${s.runningMinutes}m${prompt}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
await ctx.reply(
|
||||||
|
`*Spawned Claude Processes* (${spawned.length})\n\n${spawnedList}\n\n_Kill with: /kill ProjectName_`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for any text messages from user
|
// Listen for any text messages from user
|
||||||
bot.on('text', async (ctx) => {
|
bot.on('text', async (ctx) => {
|
||||||
const message = ctx.message.text;
|
const message = ctx.message.text;
|
||||||
@@ -114,18 +450,301 @@ bot.on('text', async (ctx) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to message queue for processing
|
// Check if message is targeted to a specific project: "ProjectName: message"
|
||||||
|
// Use 's' flag (dotall) so . matches newlines for multi-line messages
|
||||||
|
const projectMatch = message.match(/^([a-zA-Z0-9-_]+):\s*(.+)/s);
|
||||||
|
if (projectMatch) {
|
||||||
|
const [, targetProject, actualMessage] = projectMatch;
|
||||||
|
|
||||||
|
// Check if project has an active session
|
||||||
|
const activeSession = Array.from(activeSessions.values())
|
||||||
|
.find(s => s.projectName.toLowerCase() === targetProject.toLowerCase());
|
||||||
|
|
||||||
|
// Check if Claude is actually running (not just session registered)
|
||||||
|
const claudeActuallyRunning = isClaudeRunning(targetProject);
|
||||||
|
|
||||||
|
if (activeSession && claudeActuallyRunning) {
|
||||||
|
// Add to message queue with session ID
|
||||||
|
messageQueue.push({
|
||||||
|
from,
|
||||||
|
message: actualMessage,
|
||||||
|
timestamp: new Date(),
|
||||||
|
read: false,
|
||||||
|
sessionId: activeSession.id
|
||||||
|
});
|
||||||
|
await ctx.reply(`💬 Message sent to active session: *${activeSession.projectName}*`, { parse_mode: 'Markdown' });
|
||||||
|
} else {
|
||||||
|
// Clean up stale session if Claude exited
|
||||||
|
if (activeSession && !claudeActuallyRunning) {
|
||||||
|
console.log(`[CLEANUP] Removing stale session for ${activeSession.projectName} (Claude not running)`);
|
||||||
|
activeSessions.delete(activeSession.id);
|
||||||
|
}
|
||||||
|
// No active session - check if project is registered and should auto-spawn
|
||||||
|
try {
|
||||||
|
const project = await findProject(targetProject);
|
||||||
|
|
||||||
|
if (project && project.autoSpawn) {
|
||||||
|
// Auto-spawn Claude for this project
|
||||||
|
await ctx.reply(`⏳ Auto-spawning Claude for *${project.name}*...`, { parse_mode: 'Markdown' });
|
||||||
|
|
||||||
|
// Create callback to send Claude output to Telegram
|
||||||
|
const outputCallback = async (data: string, isError: boolean) => {
|
||||||
|
console.log(`[AUTO-SPAWN CALLBACK] Received output for ${project.name}: ${data.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
if (!chatId) {
|
||||||
|
console.error('[AUTO-SPAWN CALLBACK] No chatId available, cannot send to Telegram');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emoji = isError ? '❌' : '🤖';
|
||||||
|
console.log(`[AUTO-SPAWN CALLBACK] Sending to Telegram chatId: ${chatId}`);
|
||||||
|
await bot.telegram.sendMessage(
|
||||||
|
chatId,
|
||||||
|
`📁 *${project.name}*\n${emoji} ${data}`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
console.log(`[AUTO-SPAWN CALLBACK] Successfully sent to Telegram`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTO-SPAWN CALLBACK] Failed to send Claude output to Telegram:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[AUTO-SPAWN] Creating callback for ${project.name}, chatId: ${chatId}`);
|
||||||
|
const result = await spawnClaude(project.name, actualMessage, outputCallback);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await ctx.reply(
|
||||||
|
`✅ Claude started for *${project.name}*\n\n` +
|
||||||
|
`PID: ${result.pid}\n` +
|
||||||
|
`💬 Your message was passed as the initial prompt.`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Spawn failed - queue the message instead
|
||||||
|
await enqueueTask({
|
||||||
|
projectName: targetProject,
|
||||||
|
projectPath: project.path,
|
||||||
|
message: actualMessage,
|
||||||
|
from,
|
||||||
|
priority: 'normal',
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
await ctx.reply(`❌ Auto-spawn failed: ${result.message}\n\n📥 Message queued instead.`, { parse_mode: 'Markdown' });
|
||||||
|
}
|
||||||
|
} else if (project) {
|
||||||
|
// Project exists but auto-spawn disabled - just queue
|
||||||
|
await enqueueTask({
|
||||||
|
projectName: targetProject,
|
||||||
|
projectPath: project.path,
|
||||||
|
message: actualMessage,
|
||||||
|
from,
|
||||||
|
priority: 'normal',
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
await ctx.reply(
|
||||||
|
`📥 Message queued for *${project.name}* (offline)\n\n` +
|
||||||
|
`Auto-spawn is disabled. Start manually with: \`/spawn ${project.name}\``,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Project not registered
|
||||||
|
await enqueueTask({
|
||||||
|
projectName: targetProject,
|
||||||
|
projectPath: '/unknown',
|
||||||
|
message: actualMessage,
|
||||||
|
from,
|
||||||
|
priority: 'normal',
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
await ctx.reply(
|
||||||
|
`📥 Message queued for *${targetProject}* (not registered)\n\n` +
|
||||||
|
`Register with: \`/register ${targetProject} /path/to/project --auto-spawn\``,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
await ctx.reply(`❌ Failed to process message: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No project specified - check if we should auto-route to last session
|
||||||
|
if (lastMessageSession && activeSessions.has(lastMessageSession)) {
|
||||||
|
const session = activeSessions.get(lastMessageSession)!;
|
||||||
|
const claudeRunning = isClaudeRunning(session.projectName);
|
||||||
|
|
||||||
|
if (claudeRunning) {
|
||||||
|
messageQueue.push({
|
||||||
|
from,
|
||||||
|
message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
read: false,
|
||||||
|
sessionId: lastMessageSession
|
||||||
|
});
|
||||||
|
await ctx.reply(`💬 Auto-routed to: 📁 *${session.projectName}* [#${lastMessageSession.substring(0, 7)}]`, { parse_mode: 'Markdown' });
|
||||||
|
console.log(`📥 Auto-routed to ${session.projectName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude not actually running — clean up stale session
|
||||||
|
console.log(`[CLEANUP] Removing stale lastMessageSession for ${session.projectName}`);
|
||||||
|
activeSessions.delete(lastMessageSession);
|
||||||
|
lastMessageSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active project session — use chatbot if enabled
|
||||||
|
if (isChatbotEnabled()) {
|
||||||
|
const userChatId = ctx.chat.id.toString();
|
||||||
|
console.log(`🤖 Chatbot handling message from ${from}: "${message.substring(0, 50)}..."`);
|
||||||
|
|
||||||
|
// Fire-and-forget: don't block Telegraf's handler
|
||||||
|
handleChatbotMessage(
|
||||||
|
userChatId,
|
||||||
|
message,
|
||||||
|
async () => {
|
||||||
|
await ctx.sendChatAction('typing');
|
||||||
|
},
|
||||||
|
async (text: string, parseMode?: string) => {
|
||||||
|
await ctx.reply(text, parseMode ? { parse_mode: parseMode as any } : {});
|
||||||
|
}
|
||||||
|
).catch(err => console.error('[Chatbot] Async error:', err));
|
||||||
|
} else {
|
||||||
|
// Fallback: queue the message (original behavior)
|
||||||
messageQueue.push({
|
messageQueue.push({
|
||||||
from,
|
from,
|
||||||
message,
|
message,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
read: false
|
read: false
|
||||||
});
|
});
|
||||||
|
await ctx.reply('💬 Message received - queued for processing.');
|
||||||
// Acknowledge receipt - Claude will respond when available
|
|
||||||
await ctx.reply('💬 Message received - responding...');
|
|
||||||
|
|
||||||
console.log('📥 Queued for Claude to process');
|
console.log('📥 Queued for Claude to process');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register or update a Claude session
|
||||||
|
app.post('/session/register', (req, res) => {
|
||||||
|
const { sessionId, projectName, projectPath } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId || !projectName || !projectPath) {
|
||||||
|
return res.status(400).json({ error: 'sessionId, projectName, and projectPath are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const existing = activeSessions.get(sessionId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing session
|
||||||
|
existing.lastActivity = now;
|
||||||
|
existing.status = 'active';
|
||||||
|
} else {
|
||||||
|
// Create new session
|
||||||
|
activeSessions.set(sessionId, {
|
||||||
|
id: sessionId,
|
||||||
|
projectName,
|
||||||
|
projectPath,
|
||||||
|
startTime: now,
|
||||||
|
lastActivity: now,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
console.log(`📝 Registered new session: ${sessionId} (${projectName})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, sessionId, projectName });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update session activity
|
||||||
|
app.post('/session/heartbeat', (req, res) => {
|
||||||
|
const { sessionId } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: 'sessionId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = activeSessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.lastActivity = new Date();
|
||||||
|
session.status = 'active';
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List active sessions
|
||||||
|
app.get('/sessions', (req, res) => {
|
||||||
|
const sessions = Array.from(activeSessions.values()).map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
projectName: s.projectName,
|
||||||
|
projectPath: s.projectPath,
|
||||||
|
startTime: s.startTime,
|
||||||
|
lastActivity: s.lastActivity,
|
||||||
|
status: s.status,
|
||||||
|
idleMinutes: Math.floor((Date.now() - s.lastActivity.getTime()) / 60000)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ sessions, count: sessions.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue management endpoints
|
||||||
|
app.post('/queue/add', async (req, res) => {
|
||||||
|
const { projectName, projectPath, message, from, priority = 'normal' } = req.body;
|
||||||
|
|
||||||
|
if (!projectName || !message || !from) {
|
||||||
|
return res.status(400).json({ error: 'projectName, message, and from are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await enqueueTask({
|
||||||
|
projectName,
|
||||||
|
projectPath: projectPath || '/unknown',
|
||||||
|
message,
|
||||||
|
from,
|
||||||
|
priority,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, taskId: task.id, task });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/queue/:projectName', async (req, res) => {
|
||||||
|
const { projectName } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tasks = await getPendingTasks(projectName);
|
||||||
|
res.json({ projectName, tasks, count: tasks.length });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/queue/:projectName/mark-delivered', async (req, res) => {
|
||||||
|
const { projectName } = req.params;
|
||||||
|
const { taskId } = req.body;
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return res.status(400).json({ error: 'taskId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markTaskDelivered(projectName, taskId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/queue/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const summary = await getQueueSummary();
|
||||||
|
res.json({ summary, totalProjects: summary.length });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// HTTP endpoint for sending notifications
|
// HTTP endpoint for sending notifications
|
||||||
@@ -140,7 +759,7 @@ app.post('/notify', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { message, priority = 'info', parseMode = 'Markdown' } = req.body;
|
const { message, priority = 'info', parseMode = 'Markdown', sessionId } = req.body;
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return res.status(400).json({ error: 'Message is required' });
|
return res.status(400).json({ error: 'Message is required' });
|
||||||
@@ -156,9 +775,22 @@ app.post('/notify', async (req, res) => {
|
|||||||
};
|
};
|
||||||
const emoji = emojiMap[priority] || 'ℹ️';
|
const emoji = emojiMap[priority] || 'ℹ️';
|
||||||
|
|
||||||
|
// Add project context if session ID provided
|
||||||
|
let projectContext = '';
|
||||||
|
if (sessionId) {
|
||||||
|
const session = activeSessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.lastActivity = new Date();
|
||||||
|
const shortId = sessionId.substring(0, 7);
|
||||||
|
projectContext = `📁 *${session.projectName}* [#${shortId}]\n`;
|
||||||
|
// Track this as the last session that sent a message
|
||||||
|
lastMessageSession = sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await bot.telegram.sendMessage(
|
await bot.telegram.sendMessage(
|
||||||
chatId,
|
chatId,
|
||||||
`${emoji} ${message}`,
|
`${projectContext}${emoji} ${message}`,
|
||||||
{ parse_mode: parseMode as any }
|
{ parse_mode: parseMode as any }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -290,6 +922,145 @@ app.get('/status', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Project registry endpoints
|
||||||
|
app.get('/projects', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
res.json({ projects, count: projects.length });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/projects/register', async (req, res) => {
|
||||||
|
const { name, path: projectPath, autoSpawn, description, tags } = req.body;
|
||||||
|
|
||||||
|
if (!name || !projectPath) {
|
||||||
|
return res.status(400).json({ error: 'name and path are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isValid = await validateProjectPath(projectPath);
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(400).json({ error: 'Invalid path or not a directory' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await registerProject(name, projectPath, { autoSpawn, description, tags });
|
||||||
|
res.json({ success: true, project });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/projects/:name', async (req, res) => {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await unregisterProject(name);
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true, message: `Project ${name} unregistered` });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Project not found' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/projects/:name', async (req, res) => {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await findProject(name);
|
||||||
|
if (project) {
|
||||||
|
res.json({ project });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Project not found' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claude spawner endpoints
|
||||||
|
app.post('/spawn', async (req, res) => {
|
||||||
|
const { projectName, initialPrompt } = req.body;
|
||||||
|
|
||||||
|
if (!projectName) {
|
||||||
|
return res.status(400).json({ error: 'projectName is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create callback to send Claude output to Telegram
|
||||||
|
const outputCallback = chatId ? async (data: string, isError: boolean) => {
|
||||||
|
console.log(`[HTTP CALLBACK] Received output for ${projectName}: ${data.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emoji = isError ? '❌' : '🤖';
|
||||||
|
console.log(`[HTTP CALLBACK] Sending to Telegram chatId: ${chatId}`);
|
||||||
|
await bot.telegram.sendMessage(
|
||||||
|
chatId!,
|
||||||
|
`📁 *${projectName}*\n${emoji} ${data}`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
console.log(`[HTTP CALLBACK] Successfully sent to Telegram`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HTTP CALLBACK] Failed to send Claude output to Telegram:', error);
|
||||||
|
}
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
console.log(`[HTTP /spawn] Creating callback for ${projectName}, chatId: ${chatId}, hasCallback: ${!!outputCallback}`);
|
||||||
|
const result = await spawnClaude(projectName, initialPrompt, outputCallback);
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(400).json(result);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/kill/:projectName', (req, res) => {
|
||||||
|
const { projectName } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = killClaude(projectName);
|
||||||
|
if (result.success) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(404).json(result);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/spawned', (req, res) => {
|
||||||
|
try {
|
||||||
|
const spawned = listSpawnedProcesses();
|
||||||
|
res.json({ processes: spawned, count: spawned.length });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/spawned/:projectName', (req, res) => {
|
||||||
|
const { projectName } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const running = isClaudeRunning(projectName);
|
||||||
|
if (running) {
|
||||||
|
const spawned = listSpawnedProcesses().find(p => p.projectName === projectName);
|
||||||
|
res.json({ running: true, process: spawned });
|
||||||
|
} else {
|
||||||
|
res.json({ running: false });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Start bot
|
// Start bot
|
||||||
bot.launch().then(() => {
|
bot.launch().then(() => {
|
||||||
console.log('🤖 Telegram bot started');
|
console.log('🤖 Telegram bot started');
|
||||||
|
|||||||
@@ -20,6 +20,50 @@ const BRIDGE_HOST = new URL(BRIDGE_URL).hostname || 'localhost';
|
|||||||
|
|
||||||
let bridgeProcess: ChildProcess | null = null;
|
let bridgeProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
let currentSessionId: string | null = null;
|
||||||
|
let currentProjectName: string | null = null;
|
||||||
|
let currentProjectPath: string | null = null;
|
||||||
|
|
||||||
|
// Get or create session ID
|
||||||
|
function getSessionId(): string {
|
||||||
|
if (!currentSessionId) {
|
||||||
|
currentSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
return currentSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project name from current working directory
|
||||||
|
function getProjectInfo(): { name: string; path: string } {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const name = cwd.split('/').pop() || 'Unknown';
|
||||||
|
return { name, path: cwd };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register this session with the bridge
|
||||||
|
async function registerSession(): Promise<void> {
|
||||||
|
const sessionId = getSessionId();
|
||||||
|
const { name, path } = getProjectInfo();
|
||||||
|
|
||||||
|
currentProjectName = name;
|
||||||
|
currentProjectPath = path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${BRIDGE_URL}/session/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
projectName: name,
|
||||||
|
projectPath: path
|
||||||
|
})
|
||||||
|
});
|
||||||
|
console.error(`✅ Session registered: ${name} [${sessionId.substring(0, 7)}]`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ Failed to register session:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the bridge is running
|
// Check if the bridge is running
|
||||||
async function isBridgeRunning(): Promise<boolean> {
|
async function isBridgeRunning(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -174,6 +218,14 @@ const TOOLS: Tool[] = [
|
|||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'telegram_check_queue',
|
||||||
|
description: 'Check if there are any queued messages for this project. Use this on startup to see if the user sent any messages while Claude was offline.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create the MCP server
|
// Create the MCP server
|
||||||
@@ -208,7 +260,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
const response = await fetch(`${BRIDGE_URL}/notify`, {
|
const response = await fetch(`${BRIDGE_URL}/notify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message, priority }),
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
priority,
|
||||||
|
sessionId: getSessionId()
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -352,6 +408,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'telegram_check_queue': {
|
||||||
|
const { name: projectName } = getProjectInfo();
|
||||||
|
|
||||||
|
const response = await fetch(`${BRIDGE_URL}/queue/${encodeURIComponent(projectName)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to check queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = await response.json();
|
||||||
|
const tasks = result.tasks || [];
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '📭 No queued messages for this project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskList = tasks.map((t: any, i: number) => {
|
||||||
|
const timestamp = new Date(t.timestamp).toLocaleString();
|
||||||
|
return `${i + 1}. From *${t.from}* (${timestamp})\n ${t.message}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `📬 You have ${tasks.length} queued message(s):\n\n${taskList}\n\n_These messages were sent while you were offline._`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
}
|
}
|
||||||
@@ -395,6 +489,9 @@ async function main() {
|
|||||||
// Ensure the bridge is running before starting the MCP server
|
// Ensure the bridge is running before starting the MCP server
|
||||||
await ensureBridge();
|
await ensureBridge();
|
||||||
|
|
||||||
|
// Register this Claude session
|
||||||
|
await registerSession();
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
console.error('🚀 Telegram MCP server running on stdio');
|
console.error('🚀 Telegram MCP server running on stdio');
|
||||||
|
|||||||
148
src/project-registry.ts
Normal file
148
src/project-registry.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// Project Registry for InnerVoice
|
||||||
|
// Manages known projects and their locations for remote spawning
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
const REGISTRY_PATH = path.join(process.env.HOME || '~', '.innervoice', 'projects.json');
|
||||||
|
|
||||||
|
export interface RegisteredProject {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
lastAccessed: Date;
|
||||||
|
autoSpawn: boolean; // Auto-spawn when message received
|
||||||
|
metadata?: {
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure registry file exists
|
||||||
|
async function ensureRegistry(): Promise<void> {
|
||||||
|
const dir = path.dirname(REGISTRY_PATH);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(REGISTRY_PATH)) {
|
||||||
|
await fs.writeFile(REGISTRY_PATH, JSON.stringify([], null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all registered projects
|
||||||
|
export async function loadProjects(): Promise<RegisteredProject[]> {
|
||||||
|
await ensureRegistry();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(REGISTRY_PATH, 'utf-8');
|
||||||
|
const projects = JSON.parse(content);
|
||||||
|
// Convert date strings back to Date objects
|
||||||
|
return projects.map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
lastAccessed: new Date(p.lastAccessed)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading project registry:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save projects to registry
|
||||||
|
export async function saveProjects(projects: RegisteredProject[]): Promise<void> {
|
||||||
|
await ensureRegistry();
|
||||||
|
await fs.writeFile(REGISTRY_PATH, JSON.stringify(projects, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a new project
|
||||||
|
export async function registerProject(
|
||||||
|
name: string,
|
||||||
|
projectPath: string,
|
||||||
|
options?: { autoSpawn?: boolean; description?: string; tags?: string[] }
|
||||||
|
): Promise<RegisteredProject> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
const existing = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
existing.path = projectPath;
|
||||||
|
existing.lastAccessed = new Date();
|
||||||
|
if (options?.autoSpawn !== undefined) existing.autoSpawn = options.autoSpawn;
|
||||||
|
if (options?.description) {
|
||||||
|
existing.metadata = existing.metadata || {};
|
||||||
|
existing.metadata.description = options.description;
|
||||||
|
}
|
||||||
|
if (options?.tags) {
|
||||||
|
existing.metadata = existing.metadata || {};
|
||||||
|
existing.metadata.tags = options.tags;
|
||||||
|
}
|
||||||
|
await saveProjects(projects);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new
|
||||||
|
const newProject: RegisteredProject = {
|
||||||
|
name,
|
||||||
|
path: projectPath,
|
||||||
|
lastAccessed: new Date(),
|
||||||
|
autoSpawn: options?.autoSpawn ?? false,
|
||||||
|
metadata: {
|
||||||
|
description: options?.description,
|
||||||
|
tags: options?.tags
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
projects.push(newProject);
|
||||||
|
await saveProjects(projects);
|
||||||
|
|
||||||
|
console.log(`📝 Registered project: ${name} at ${projectPath}`);
|
||||||
|
return newProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister a project
|
||||||
|
export async function unregisterProject(name: string): Promise<boolean> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
const filtered = projects.filter(p => p.name.toLowerCase() !== name.toLowerCase());
|
||||||
|
|
||||||
|
if (filtered.length === projects.length) {
|
||||||
|
return false; // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveProjects(filtered);
|
||||||
|
console.log(`🗑️ Unregistered project: ${name}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a project by name
|
||||||
|
export async function findProject(name: string): Promise<RegisteredProject | null> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
return projects.find(p => p.name.toLowerCase() === name.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last accessed time
|
||||||
|
export async function touchProject(name: string): Promise<void> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
const project = projects.find(p => p.name.toLowerCase() === name.toLowerCase());
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
project.lastAccessed = new Date();
|
||||||
|
await saveProjects(projects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get projects eligible for auto-spawn
|
||||||
|
export async function getAutoSpawnProjects(): Promise<RegisteredProject[]> {
|
||||||
|
const projects = await loadProjects();
|
||||||
|
return projects.filter(p => p.autoSpawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a project path exists
|
||||||
|
export async function validateProjectPath(projectPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(projectPath);
|
||||||
|
return stats.isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/queue-manager.ts
Normal file
145
src/queue-manager.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Message Queue Manager for InnerVoice
|
||||||
|
// Stores messages for offline/inactive projects
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
const QUEUE_DIR = path.join(process.env.HOME || '~', '.innervoice', 'queues');
|
||||||
|
|
||||||
|
export interface QueuedTask {
|
||||||
|
id: string;
|
||||||
|
projectName: string;
|
||||||
|
projectPath: string;
|
||||||
|
message: string;
|
||||||
|
from: string;
|
||||||
|
timestamp: Date;
|
||||||
|
priority: 'low' | 'normal' | 'high';
|
||||||
|
status: 'pending' | 'delivered' | 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure queue directory exists
|
||||||
|
async function ensureQueueDir(): Promise<void> {
|
||||||
|
if (!existsSync(QUEUE_DIR)) {
|
||||||
|
await fs.mkdir(QUEUE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue file path for a project
|
||||||
|
function getQueuePath(projectName: string): string {
|
||||||
|
const safeName = projectName.replace(/[^a-z0-9-_]/gi, '_').toLowerCase();
|
||||||
|
return path.join(QUEUE_DIR, `${safeName}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load queue for a project
|
||||||
|
export async function loadQueue(projectName: string): Promise<QueuedTask[]> {
|
||||||
|
await ensureQueueDir();
|
||||||
|
const queuePath = getQueuePath(projectName);
|
||||||
|
|
||||||
|
if (!existsSync(queuePath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(queuePath, 'utf-8');
|
||||||
|
const tasks = JSON.parse(content);
|
||||||
|
// Convert timestamp strings back to Date objects
|
||||||
|
return tasks.map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
timestamp: new Date(t.timestamp)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading queue for ${projectName}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save queue for a project
|
||||||
|
export async function saveQueue(projectName: string, tasks: QueuedTask[]): Promise<void> {
|
||||||
|
await ensureQueueDir();
|
||||||
|
const queuePath = getQueuePath(projectName);
|
||||||
|
await fs.writeFile(queuePath, JSON.stringify(tasks, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a task to the queue
|
||||||
|
export async function enqueueTask(task: Omit<QueuedTask, 'id' | 'status'>): Promise<QueuedTask> {
|
||||||
|
const queue = await loadQueue(task.projectName);
|
||||||
|
|
||||||
|
const newTask: QueuedTask = {
|
||||||
|
...task,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
status: 'pending'
|
||||||
|
};
|
||||||
|
|
||||||
|
queue.push(newTask);
|
||||||
|
await saveQueue(task.projectName, queue);
|
||||||
|
|
||||||
|
console.log(`📥 Queued task for ${task.projectName}: ${task.message.substring(0, 50)}...`);
|
||||||
|
return newTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending tasks for a project
|
||||||
|
export async function getPendingTasks(projectName: string): Promise<QueuedTask[]> {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
return queue.filter(t => t.status === 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a task as delivered
|
||||||
|
export async function markTaskDelivered(projectName: string, taskId: string): Promise<void> {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
const task = queue.find(t => t.id === taskId);
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
task.status = 'delivered';
|
||||||
|
await saveQueue(projectName, queue);
|
||||||
|
console.log(`✅ Task delivered: ${taskId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear delivered tasks older than N days
|
||||||
|
export async function cleanupOldTasks(projectName: string, daysOld: number = 7): Promise<number> {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - daysOld);
|
||||||
|
|
||||||
|
const filtered = queue.filter(t => {
|
||||||
|
if (t.status === 'delivered' && t.timestamp < cutoff) {
|
||||||
|
return false; // Remove old delivered tasks
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = queue.length - filtered.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
await saveQueue(projectName, filtered);
|
||||||
|
console.log(`🧹 Cleaned up ${removed} old tasks for ${projectName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all projects with queued tasks
|
||||||
|
export async function listProjectsWithQueues(): Promise<string[]> {
|
||||||
|
await ensureQueueDir();
|
||||||
|
const files = await fs.readdir(QUEUE_DIR);
|
||||||
|
return files
|
||||||
|
.filter(f => f.endsWith('.json'))
|
||||||
|
.map(f => f.replace('.json', '').replace(/_/g, '-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get queue summary for all projects
|
||||||
|
export async function getQueueSummary(): Promise<{ projectName: string; pending: number; total: number }[]> {
|
||||||
|
const projects = await listProjectsWithQueues();
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
projects.map(async (projectName) => {
|
||||||
|
const queue = await loadQueue(projectName);
|
||||||
|
return {
|
||||||
|
projectName,
|
||||||
|
pending: queue.filter(t => t.status === 'pending').length,
|
||||||
|
total: queue.length
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return summaries.filter(s => s.total > 0);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user