feat(phase3): add remote Claude spawner and project registry
Phase 3 adds the ability to spawn Claude Code instances remotely from Telegram in registered projects. New Features: - Project registry system (~/.innervoice/projects.json) - Remote Claude spawner with process management - Auto-spawn capability for projects - Full CRUD operations for projects via Telegram Telegram Bot Commands: - /projects - List all registered projects - /register ProjectName /path [--auto-spawn] - /unregister ProjectName - /spawn ProjectName [initial prompt] - /spawned - List running spawned processes - /kill ProjectName API Endpoints: - GET /projects - List registered projects - POST /projects/register - Register new project - DELETE /projects/:name - Unregister project - GET /projects/:name - Get project details - POST /spawn - Spawn Claude in project - POST /kill/:projectName - Kill spawned process - GET /spawned - List all spawned processes - GET /spawned/:projectName - Check if project running Files Added: - src/project-registry.ts - Project registry management - src/claude-spawner.ts - Claude process spawning Files Modified: - src/index.ts - Added spawner integration, bot commands, API endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
335
src/index.ts
335
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');
|
||||
|
||||
Reference in New Issue
Block a user