From bdfe51fedf7f4240c8cf6ecf2cca651b007843c6 Mon Sep 17 00:00:00 2001 From: RichardDillman Date: Sun, 23 Nov 2025 17:33:30 -0500 Subject: [PATCH] feat(phase3): add remote Claude spawner and project registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 adds the ability to spawn Claude Code instances remotely from Telegram in registered projects. New Features: - Project registry system (~/.innervoice/projects.json) - Remote Claude spawner with process management - Auto-spawn capability for projects - Full CRUD operations for projects via Telegram Telegram Bot Commands: - /projects - List all registered projects - /register ProjectName /path [--auto-spawn] - /unregister ProjectName - /spawn ProjectName [initial prompt] - /spawned - List running spawned processes - /kill ProjectName API Endpoints: - GET /projects - List registered projects - POST /projects/register - Register new project - DELETE /projects/:name - Unregister project - GET /projects/:name - Get project details - POST /spawn - Spawn Claude in project - POST /kill/:projectName - Kill spawned process - GET /spawned - List all spawned processes - GET /spawned/:projectName - Check if project running Files Added: - src/project-registry.ts - Project registry management - src/claude-spawner.ts - Claude process spawning Files Modified: - src/index.ts - Added spawner integration, bot commands, API endpoints πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/claude-spawner.ts | 147 ++++++++++++++++++ src/index.ts | 335 ++++++++++++++++++++++++++++++++++++++-- src/project-registry.ts | 148 ++++++++++++++++++ 3 files changed, 616 insertions(+), 14 deletions(-) create mode 100644 src/claude-spawner.ts create mode 100644 src/project-registry.ts diff --git a/src/claude-spawner.ts b/src/claude-spawner.ts new file mode 100644 index 0000000..f9b8672 --- /dev/null +++ b/src/claude-spawner.ts @@ -0,0 +1,147 @@ +// Claude Spawner for InnerVoice +// Spawns Claude Code instances remotely from Telegram + +import { spawn, ChildProcess } from 'child_process'; +import { findProject, touchProject } from './project-registry.js'; + +interface SpawnedProcess { + projectName: string; + process: ChildProcess; + startTime: Date; + initialPrompt?: string; +} + +const activeProcesses = new Map(); + +// Spawn Claude in a project +export async function spawnClaude( + projectName: string, + initialPrompt?: string +): 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 { + // Spawn Claude Code + const claudeProcess = spawn('claude', initialPrompt ? [initialPrompt] : [], { + cwd: project.path, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + env: { + ...process.env, + INNERVOICE_SPAWNED: '1' // Mark as spawned by InnerVoice + } + }); + + // Store process + activeProcesses.set(projectName, { + projectName, + process: claudeProcess, + startTime: new Date(), + initialPrompt + }); + + // Update last accessed + await touchProject(projectName); + + // Log output (for debugging) + if (claudeProcess.stdout) { + claudeProcess.stdout.on('data', (data) => { + console.log(`[${projectName}] ${data.toString().trim()}`); + }); + } + + if (claudeProcess.stderr) { + claudeProcess.stderr.on('data', (data) => { + console.error(`[${projectName}] ${data.toString().trim()}`); + }); + } + + // 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); +} diff --git a/src/index.ts b/src/index.ts index a6255d4..7079190 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,19 @@ import { 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'; dotenv.config(); @@ -109,23 +122,25 @@ bot.command('status', async (ctx) => { bot.command('help', async (ctx) => { await ctx.reply( '*Claude Telegram Bridge - Commands*\n\n' + - '*Bot Commands:*\n' + - '`/start` - Initialize and connect\n' + - '`/help` - Show this help message\n' + - '`/status` - Check bridge status\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' + '`/test` - Send test notification\n\n' + '*How it works:*\n' + - 'β€’ Send me any message - I forward it to Claude\n' + - 'β€’ Claude processes it and replies back\n' + - 'β€’ When Claude asks a question, your next message answers it\n' + - 'β€’ Messages show project context: πŸ“ ProjectName [#abc1234]\n\n' + - '*Features:*\n' + - 'βœ… Multi-project session tracking\n' + - 'βœ… Two-way communication\n' + - 'βœ… Question/Answer flow\n' + - 'βœ… Progress notifications\n' + - 'βœ… Error alerts\n\n' + + 'β€’ Send any message - forwards to active Claude\n' + + 'β€’ Target specific project: `ProjectName: message`\n' + + 'β€’ Messages show context: πŸ“ ProjectName [#abc1234]\n' + + 'β€’ Register projects for remote spawning\n' + + 'β€’ Messages queue when projects are offline\n\n' + 'More info: See README in bridge folder', { parse_mode: 'Markdown' } ); @@ -178,6 +193,178 @@ bot.command('queue', async (ctx) => { } }); +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' }); + + const result = await spawnClaude(projectName, initialPrompt); + + 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 bot.on('text', async (ctx) => { const message = ctx.message.text; @@ -545,6 +732,126 @@ 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 { + const result = await spawnClaude(projectName, initialPrompt); + 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 bot.launch().then(() => { console.log('πŸ€– Telegram bot started'); diff --git a/src/project-registry.ts b/src/project-registry.ts new file mode 100644 index 0000000..79cbdf5 --- /dev/null +++ b/src/project-registry.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const projects = await loadProjects(); + return projects.filter(p => p.autoSpawn); +} + +// Check if a project path exists +export async function validateProjectPath(projectPath: string): Promise { + try { + const stats = await fs.stat(projectPath); + return stats.isDirectory(); + } catch { + return false; + } +}