ccremote
Version:
Claude Code Remote: approve prompts from Discord, auto-continue sessions after quota resets, and schedule quota windows around your workday.
1,217 lines (1,211 loc) • 47.5 kB
JavaScript
import { promises } from "node:fs";
import { dirname, join } from "node:path";
import { ChannelType, Client, Events, GatewayIntentBits, PermissionFlagsBits } from "discord.js";
import { randomBytes } from "node:crypto";
import { homedir } from "node:os";
import { exec } from "node:child_process";
import { promisify } from "node:util";
//#region src/utils/discord-error-handling.ts
/**
* Check if an error is retryable (network/connection issues)
*/
function isRetryableDiscordError(error) {
const message = error.message.toLowerCase();
const isNetworkError = message.includes("opening handshake has timed out") || message.includes("connection timeout") || message.includes("network error") || message.includes("enotfound") || message.includes("econnreset") || message.includes("econnrefused");
const isRateLimit = message.includes("rate limit") || message.includes("too many requests") || message.includes("429");
return isNetworkError || isRateLimit;
}
/**
* Check if an error is permanent (invalid token, permissions, etc.)
*/
function isPermanentDiscordError(error) {
const message = error.message.toLowerCase();
return message.includes("token") || message.includes("unauthorized") || message.includes("forbidden") || message.includes("invalid") || message.includes("missing access");
}
/**
* Execute a Discord operation with retry logic
*/
async function withDiscordRetry(operation, options = {}) {
const { maxRetries = 3, baseDelayMs = 1e3, maxDelayMs = 3e4, onRetry } = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) try {
return {
success: true,
result: await operation(),
attempts: attempt + 1
};
} catch (error) {
lastError = error;
if (isPermanentDiscordError(lastError)) return {
success: false,
error: lastError,
attempts: attempt + 1
};
if (attempt === maxRetries) break;
if (!isRetryableDiscordError(lastError)) return {
success: false,
error: lastError,
attempts: attempt + 1
};
const delayMs = Math.min(baseDelayMs * 2 ** attempt + Math.random() * 1e3, maxDelayMs);
onRetry?.(lastError, attempt + 1);
await new Promise((resolve$1) => setTimeout(resolve$1, delayMs));
}
return {
success: false,
error: lastError,
attempts: maxRetries + 1
};
}
/**
* Safe wrapper for Discord operations that logs errors but doesn't throw
*/
async function safeDiscordOperation(operation, operationName, logger, retryOptions) {
const result = await withDiscordRetry(operation, {
...retryOptions,
onRetry: (error, attempt) => {
logger.warn?.(`${operationName} failed (attempt ${attempt}): ${error.message}. Retrying...`);
retryOptions?.onRetry?.(error, attempt);
}
});
if (result.success) {
if (result.attempts > 1) logger.debug?.(`${operationName} succeeded after ${result.attempts} attempts`);
return result.result;
}
logger.warn(`${operationName} failed after ${result.attempts} attempts: ${result.error?.message}`);
}
if (import.meta.vitest) {
const { describe, it, expect, vi } = import.meta.vitest;
describe("Discord Error Handling", () => {
describe("isRetryableDiscordError", () => {
it("should identify handshake timeout as retryable", () => {
expect(isRetryableDiscordError(/* @__PURE__ */ new Error("Opening handshake has timed out"))).toBe(true);
});
it("should identify rate limit as retryable", () => {
expect(isRetryableDiscordError(/* @__PURE__ */ new Error("Rate limit exceeded"))).toBe(true);
});
it("should not identify token error as retryable", () => {
expect(isRetryableDiscordError(/* @__PURE__ */ new Error("An invalid token was provided"))).toBe(false);
});
});
describe("isPermanentDiscordError", () => {
it("should identify token errors as permanent", () => {
expect(isPermanentDiscordError(/* @__PURE__ */ new Error("An invalid token was provided"))).toBe(true);
});
it("should identify unauthorized as permanent", () => {
expect(isPermanentDiscordError(/* @__PURE__ */ new Error("Unauthorized"))).toBe(true);
});
it("should not identify network errors as permanent", () => {
expect(isPermanentDiscordError(/* @__PURE__ */ new Error("Opening handshake has timed out"))).toBe(false);
});
});
describe("withDiscordRetry", () => {
it("should succeed on first try", async () => {
const operation = vi.fn().mockResolvedValue("success");
const result = await withDiscordRetry(operation);
expect(result.success).toBe(true);
expect(result.result).toBe("success");
expect(result.attempts).toBe(1);
expect(operation).toHaveBeenCalledTimes(1);
});
it("should retry retryable errors", async () => {
const operation = vi.fn().mockRejectedValueOnce(/* @__PURE__ */ new Error("Opening handshake has timed out")).mockResolvedValue("success");
const result = await withDiscordRetry(operation, {
maxRetries: 2,
baseDelayMs: 1
});
expect(result.success).toBe(true);
expect(result.result).toBe("success");
expect(result.attempts).toBe(2);
expect(operation).toHaveBeenCalledTimes(2);
});
it("should not retry permanent errors", async () => {
const operation = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("An invalid token was provided"));
const result = await withDiscordRetry(operation, { maxRetries: 2 });
expect(result.success).toBe(false);
expect(result.attempts).toBe(1);
expect(operation).toHaveBeenCalledTimes(1);
});
it("should respect max retries", async () => {
const operation = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("Opening handshake has timed out"));
const result = await withDiscordRetry(operation, {
maxRetries: 2,
baseDelayMs: 1
});
expect(result.success).toBe(false);
expect(result.attempts).toBe(3);
expect(operation).toHaveBeenCalledTimes(3);
});
});
describe("safeDiscordOperation", () => {
it("should return result on success", async () => {
const operation = vi.fn().mockResolvedValue("success");
const logger = {
warn: vi.fn(),
debug: vi.fn()
};
expect(await safeDiscordOperation(operation, "test op", logger)).toBe("success");
expect(logger.warn).not.toHaveBeenCalled();
});
it("should return undefined on failure and log warning", async () => {
const operation = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("test error"));
const logger = {
warn: vi.fn(),
debug: vi.fn()
};
expect(await safeDiscordOperation(operation, "test op", logger)).toBe(void 0);
expect(logger.warn).toHaveBeenCalledWith("test op failed after 1 attempts: test error");
});
it("should log retry attempts", async () => {
const operation = vi.fn().mockRejectedValueOnce(/* @__PURE__ */ new Error("Opening handshake has timed out")).mockResolvedValue("success");
const logger = {
warn: vi.fn(),
debug: vi.fn()
};
expect(await safeDiscordOperation(operation, "test op", logger, {
maxRetries: 2,
baseDelayMs: 1
})).toBe("success");
expect(logger.warn).toHaveBeenCalledWith("test op failed (attempt 1): Opening handshake has timed out. Retrying...");
expect(logger.debug).toHaveBeenCalledWith("test op succeeded after 2 attempts");
});
});
});
}
//#endregion
//#region src/core/discord.ts
var DiscordBot = class {
client;
authorizedUsers = [];
ownerId = "";
sessionChannelMap = /* @__PURE__ */ new Map();
channelSessionMap = /* @__PURE__ */ new Map();
guildId = null;
isReady = false;
token = "";
healthCheckInterval = null;
lastHealthCheckTime = /* @__PURE__ */ new Date();
sessionManager;
tmuxManager;
isShuttingDown = false;
readyTimestamp = 0;
constructor(sessionManager, tmuxManager) {
this.sessionManager = sessionManager;
this.tmuxManager = tmuxManager;
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages
],
ws: {
handshakeTimeout: 6e4,
helloTimeout: 12e4,
readyTimeout: 3e4
}
});
this.setupEventHandlers();
this.setupErrorSuppression();
}
/**
* Setup error suppression for expected Discord.js shutdown errors
*/
setupErrorSuppression() {}
async start(token, ownerId, authorizedUsers = [], healthCheckInterval) {
console.info("[DISCORD] start() method called");
console.info(`[DISCORD] Starting Discord bot with owner: ${ownerId}, authorized users: ${authorizedUsers.join(", ")}`);
this.token = token;
this.ownerId = ownerId;
this.authorizedUsers = [ownerId, ...authorizedUsers];
const result = await withDiscordRetry(async () => this.performLogin(token), {
maxRetries: 3,
baseDelayMs: 2e3,
maxDelayMs: 3e4,
onRetry: (error, attempt) => {
console.warn(`[DISCORD] Login attempt ${attempt} failed: ${error.message}. Retrying...`);
}
});
if (!result.success) throw result.error || /* @__PURE__ */ new Error("Discord login failed after retries");
if (result.attempts > 1) console.info(`[DISCORD] Successfully connected after ${result.attempts} attempts`);
this.startHealthCheck(healthCheckInterval);
}
async performLogin(token) {
return new Promise((resolve$1, reject) => {
const timeout = setTimeout(() => {
const error = /* @__PURE__ */ new Error("Opening handshake has timed out");
console.error("[DISCORD] Discord bot login timed out after 30 seconds");
reject(error);
}, 3e4);
this.client.once(Events.ClientReady, () => {
console.info("[DISCORD] ClientReady event fired!");
clearTimeout(timeout);
this.isReady = true;
this.readyTimestamp = Date.now();
const guild = this.client.guilds.cache.first();
if (guild) {
this.guildId = guild.id;
console.info(`[DISCORD] Discord bot logged in as ${this.client.user?.tag} in guild: ${guild.name}`);
} else console.warn("[DISCORD] Discord bot not in any guilds - cannot create channels");
console.info("[DISCORD] Bot startup complete, resolving promise");
resolve$1();
});
this.client.once(Events.Error, (error) => {
console.error("[DISCORD] Discord client error during startup:", error);
clearTimeout(timeout);
reject(error);
});
console.info("[DISCORD] Calling client.login()...");
this.client.login(token).then(() => {
console.info("[DISCORD] client.login() resolved successfully");
}).catch((error) => {
console.error("[DISCORD] Login failed:", error);
clearTimeout(timeout);
reject(error);
});
});
}
setupEventHandlers() {
this.client.on(Events.MessageCreate, (message) => {
if (message.author.bot) return;
this.handleMessage(message);
});
this.client.on(Events.Error, (error) => {
console.error("Discord client error:", error);
});
}
async handleMessage(message) {
if (!this.isAuthorized(message.author.id)) return;
const content = message.content.toLowerCase().trim();
const sessionId = this.channelSessionMap.get(message.channel.id);
if (!sessionId) return;
try {
const numericMatch = content.match(/^(\d+)$/);
if (numericMatch) {
const optionNumber = Number.parseInt(numericMatch[1], 10);
await this.handleOptionSelection(sessionId, optionNumber);
await message.reply(`✅ Selected option ${optionNumber}`);
} else if (content === "approve") {
await this.handleOptionSelection(sessionId, 1);
await message.reply("✅ Approved (option 1)");
} else if (content === "deny") {
await this.handleOptionSelection(sessionId, 2);
await message.reply("❌ Denied (option 2)");
} else if (content === "status") await this.handleStatus(sessionId, message);
else if (content === "output" || content === "/output") await this.handleOutput(sessionId, message);
} catch (error) {
console.error("Error handling Discord message:", error);
await message.reply("❌ Error processing command");
}
}
isAuthorized(userId) {
return this.authorizedUsers.includes(userId);
}
async sendNotification(sessionId, notification) {
if (!this.isReady) {
console.warn("Discord bot not ready, skipping notification");
return;
}
const channelId = this.sessionChannelMap.get(sessionId);
if (!channelId) {
console.warn(`No Discord channel found for session ${sessionId}`);
return;
}
await safeDiscordOperation(async () => {
const channel = await this.client.channels.fetch(channelId);
if (!channel) throw new Error(`Discord channel ${channelId} not found`);
const message = this.formatNotification(notification);
await channel.send(message);
if (notification.type === "session_ended") setTimeout(() => {
this.cleanupSessionChannel(sessionId);
}, 2e3);
}, "send Discord notification", {
warn: console.warn,
debug: console.info
}, {
maxRetries: 2,
baseDelayMs: 1e3
});
}
formatNotification(notification) {
const { type, sessionName, message, metadata } = notification;
switch (type) {
case "limit": return `🚫 **${sessionName}** - Usage limit reached\n📅 Resets: ${metadata?.resetTime || "unknown"}\n\n${message}`;
case "continued": return `✅ **${sessionName}** - Session resumed\n\n${message}`;
case "approval": {
const toolName = metadata?.toolName || "unknown";
const command = metadata?.command || "";
return `⚠️ **${sessionName}** - Approval Required\n🔧 Tool: ${toolName}\n\n${message}\n\n${command ? `\`\`\`${command}\`\`\`` : ""}`;
}
case "error": return `❌ **${sessionName}** - Error\n\n${message}`;
case "session_ended": return `🏁 **${sessionName}** - Session Ended\n\n${message}`;
case "task_completed": return `✅ **${sessionName}** - Task completed\n⏱️ Idle for: ${metadata?.idleDurationSeconds || 0}s\n\n${message}`;
default: return `📝 **${sessionName}**\n\n${message}`;
}
}
async createOrGetChannel(sessionId, sessionName) {
console.info(`[DISCORD] createOrGetChannel called for session: ${sessionId}, name: ${sessionName}`);
const existingChannelId = this.sessionChannelMap.get(sessionId);
if (existingChannelId) {
console.info(`[DISCORD] Found existing channel mapping: ${existingChannelId}`);
try {
if (await this.client.channels.fetch(existingChannelId)) {
console.info(`[DISCORD] Existing channel confirmed, returning: ${existingChannelId}`);
return existingChannelId;
}
} catch {
console.warn(`[DISCORD] Existing channel ${existingChannelId} no longer exists, will create new one`);
}
}
if (!this.guildId) {
console.warn("[DISCORD] No guild available, falling back to DM");
return this.createDMChannel(sessionId, sessionName);
}
const channelName = await this.generateChannelName(sessionName);
console.info(`[DISCORD] Checking for existing channel with name: ${channelName}`);
try {
const guild = await this.client.guilds.fetch(this.guildId);
if (guild) {
const existingChannel = guild.channels.cache.find((channel) => channel.name === channelName && channel.isTextBased() && !channel.name.startsWith("_archived-"));
if (existingChannel) {
console.info(`[DISCORD] Found existing channel by name: ${existingChannel.id} (${channelName})`);
this.sessionChannelMap.set(sessionId, existingChannel.id);
this.channelSessionMap.set(existingChannel.id, sessionId);
if (existingChannel.isTextBased()) await existingChannel.send(`🔄 **ccremote Session Resumed**\nSession: ${sessionName} (${sessionId})\n\nReusing existing channel for this session.`);
return existingChannel.id;
}
}
} catch (error) {
console.warn(`[DISCORD] Error checking for existing channel: ${error}`);
}
console.info(`[DISCORD] Creating new channel in guild: ${this.guildId}`);
const channelId = await safeDiscordOperation(async () => {
console.info(`[DISCORD] Fetching guild: ${this.guildId}`);
const guild = await this.client.guilds.fetch(this.guildId);
if (!guild) throw new Error(`Guild ${this.guildId} not found`);
console.info(`[DISCORD] Guild fetched successfully: ${guild.name}`);
console.info(`[DISCORD] Will create channel with name: ${channelName}`);
console.info(`[DISCORD] Creating channel with bot access ensured...`);
const botMember = guild.members.me;
const initialPermissions = [];
initialPermissions.push({
id: guild.roles.everyone.id,
deny: [PermissionFlagsBits.ViewChannel]
});
if (botMember) initialPermissions.push({
id: botMember.id,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
});
const channel = await guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
permissionOverwrites: initialPermissions
});
console.info(`[DISCORD] Channel created, attempting to add user permissions...`);
let permissionsSet = false;
for (const userId of this.authorizedUsers) try {
console.info(`[DISCORD] Fetching and adding permissions for user: ${userId}`);
const user = await this.client.users.fetch(userId);
await channel.permissionOverwrites.create(user, {
ViewChannel: true,
SendMessages: true,
ReadMessageHistory: true
});
console.info(`[DISCORD] Successfully added permissions for user: ${userId}`);
permissionsSet = true;
} catch (permError) {
console.warn(`Failed to add permissions for user ${userId}:`, permError);
console.warn("This is usually due to Discord permission hierarchy - continuing without user-specific permissions");
}
if (!permissionsSet) console.warn(`[DISCORD] Could not set user-specific permissions - channel will rely on server permissions`);
console.info(`[DISCORD] Channel created with initial permissions (hidden from @everyone, bot has access)`);
this.sessionChannelMap.set(sessionId, channel.id);
this.channelSessionMap.set(channel.id, sessionId);
await channel.send(`🚀 **ccremote Session Started**\nSession: ${sessionName} (${sessionId})\n\nI'll send notifications for this session here. This channel is private and only visible to authorized users.`);
return channel.id;
}, "create Discord channel", {
warn: console.warn,
debug: console.info
}, {
maxRetries: 2,
baseDelayMs: 1500
});
if (channelId) return channelId;
console.warn(`[DISCORD] Failed to create channel in guild, falling back to DM`);
return this.createDMChannel(sessionId, sessionName);
}
async createDMChannel(sessionId, sessionName) {
const channelId = await safeDiscordOperation(async () => {
const dmChannel = await (await this.client.users.fetch(this.ownerId)).createDM();
this.sessionChannelMap.set(sessionId, dmChannel.id);
this.channelSessionMap.set(dmChannel.id, sessionId);
await dmChannel.send(`🚀 **ccremote Session Started**\nSession: ${sessionName} (${sessionId})\n\nI'll send notifications for this session here. (Using DM as fallback - no guild available)`);
return dmChannel.id;
}, "create Discord DM channel", {
warn: console.warn,
debug: console.info
}, {
maxRetries: 2,
baseDelayMs: 1e3
});
if (!channelId) throw new Error("Failed to create DM channel after retries");
return channelId;
}
async assignChannelToSession(sessionId, channelId) {
this.sessionChannelMap.set(sessionId, channelId);
this.channelSessionMap.set(channelId, sessionId);
}
/**
* Generate a Discord channel name for a session
* Matches Discord's naming rules: lowercase, alphanumeric + hyphens only
*/
async generateChannelName(sessionName) {
return `${(await import("node:path")).basename(process.cwd())}-${sessionName}`.toLowerCase().replace(/[^a-z0-9-]/g, "-");
}
/**
* Delete a Discord channel and send a final message before deletion
*/
async deleteChannel(channelId, finalMessage, deleteReason) {
const channel = await this.client.channels.fetch(channelId);
if (!channel || channel.type !== ChannelType.GuildText) return;
const botMember = channel.guild.members.me;
if (botMember) try {
await channel.permissionOverwrites.edit(botMember, {
ViewChannel: true,
SendMessages: true,
ReadMessageHistory: true
});
} catch (permError) {
console.warn(`Failed to ensure bot permissions for channel ${channelId}:`, permError);
}
await safeDiscordOperation(async () => {
await channel.send(finalMessage);
}, "send deletion notification", {
warn: console.warn,
debug: console.info
}, {
maxRetries: 1,
baseDelayMs: 1e3
});
await new Promise((resolve$1) => setTimeout(resolve$1, 2e3));
await channel.delete(deleteReason);
}
async cleanupSessionChannel(sessionId) {
const channelId = this.sessionChannelMap.get(sessionId);
if (!channelId) return;
try {
await this.deleteChannel(channelId, `🏁 Session ${sessionId} ended. This channel will be deleted.`, "Session ended - cleaning up channel");
} catch (error) {
console.error(`Error cleaning up channel for session ${sessionId}:`, error);
} finally {
this.sessionChannelMap.delete(sessionId);
this.channelSessionMap.delete(channelId);
}
}
async handleOptionSelection(sessionId, optionNumber) {
this.client.emit("ccremote:option_selected", {
sessionId,
optionNumber
});
}
async handleStatus(sessionId, message) {
await message.reply(`📊 Session status for ${sessionId} - implementation pending`);
}
async handleOutput(sessionId, message) {
if (!this.sessionManager || !this.tmuxManager) {
await message.reply("❌ Output display not available - missing dependencies");
return;
}
try {
const session = await this.sessionManager.getSession(sessionId);
if (!session) {
await message.reply(`❌ Session ${sessionId} not found`);
return;
}
const output = await this.tmuxManager.capturePane(session.tmuxSession);
if (!output || output.trim().length === 0) {
await message.reply("📺 Session output is empty");
return;
}
const formattedOutput = this.formatOutputForDiscord(output);
for (const chunk of formattedOutput) await message.reply(chunk);
} catch (error) {
console.error("Error fetching session output:", error);
await message.reply("❌ Failed to fetch session output");
}
}
/**
* Format tmux output for Discord display with proper code blocks and chunking
*/
formatOutputForDiscord(output) {
const MAX_CONTENT_LENGTH = 1992;
const cleanedOutput = output.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ").split("\n").slice(-50).join("\n").trim();
if (cleanedOutput.length === 0) return ["```\n(empty output)\n```"];
if (cleanedOutput.length <= MAX_CONTENT_LENGTH) return [`\`\`\`\n${cleanedOutput}\n\`\`\``];
const chunks = [];
const lines = cleanedOutput.split("\n");
let currentChunk = "";
for (const line of lines) {
const lineWithNewline = `${line}\n`;
if (currentChunk.length + lineWithNewline.length > MAX_CONTENT_LENGTH) {
if (currentChunk.trim().length > 0) chunks.push(`\`\`\`\n${currentChunk.trim()}\n\`\`\``);
currentChunk = lineWithNewline;
} else currentChunk += lineWithNewline;
}
if (currentChunk.trim().length > 0) chunks.push(`\`\`\`\n${currentChunk.trim()}\n\`\`\``);
if (chunks.length > 1) return chunks.map((chunk, index) => `📺 **Session Output (${index + 1}/${chunks.length})**\n${chunk}`);
return chunks.length > 0 ? [`📺 **Session Output**\n${chunks[0]}`] : ["```\n(no output to display)\n```"];
}
onOptionSelected(handler) {
this.client.on("ccremote:option_selected", ({ sessionId, optionNumber }) => {
handler(sessionId, optionNumber);
});
}
/**
* Find orphaned ccremote channels that exist but aren't connected to any active session
*
* IMPORTANT: Only examines channels belonging to the current project to avoid
* cross-project interference when multiple projects share the same Discord guild.
*/
async findOrphanedChannels(activeSessions) {
try {
if (!this.client.guilds.cache.size) return [];
const orphanedChannels = [];
const guild = this.client.guilds.cache.first();
if (!guild) return [];
const projectPrefix = `${(await import("node:path")).basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-")}-`;
const expectedChannelNames = new Set(await Promise.all(activeSessions.map(async (session) => this.generateChannelName(session.name))));
const projectChannels = guild.channels.cache.filter((channel) => channel.name.startsWith(projectPrefix) && !channel.name.startsWith("_archived-") && channel.type === ChannelType.GuildText);
console.info(`[DISCORD] Checking ${projectChannels.size} channels with prefix '${projectPrefix}' for orphans`);
for (const [channelId, channel] of projectChannels) {
const mappedSessionId = this.channelSessionMap.get(channelId);
const activeSessionIds = activeSessions.map((s) => s.id);
if (!mappedSessionId && !expectedChannelNames.has(channel.name) || mappedSessionId && !activeSessionIds.includes(mappedSessionId)) {
orphanedChannels.push(channelId);
console.info(`[DISCORD] Found orphaned channel: ${channel.name} (${channelId}) - mapped to: ${mappedSessionId || "none"}, expected names: [${Array.from(expectedChannelNames).join(", ")}]`);
}
}
return orphanedChannels;
} catch (error) {
console.warn("[DISCORD] Error finding orphaned channels:", error);
return [];
}
}
/**
* Find archived channels from previous runs (channels starting with _archived-)
*/
async findArchivedChannels() {
try {
if (!this.client.guilds.cache.size) return [];
const guild = this.client.guilds.cache.first();
if (!guild) return [];
const archivedChannels = guild.channels.cache.filter((channel) => channel.name.startsWith("_archived-") && channel.type === ChannelType.GuildText).map((channel) => channel.id);
return Array.from(archivedChannels);
} catch (error) {
console.warn("[DISCORD] Error finding archived channels:", error);
return [];
}
}
/**
* Delete an orphaned channel by ID
*/
async deleteOrphanedChannel(channelId) {
try {
await this.deleteChannel(channelId, "🏁 Orphaned channel detected during cleanup. This channel will be deleted.", "Orphaned channel cleanup");
this.channelSessionMap.delete(channelId);
for (const [sessionId, mappedChannelId] of this.sessionChannelMap.entries()) if (mappedChannelId === channelId) {
this.sessionChannelMap.delete(sessionId);
break;
}
console.info(`[DISCORD] Deleted orphaned channel`);
return true;
} catch (error) {
console.warn(`[DISCORD] Failed to delete orphaned channel ${channelId}:`, error);
return false;
}
}
async shutdown() {
try {
this.isShuttingDown = true;
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
this.isReady = false;
if (this.client) this.client = null;
} catch (error) {
console.warn("[DISCORD] Error during shutdown:", error);
} finally {
this.isShuttingDown = false;
}
}
/**
* Start periodic health check (every hour by default)
*/
startHealthCheck(intervalMs = 3600 * 1e3) {
console.info(`[DISCORD] Starting health check with ${intervalMs / 6e4} minute interval`);
this.stopHealthCheck();
this.healthCheckInterval = setInterval(() => {
this.performHealthCheck();
}, intervalMs);
this.lastHealthCheckTime = /* @__PURE__ */ new Date();
}
/**
* Stop the periodic health check
*/
stopHealthCheck() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
}
/**
* Perform a health check and attempt reconnection if needed
*/
async performHealthCheck() {
try {
this.lastHealthCheckTime = /* @__PURE__ */ new Date();
if (!this.isHealthy()) {
console.warn("[DISCORD] Health check failed - bot appears disconnected");
await this.attemptReconnection();
} else console.info("[DISCORD] Health check passed - bot is connected");
} catch (error) {
console.error(`[DISCORD] Health check error: ${error instanceof Error ? error.message : String(error)}`);
await this.attemptReconnection();
}
}
/**
* Check if Discord bot is healthy (connected and ready)
*/
isHealthy() {
return this.isReady && this.client && this.client.readyTimestamp !== null && this.client.ws.status === 0;
}
/**
* Attempt to reconnect Discord bot
*/
async attemptReconnection() {
console.info("[DISCORD] Attempting to reconnect...");
try {
this.isReady = false;
if (this.client && typeof this.client.destroy === "function") {
console.info("[DISCORD] Destroying existing client");
await this.client.destroy();
}
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages
],
ws: {
handshakeTimeout: 6e4,
helloTimeout: 12e4,
readyTimeout: 3e4
}
});
this.setupEventHandlers();
if ((await withDiscordRetry(async () => this.performLogin(this.token), {
maxRetries: 3,
baseDelayMs: 2e3,
maxDelayMs: 3e4,
onRetry: (error, attempt) => {
console.warn(`[DISCORD] Reconnection attempt ${attempt} failed: ${error.message}. Retrying...`);
}
})).success) console.info("[DISCORD] Successfully reconnected to Discord");
else console.error("[DISCORD] Failed to reconnect after all retries");
} catch (error) {
console.error(`[DISCORD] Reconnection failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async stop() {
await this.shutdown();
}
};
if (import.meta.vitest) {
const { beforeEach, describe, it, expect, vi } = await import("./dist-bz1tWxsS.js");
describe("DiscordBot", () => {
let discordBot;
let mockSessionManager;
let mockTmuxManager;
let mockMessage;
beforeEach(() => {
mockSessionManager = { getSession: vi.fn() };
mockTmuxManager = { capturePane: vi.fn() };
mockMessage = {
reply: vi.fn(),
author: { id: "test-user" },
channel: { id: "test-channel" }
};
discordBot = new DiscordBot(mockSessionManager, mockTmuxManager);
discordBot.sessionManager = mockSessionManager;
discordBot.tmuxManager = mockTmuxManager;
discordBot.channelSessionMap.set("test-channel", "test-session");
discordBot.authorizedUsers = ["test-user"];
});
describe("formatOutputForDiscord", () => {
it("should handle empty output", () => {
expect(discordBot.formatOutputForDiscord("")).toEqual(["```\n(empty output)\n```"]);
});
it("should handle short output within message limit", () => {
const result = discordBot.formatOutputForDiscord("Hello world\nThis is a test");
expect(result).toHaveLength(1);
expect(result[0]).toBe("```\nHello world\nThis is a test\n```");
});
it("should clean and normalize output correctly", () => {
expect(discordBot.formatOutputForDiscord("Line 1\r\nLine 2\rLine 3 Tabbed content")[0]).toContain("Line 1\nLine 2\nLine 3 Tabbed content");
});
it("should limit to last 50 lines for long output", () => {
const longOutput = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`).join("\n");
const content = discordBot.formatOutputForDiscord(longOutput)[0];
expect(content).toContain("Line 51");
expect(content).toContain("Line 100");
expect(content).not.toContain("Line 50");
});
it("should split long output into multiple chunks", () => {
const longLine = "A".repeat(1900);
const longOutput = `${longLine}\n${longLine}\n${longLine}`;
const result = discordBot.formatOutputForDiscord(longOutput);
expect(result.length).toBeGreaterThan(1);
expect(result[0]).toContain("📺 **Session Output (1/");
expect(result[1]).toContain("📺 **Session Output (2/");
});
it("should properly wrap chunks in code blocks", () => {
expect(discordBot.formatOutputForDiscord("Some test output")[0]).toMatch(/```\n[\s\S]*\n```/);
});
});
describe("handleOutput", () => {
it("should reply with error when dependencies are missing", async () => {
await new DiscordBot().handleOutput("test-session", mockMessage);
expect(mockMessage.reply).toHaveBeenCalledWith("❌ Output display not available - missing dependencies");
});
it("should reply with error when session not found", async () => {
mockSessionManager.getSession = vi.fn().mockResolvedValue(null);
await discordBot.handleOutput("test-session", mockMessage);
expect(mockMessage.reply).toHaveBeenCalledWith("❌ Session test-session not found");
});
it("should reply when output is empty", async () => {
mockSessionManager.getSession = vi.fn().mockResolvedValue({
id: "test-session",
tmuxSession: "test-tmux"
});
mockTmuxManager.capturePane = vi.fn().mockResolvedValue("");
await discordBot.handleOutput("test-session", mockMessage);
expect(mockMessage.reply).toHaveBeenCalledWith("📺 Session output is empty");
});
it("should send formatted output when successful", async () => {
const session = {
id: "test-session",
tmuxSession: "test-tmux"
};
const tmuxOutput = "Test output\nAnother line";
mockSessionManager.getSession = vi.fn().mockResolvedValue(session);
mockTmuxManager.capturePane = vi.fn().mockResolvedValue(tmuxOutput);
await discordBot.handleOutput("test-session", mockMessage);
expect(mockMessage.reply).toHaveBeenCalledWith("```\nTest output\nAnother line\n```");
});
it("should send multiple chunks for long output", async () => {
const session = {
id: "test-session",
tmuxSession: "test-tmux"
};
const longOutput = `${"A".repeat(1900)}\n${"B".repeat(1900)}\n${"C".repeat(1900)}`;
mockSessionManager.getSession = vi.fn().mockResolvedValue(session);
mockTmuxManager.capturePane = vi.fn().mockResolvedValue(longOutput);
await discordBot.handleOutput("test-session", mockMessage);
expect(mockMessage.reply.mock.calls.length).toBeGreaterThan(1);
const calls = mockMessage.reply.mock.calls;
expect(calls[0][0]).toContain("(1/");
expect(calls[1][0]).toContain("(2/");
});
it("should handle errors gracefully", async () => {
mockSessionManager.getSession = vi.fn().mockResolvedValue({
id: "test-session",
tmuxSession: "test-tmux"
});
mockTmuxManager.capturePane = vi.fn().mockRejectedValue(/* @__PURE__ */ new Error("Tmux error"));
await discordBot.handleOutput("test-session", mockMessage);
expect(mockMessage.reply).toHaveBeenCalledWith("❌ Failed to fetch session output");
});
});
describe("findOrphanedChannels", () => {
it("should return empty array when no guilds", async () => {
discordBot.client = { guilds: { cache: { size: 0 } } };
expect(await discordBot.findOrphanedChannels([])).toEqual([]);
});
it("should identify orphaned channels correctly", async () => {
const mockChannelCache = new Map([["channel-1", {
id: "channel-1",
name: "ccremote-session-1",
type: 0
}], ["channel-2", {
id: "channel-2",
name: "ccremote-session-2",
type: 0
}]]);
mockChannelCache.filter = vi.fn().mockReturnValue(mockChannelCache);
const mockGuild = { channels: { cache: mockChannelCache } };
discordBot.client = { guilds: { cache: {
size: 1,
first: () => mockGuild
} } };
const result = await discordBot.findOrphanedChannels([{
id: "ccremote-1",
name: "session-1"
}]);
expect(result).toContain("channel-2");
expect(result).not.toContain("channel-1");
});
it("should handle errors gracefully", async () => {
discordBot.client = { guilds: { cache: {
size: 1,
first: () => {
throw new Error("Guild error");
}
} } };
expect(await discordBot.findOrphanedChannels([])).toEqual([]);
});
});
});
}
//#endregion
//#region src/utils/quota.ts
/**
* Utility functions for quota scheduling
*/
/**
* Generate the quota message for a given execution time
*/
function generateQuotaMessage(executeAt) {
return `🕕 This message will be sent at ${executeAt.toLocaleString()} to ensure the quota window starts at that time.`;
}
//#endregion
//#region src/core/session.ts
var SessionManager = class {
globalConfigDir;
sessionsFile;
sessions = /* @__PURE__ */ new Map();
lockFile;
writeLock = Promise.resolve();
constructor() {
this.globalConfigDir = join(homedir(), ".ccremote");
this.sessionsFile = join(this.globalConfigDir, "sessions.json");
this.lockFile = join(this.globalConfigDir, "sessions.lock");
}
async initialize() {
await this.ensureConfigDir();
await this.loadSessions();
}
async ensureConfigDir() {
try {
await promises.access(this.globalConfigDir);
} catch {
await promises.mkdir(this.globalConfigDir, { recursive: true });
}
}
async loadSessions() {
await this.withWriteLock(async () => {
try {
const data = await promises.readFile(this.sessionsFile, "utf-8");
const sessionData = JSON.parse(data);
this.sessions.clear();
let needsMigration = false;
for (const [id, sessionRaw] of Object.entries(sessionData)) {
const session = sessionRaw;
if (!session.projectPath || !session.workingDirectory) {
session.projectPath = session.projectPath || "/Users/fredrik.wollsen/Dev/ccremote";
session.workingDirectory = session.workingDirectory || "/Users/fredrik.wollsen/Dev/ccremote";
needsMigration = true;
}
this.sessions.set(id, session);
}
if (needsMigration) await this.writeSessionsToFile();
} catch {
this.sessions.clear();
}
});
}
async saveSessions() {
await this.withWriteLock(async () => {
await this.writeSessionsToFile();
});
}
async writeSessionsToFile() {
const sessionData = {};
for (const [id, session] of this.sessions) sessionData[id] = session;
const dir = dirname(this.sessionsFile);
await promises.mkdir(dir, { recursive: true });
const tempFile = `${this.sessionsFile}.tmp.${randomBytes(4).toString("hex")}`;
await promises.writeFile(tempFile, JSON.stringify(sessionData, null, 2));
await promises.rename(tempFile, this.sessionsFile);
}
async loadSessionsFromDisk() {
try {
const data = await promises.readFile(this.sessionsFile, "utf-8");
const sessionData = JSON.parse(data);
this.sessions.clear();
for (const [id, session] of Object.entries(sessionData)) this.sessions.set(id, session);
} catch {}
}
async mergeFromDisk() {
try {
const data = await promises.readFile(this.sessionsFile, "utf-8");
const sessionData = JSON.parse(data);
for (const [id, diskSession] of Object.entries(sessionData)) {
const currentSession = this.sessions.get(id);
if (currentSession) {
const diskState = diskSession;
if (new Date(diskState.lastActivity) > new Date(currentSession.lastActivity)) this.sessions.set(id, diskState);
} else this.sessions.set(id, diskSession);
}
} catch {}
}
async withWriteLock(fn) {
const currentLock = this.writeLock;
let resolve$1;
let reject;
this.writeLock = new Promise((res, rej) => {
currentLock.then(async () => {
try {
const result = await fn();
resolve$1(result);
res();
} catch (error) {
reject(error);
rej(error);
}
}).catch(rej);
});
return new Promise((res, rej) => {
resolve$1 = res;
reject = rej;
});
}
async createSession(name, channelId) {
const sessionId = this.generateSessionId();
const session = {
id: sessionId,
name: name || `session-${sessionId.split("-")[1]}`,
tmuxSession: sessionId,
channelId: channelId || "",
status: "active",
created: (/* @__PURE__ */ new Date()).toISOString(),
lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
projectPath: process.cwd(),
workingDirectory: process.cwd()
};
this.sessions.set(sessionId, session);
await this.saveSessions();
return session;
}
async listSessions() {
return Array.from(this.sessions.values());
}
async listSessionsForProject(projectPath) {
const targetPath = projectPath || process.cwd();
return Array.from(this.sessions.values()).filter((session) => session.projectPath === targetPath);
}
async getSession(id) {
return this.sessions.get(id) || null;
}
async getSessionByName(name) {
for (const session of this.sessions.values()) if (session.name === name) return session;
return null;
}
async updateSession(id, updates) {
await this.withWriteLock(async () => {
await this.mergeFromDisk();
const session = this.sessions.get(id);
if (!session) throw new Error(`Session not found: ${id}`);
Object.assign(session, updates, { lastActivity: (/* @__PURE__ */ new Date()).toISOString() });
await this.writeSessionsToFile();
});
}
async deleteSession(id) {
await this.withWriteLock(async () => {
await this.mergeFromDisk();
if (!this.sessions.has(id)) throw new Error(`Session not found: ${id}`);
this.sessions.delete(id);
await this.writeSessionsToFile();
});
}
generateSessionId() {
const existingNumbers = Array.from(this.sessions.keys()).map((id) => {
const match = id.match(/^ccremote-(\d+)$/);
return match ? Number.parseInt(match[1], 10) : 0;
}).filter((n) => n > 0);
return `ccremote-${existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1}`;
}
};
if (import.meta.vitest) {
const { beforeEach, afterEach, describe, it, expect } = await import("./dist-bz1tWxsS.js");
describe("SessionManager", () => {
const testDir = ".ccremote-test";
beforeEach(async () => {
try {
await promises.rm(testDir, {
recursive: true,
force: true
});
} catch {}
});
afterEach(async () => {
try {
await promises.rm(testDir, {
recursive: true,
force: true
});
} catch {}
});
it("should create sessions with auto-generated IDs", async () => {
const sessionManager = new SessionManager();
sessionManager.sessionsFile = `${testDir}/sessions.json`;
await sessionManager.initialize();
const session1 = await sessionManager.createSession();
expect(session1.id).toBe("ccremote-1");
expect(session1.name).toBe("session-1");
const session2 = await sessionManager.createSession("my-session");
expect(session2.id).toBe("ccremote-2");
expect(session2.name).toBe("my-session");
});
it("should persist sessions across instances", async () => {
const sessionManager1 = new SessionManager();
sessionManager1.sessionsFile = `${testDir}/sessions.json`;
await sessionManager1.initialize();
await sessionManager1.createSession("test-persistence");
const sessionManager2 = new SessionManager();
sessionManager2.sessionsFile = `${testDir}/sessions.json`;
await sessionManager2.initialize();
const testSession = (await sessionManager2.listSessions()).find((s) => s.name === "test-persistence");
expect(testSession).toBeDefined();
expect(testSession?.id).toBe("ccremote-1");
});
it("should handle CRUD operations correctly", async () => {
const sessionManager = new SessionManager();
sessionManager.sessionsFile = `${testDir}/sessions.json`;
await sessionManager.initialize();
const session = await sessionManager.createSession("crud-test");
expect(session.status).toBe("active");
expect((await sessionManager.getSession(session.id))?.name).toBe("crud-test");
expect((await sessionManager.getSessionByName("crud-test"))?.id).toBe(session.id);
await sessionManager.updateSession(session.id, {
status: "waiting",
channelId: "12345"
});
const updated = await sessionManager.getSession(session.id);
expect(updated?.status).toBe("waiting");
expect(updated?.channelId).toBe("12345");
await sessionManager.deleteSession(session.id);
expect(await sessionManager.getSession(session.id)).toBe(null);
});
});
}
//#endregion
//#region src/core/tmux.ts
const execAsync = promisify(exec);
var TmuxManager = class {
async isTmuxAvailable() {
try {
await execAsync("tmux -V");
return true;
} catch {
return false;
}
}
async createSession(sessionName) {
try {
const ccremoteConfig = `${process.env.HOME}/.ccremote/tmux.conf`;
const hasConfig = (await import("node:fs")).existsSync(ccremoteConfig);
await execAsync(hasConfig ? `tmux new-session -d -s "${sessionName}" -c "${process.cwd()}"` : `tmux new-session -d -s "${sessionName}" -c "${process.cwd()}" \\; set -g mouse on`);
if (hasConfig) await execAsync(`tmux source-file "${ccremoteConfig}"`);
await execAsync(`tmux send-keys -t "${sessionName}" "claude" Enter`);
} catch (error) {
throw new Error(`Failed to create tmux session: ${error instanceof Error ? error.message : error}`);
}
}
async capturePane(sessionName) {
try {
const { stdout } = await execAsync(`tmux capture-pane -t "${sessionName}" -p`);
return stdout;
} catch (error) {
throw new Error(`Failed to capture tmux pane: ${error instanceof Error ? error.message : error}`);
}
}
async capturePaneWithColors(sessionName) {
try {
const { stdout } = await execAsync(`tmux capture-pane -t "${sessionName}" -p -e`);
return stdout;
} catch (error) {
throw new Error(`Failed to capture tmux pane with colors: ${error instanceof Error ? error.message : error}`);
}
}
async sendKeys(sessionName, keys) {
try {
await execAsync(`tmux send-keys -t "${sessionName}" "${keys}" Enter`);
} catch (error) {
throw new Error(`Failed to send keys to tmux: ${error instanceof Error ? error.message : error}`);
}
}
async sendRawKeys(sessionName, keys) {
try {
await execAsync(`tmux send-keys -t "${sessionName}" "${keys}"`);
} catch (error) {
throw new Error(`Failed to send raw keys to tmux: ${error instanceof Error ? error.message : error}`);
}
}
async clearInput(sessionName) {
try {
await execAsync(`tmux send-keys -t "${sessionName}" C-u`);
} catch (error) {
throw new Error(`Failed to clear tmux input: ${error instanceof Error ? error.message : error}`);
}
}
async sessionExists(sessionName) {
try {
const command = `tmux has-session -t "${sessionName}"`;
await Promise.race([execAsync(command), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Timeout")), 5e3))]);
return true;
} catch {
return false;
}
}
async killSession(sessionName) {
try {
await execAsync(`tmux kill-session -t "${sessionName}"`);
} catch (error) {
if (!error || !String(error).includes("session not found")) throw new Error(`Failed to kill tmux session: ${error instanceof Error ? error.message : error}`);
}
}
async listSessions() {
try {
const { stdout } = await execAsync("tmux list-sessions -F \"#{session_name},#{session_created},#{session_windows}\"");
return stdout.trim().split("\n").filter((line) => line.length > 0).map((line) => {
const [name, created, windows] = line.split(",");
return {
name,
created: (/* @__PURE__ */ new Date(Number(created) * 1e3)).toISOString(),
windows: Number.parseInt(windows, 10)
};
});
} catch {
return [];
}
}
async sendContinueCommand(sessionName) {
await this.clearInput(sessionName);
await new Promise((resolve$1) => setTimeout(resolve$1, 200));
await this.sendRawKeys(sessionName, "continue");
await new Promise((resolve$1) => setTimeout(resolve$1, 200));
await this.sendRawKeys(sessionName, "Enter");
}
async sendOptionSelection(sessionName, optionNumber) {
const response = String(optionNumber);
await this.sendRawKeys(sessionName, response);
}
};
//#endregion
export { DiscordBot as i, SessionManager as n, generateQuotaMessage as r, TmuxManager as t };