feat(phase1): add project context and session tracking
Phase 1 Complete: Multi-project session management with context display Session Tracking: - Auto-generate unique session IDs for each Claude instance - Track project name and path from working directory - Register sessions automatically on MCP server startup - Expire inactive sessions after 30 minutes - Session heartbeat to track activity Project Context in Messages: - All notifications now show: 📁 ProjectName [#shortId] - Session ID automatically included in all notify calls - Project context helps identify which Claude sent the message - Short session ID (first 7 chars) for easy reference New API Endpoints: - POST /session/register - Register/update a Claude session - POST /session/heartbeat - Update session activity - GET /sessions - List all active sessions with idle times Telegram Bot Commands: - /sessions - View all active Claude sessions - Shows project name, session ID, and idle time - Status indicators: 🟢 active, 🟡 idle - Updated /help with new multi-project features MCP Server Changes: - getSessionId() - Generate/retrieve session ID - getProjectInfo() - Extract project name from cwd - registerSession() - Auto-register on startup - Include sessionId in all notify API calls This enables working with multiple Claude instances across different projects simultaneously, with clear visual indication of which project each message comes from. Next: Phase 2 (message queuing) and Phase 3 (remote spawner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
136
src/index.ts
136
src/index.ts
@@ -15,16 +15,45 @@ 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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
@@ -77,12 +106,15 @@ bot.command('help', async (ctx) => {
|
|||||||
'`/start` - Initialize and connect\n' +
|
'`/start` - Initialize and connect\n' +
|
||||||
'`/help` - Show this help message\n' +
|
'`/help` - Show this help message\n' +
|
||||||
'`/status` - Check bridge status\n' +
|
'`/status` - Check bridge status\n' +
|
||||||
|
'`/sessions` - List active Claude sessions\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 me any message - I forward it to Claude\n' +
|
||||||
'• Claude processes it and replies back\n' +
|
'• Claude processes it and replies back\n' +
|
||||||
'• When Claude asks a question, your next message answers it\n\n' +
|
'• When Claude asks a question, your next message answers it\n' +
|
||||||
|
'• Messages show project context: 📁 ProjectName [#abc1234]\n\n' +
|
||||||
'*Features:*\n' +
|
'*Features:*\n' +
|
||||||
|
'✅ Multi-project session tracking\n' +
|
||||||
'✅ Two-way communication\n' +
|
'✅ Two-way communication\n' +
|
||||||
'✅ Question/Answer flow\n' +
|
'✅ Question/Answer flow\n' +
|
||||||
'✅ Progress notifications\n' +
|
'✅ Progress notifications\n' +
|
||||||
@@ -96,6 +128,27 @@ 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('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_Reply with #sessionId to send a message to a specific session_`,
|
||||||
|
{ parse_mode: 'Markdown' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -128,6 +181,70 @@ bot.on('text', async (ctx) => {
|
|||||||
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 });
|
||||||
|
});
|
||||||
|
|
||||||
// HTTP endpoint for sending notifications
|
// HTTP endpoint for sending notifications
|
||||||
app.post('/notify', async (req, res) => {
|
app.post('/notify', async (req, res) => {
|
||||||
if (!ENABLED) {
|
if (!ENABLED) {
|
||||||
@@ -140,7 +257,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 +273,20 @@ 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -208,7 +252,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) {
|
||||||
@@ -395,6 +443,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');
|
||||||
|
|||||||
Reference in New Issue
Block a user