UNPKG

ccremote

Version:

Claude Code Remote: approve prompts from Discord, auto-continue sessions after quota resets, and schedule quota windows around your workday.

1,066 lines (1,063 loc) 70.2 kB
#!/usr/bin/env node import { i as DiscordBot, n as SessionManager, r as generateQuotaMessage, t as TmuxManager } from "./tmux-D_WriM83.js"; import { promises } from "node:fs"; import { dirname } from "node:path"; import { EventEmitter } from "node:events"; import process$1 from "node:process"; import { consola as consola$1 } from "consola"; //#region src/core/logger.ts const packageName = "ccremote"; /** * Application logger instance with package name tag */ const logger = consola$1.withTag(packageName); if (process$1.env.LOG_LEVEL != null) { const level = Number.parseInt(process$1.env.LOG_LEVEL, 10); if (!Number.isNaN(level)) logger.level = level; } //#endregion //#region src/core/monitor.ts var Monitor = class extends EventEmitter { sessionManager; tmuxManager; discordBot; options; monitoringIntervals = /* @__PURE__ */ new Map(); sessionStates = /* @__PURE__ */ new Map(); ANSI_ESCAPE = "\x1B["; patterns = { approvalDialog: { question: /Do you want to (?:make this edit to|create|proceed)/i, numberedOptions: /\b\d+\.\s+Yes/, currentSelection: /❯/ }, resetTime: /(\d{1,2}(?::\d{2})?(?:am|pm))/i, taskCompletion: { waitingForInput: /^>\s*$/m, taskFinished: /(?:completed|finished|done|ready)/i, notProcessing: /^(?!.*(?:processing|analyzing|running|executing|working)).*$/im } }; constructor(sessionManager, tmuxManager, discordBot, options = {}) { super(); this.sessionManager = sessionManager; this.tmuxManager = tmuxManager; this.discordBot = discordBot; this.options = { pollInterval: options.pollInterval || 2e3, maxRetries: options.maxRetries || 3, autoRestart: options.autoRestart || true }; } /** * Safely send Discord notification with error handling */ async safeNotifyDiscord(sessionId, notification) { if (!this.discordBot) { logger.debug("Discord bot not available, skipping notification"); return; } try { await this.discordBot.sendNotification(sessionId, notification); } catch (error) { logger.warn(`Failed to send Discord notification after retries: ${error instanceof Error ? error.message : String(error)}`); } } async startMonitoring(sessionId) { if (!await this.sessionManager.getSession(sessionId)) throw new Error(`Session not found: ${sessionId}`); this.sessionStates.set(sessionId, { lastOutput: "", awaitingContinuation: false, retryCount: 0, lastContinuationTime: void 0, scheduledResetTime: void 0, immediateContinueAttempted: false, quotaCommandSent: false, lastOutputChangeTime: /* @__PURE__ */ new Date(), lastTaskCompletionNotification: void 0 }); const interval = setInterval(() => { this.pollSession(sessionId); }, this.options.pollInterval); this.monitoringIntervals.set(sessionId, interval); logger.info(`Started monitoring session: ${sessionId}`); } async stopMonitoring(sessionId) { const interval = this.monitoringIntervals.get(sessionId); if (interval) { clearInterval(interval); this.monitoringIntervals.delete(sessionId); } this.sessionStates.delete(sessionId); logger.info(`Stopped monitoring session: ${sessionId}`); } async pollSession(sessionId) { try { const session = await this.sessionManager.getSession(sessionId); if (!session) { logger.warn(`Session ${sessionId} not found, stopping monitoring`); await this.stopMonitoring(sessionId); return; } if (!await this.tmuxManager.sessionExists(session.tmuxSession)) { logger.info(`Tmux session ${session.tmuxSession} no longer exists`); await this.handleSessionEnded(sessionId); return; } const sessionState = this.sessionStates.get(sessionId); if (sessionState?.scheduledResetTime) { if (/* @__PURE__ */ new Date() >= sessionState.scheduledResetTime) { logger.info(`Scheduled reset time arrived, executing continuation for session ${sessionId}`); sessionState.scheduledResetTime = void 0; await this.performAutoContinuation(sessionId); return; } } if (session.quotaSchedule) { const sessionState$1 = this.sessionStates.get(sessionId); const now = /* @__PURE__ */ new Date(); const nextExecution = new Date(session.quotaSchedule.nextExecution); if (!sessionState$1?.quotaCommandSent) { if (now.getTime() - new Date(session.created).getTime() > 5e3) { logger.info(`Staging quota command for session ${sessionId} to display in terminal`); await this.tmuxManager.sendRawKeys(session.tmuxSession, session.quotaSchedule.command); if (sessionState$1) sessionState$1.quotaCommandSent = true; } } if (now >= nextExecution && sessionState$1?.quotaCommandSent) { logger.info(`Quota schedule time arrived, executing staged command for session ${sessionId}`); await this.executeQuotaSchedule(sessionId, session.quotaSchedule); return; } } const currentOutput = await this.tmuxManager.capturePane(session.tmuxSession); await this.analyzeOutput(sessionId, currentOutput); } catch (error) { logger.error(`Error polling session ${sessionId}: ${error}`); await this.handlePollingError(sessionId, error); } } async analyzeOutput(sessionId, output) { const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; if (output !== sessionState.lastOutput) { sessionState.lastOutputChangeTime = /* @__PURE__ */ new Date(); const newOutput = this.getNewOutput(sessionState.lastOutput, output); sessionState.lastOutput = output; await this.detectPatterns(sessionId, newOutput); } await this.checkTaskCompletion(sessionId, output); } getNewOutput(lastOutput, currentOutput) { if (!lastOutput) return currentOutput; if (currentOutput.includes(lastOutput)) return currentOutput.substring(lastOutput.length); return currentOutput; } async detectPatterns(sessionId, output) { const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; if (this.hasLimitMessage(output) && this.isActiveTerminalState(output) && !sessionState.awaitingContinuation) { const CONTINUATION_COOLDOWN_MS = 300 * 1e3; const timeSinceLastContinuation = sessionState.lastContinuationTime ? Date.now() - sessionState.lastContinuationTime.getTime() : CONTINUATION_COOLDOWN_MS + 1; if (timeSinceLastContinuation < CONTINUATION_COOLDOWN_MS) { const remainingCooldown = Math.round((CONTINUATION_COOLDOWN_MS - timeSinceLastContinuation) / 1e3); logger.info(`Usage limit detected but in cooldown period (${remainingCooldown}s remaining), skipping`); return; } logger.info(`Usage limit detected for session ${sessionId}`); sessionState.limitDetectedAt = /* @__PURE__ */ new Date(); sessionState.awaitingContinuation = true; await this.handleLimitDetected(sessionId, output); } if (this.detectApprovalDialog(output)) { const session = await this.sessionManager.getSession(sessionId); if (session) { const colorOutput = await this.tmuxManager.capturePaneWithColors(session.tmuxSession); if (this.isInteractiveApprovalDialog(colorOutput)) { logger.info(`Interactive approval dialog detected for session ${sessionId}`); await this.handleApprovalRequest(sessionId, output); } else logger.debug(`Approval-like text detected but not interactive (likely pasted text), skipping`); } } } /** * Check for task completion based on idle detection and completion patterns */ async checkTaskCompletion(sessionId, output) { const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; if (sessionState.awaitingContinuation) return; const lastChangeTime = sessionState.lastOutputChangeTime; if (!lastChangeTime) return; const now = /* @__PURE__ */ new Date(); const idleDuration = (now.getTime() - lastChangeTime.getTime()) / 1e3; const MIN_IDLE_SECONDS = 10; const COMPLETION_COOLDOWN_MS = 300 * 1e3; if (idleDuration < MIN_IDLE_SECONDS) return; const isWaitingForInput = this.patterns.taskCompletion.waitingForInput.test(output); const notProcessing = this.patterns.taskCompletion.notProcessing.test(output); if (!isWaitingForInput || !notProcessing) return; const lastNotification = sessionState.lastTaskCompletionNotification; if (lastNotification) { if (now.getTime() - lastNotification.getTime() < COMPLETION_COOLDOWN_MS) return; } await this.handleTaskCompletion(sessionId, output, idleDuration); } async handleTaskCompletion(sessionId, output, idleDurationSeconds) { const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; logger.info(`Task completion detected for session ${sessionId} after ${idleDurationSeconds}s idle`); sessionState.lastTaskCompletionNotification = /* @__PURE__ */ new Date(); const event = { type: "task_completed", sessionId, data: { output, idleDurationSeconds }, timestamp: /* @__PURE__ */ new Date() }; this.emit("task_completed", event); await this.safeNotifyDiscord(sessionId, { type: "task_completed", sessionId, sessionName: (await this.sessionManager.getSession(sessionId))?.name || sessionId, message: `✅ Task completed! Claude has been idle for ${Math.round(idleDurationSeconds)}s and is ready for new input.`, metadata: { idleDurationSeconds: Math.round(idleDurationSeconds), lastOutputTimestamp: sessionState.lastOutputChangeTime?.toISOString(), timestamp: (/* @__PURE__ */ new Date()).toISOString() } }); logger.info(`Task completion notification sent for session ${sessionId}`); } async handleLimitDetected(sessionId, output) { const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; if (sessionState.scheduledResetTime) { logger.info(`Already scheduled continuation for ${sessionState.scheduledResetTime.toLocaleString()}, skipping duplicate detection`); return; } const event = { type: "limit_detected", sessionId, data: { output }, timestamp: /* @__PURE__ */ new Date() }; this.emit("limit_detected", event); if (!sessionState.immediateContinueAttempted) { sessionState.immediateContinueAttempted = true; const continueResult = await this.tryImmediateContinuation(sessionId, output); if (continueResult.success) { logger.info(`Immediate continuation successful for session ${sessionId} - no notification needed`); sessionState.lastContinuationTime = /* @__PURE__ */ new Date(); sessionState.awaitingContinuation = false; sessionState.immediateContinueAttempted = false; await this.sessionManager.updateSession(sessionId, { status: "active" }); return; } if (continueResult.response) output = continueResult.response; } const resetTime = this.extractResetTime(output); if (resetTime) { const resetDateTime = await this.parseResetTime(resetTime); if (resetDateTime) { sessionState.scheduledResetTime = resetDateTime; logger.info(`Scheduled continuation for ${resetDateTime.toLocaleString()}`); } } await this.safeNotifyDiscord(sessionId, { type: "limit", sessionId, sessionName: (await this.sessionManager.getSession(sessionId))?.name || sessionId, message: "Usage limit reached. Will automatically continue when limit resets.", metadata: { resetTime: resetTime || "Monitoring for availability", detectedAt: (/* @__PURE__ */ new Date()).toISOString() } }); await this.sessionManager.updateSession(sessionId, { status: "waiting" }); } /** * Check if approval dialog is interactive (not pasted text) by looking for color codes * Real approval dialogs have color formatting, pasted text appears in grey */ isInteractiveApprovalDialog(colorOutput) { const lines = colorOutput.split("\n"); let hasInteractiveColors = false; let hasApprovalContent = false; for (const line of lines) if (line.includes("Do you want to") || line.includes("❯") || /\d+\.\s+Yes/.test(line)) { hasApprovalContent = true; const hasNormalColors = line.includes(this.ANSI_ESCAPE) && /\[(?:[013-79]|[13][0-79]|4[0-79]|9[1-79])m/.test(line); const isGreyOrDim = line.includes(this.ANSI_ESCAPE) && /\[(?:2|8|90)m/.test(line); if (hasNormalColors && !isGreyOrDim) hasInteractiveColors = true; } if (hasApprovalContent && !colorOutput.includes(this.ANSI_ESCAPE)) return true; return hasApprovalContent && hasInteractiveColors; } /** * Check if output contains a limit message (simplified pattern) */ hasLimitMessage(output) { return /limit\s+reached|usage\s+limit|limit.*resets/i.test(output); } /** * Check if terminal is in an active state (has input prompt, not just displaying a list) * Active states include command prompts, input boxes, or continuation messages */ isActiveTerminalState(output) { return [ /^>\s*$/m, /^[^>\n]*>\s*$/m, /─+\s*>\s*─+/, /continue\s+this\s+conversation/i, /you\s+can\s+continue/i, /your\s+limit\s+(?:will\s+)?reset/i ].some((pattern) => pattern.test(output)); } /** * Detect Claude Code approval dialogs using proven patterns from proof-of-concept * Requires all three components: question, numbered options, and current selection */ detectApprovalDialog(output) { const lines = output.split("\n"); let hasApprovalQuestion = false; let hasNumberedOptions = false; let hasCurrentSelection = false; for (const line of lines) { const trimmedLine = line.trim(); if (this.patterns.approvalDialog.question.test(trimmedLine)) hasApprovalQuestion = true; if (this.patterns.approvalDialog.numberedOptions.test(trimmedLine)) hasNumberedOptions = true; if (this.patterns.approvalDialog.currentSelection.test(trimmedLine)) hasCurrentSelection = true; if (hasApprovalQuestion && hasNumberedOptions && hasCurrentSelection) return true; } return false; } /** * Extract approval information from the detected dialog including all available options */ extractApprovalInfo(output) { const lines = output.split("\n"); let question = ""; let tool = "Unknown"; let action = "Unknown operation"; const options = []; for (const line of lines) { const cleanLine = line.replace(/[│┃┆┊╎╏║╭╮╯╰┌┐└┘├┤┬┴┼─━┄┅┈┉═╔╗╚╝╠╣╦╩╬❯]/g, "").replace(/\s+/g, " ").trim(); const numberMatch = cleanLine.match(/^(\d+)\./); if (numberMatch) { const number = Number.parseInt(numberMatch[1], 10); const afterNumber = cleanLine.slice(numberMatch[0].length).trim(); const shortcutMatch = afterNumber.match(/\(([^)]+)\)$/); let text = afterNumber; let shortcut; if (shortcutMatch) { text = afterNumber.slice(0, -shortcutMatch[0].length).trim(); shortcut = shortcutMatch[1]; } options.push({ number, text, shortcut }); } if (this.patterns.approvalDialog.question.test(cleanLine)) { question = cleanLine; if (cleanLine.includes("make this edit to")) { tool = "Edit"; action = `Edit ${cleanLine.match(/([^/\\\s]+\.[a-z0-9]+)\?/i)?.[1] || "file"}`; } else if (cleanLine.includes("create") && cleanLine.includes(".")) { tool = "Write"; action = `Create ${cleanLine.match(/create ([^?\s]+)/)?.[1] || "file"}`; } else if (cleanLine.includes("proceed")) if (output.includes("Bash command")) { tool = "Bash"; const lines$1 = output.split("\n"); let command = "unknown command"; for (const line$1 of lines$1) { const cleanLine$1 = line$1.replace(/[│┃┆┊╎╏║╭╮╯╰┌┐└┘├┤┬┴┼─━┄┅┈┉═╔╗╚╝╠╣╦╩╬]/g, "").trim(); if (cleanLine$1 && !cleanLine$1.includes("Bash command") && !cleanLine$1.includes("Do you want") && !cleanLine$1.includes("Yes") && !cleanLine$1.includes("No") && cleanLine$1.length > 3) { command = cleanLine$1; break; } } action = `Execute: ${command}`; } else { tool = "Tool"; action = "Proceed with operation"; } } } return { tool, action, question, options }; } async handleApprovalRequest(sessionId, output) { const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; const approvalInfo = this.extractApprovalInfo(output); const approvalKey = approvalInfo.question; if (sessionState.lastApprovalQuestion === approvalKey) { logger.info("Skipping duplicate approval request"); return; } sessionState.lastApprovalQuestion = approvalKey; const event = { type: "approval_needed", sessionId, data: { output, approvalInfo, reason: "approval_dialog" }, timestamp: /* @__PURE__ */ new Date() }; this.emit("approval_needed", event); const optionsText = approvalInfo.options.length > 0 ? approvalInfo.options.map((opt) => `**${opt.number}.** ${opt.text}${opt.shortcut ? ` *(${opt.shortcut})*` : ""}`).join("\n") : "No options detected"; await this.safeNotifyDiscord(sessionId, { type: "approval", sessionId, sessionName: (await this.sessionManager.getSession(sessionId))?.name || sessionId, message: `🔐 Approval Required\n\n**Tool:** ${approvalInfo.tool}\n**Action:** ${approvalInfo.action}\n**Question:** ${approvalInfo.question}\n\n**Options:**\n${optionsText}\n\nReply with the option number (e.g. '1', '2', '3')`, metadata: { toolName: approvalInfo.tool, action: approvalInfo.action, question: approvalInfo.question, approvalRequested: true, timestamp: (/* @__PURE__ */ new Date()).toISOString() } }); await this.sessionManager.updateSession(sessionId, { status: "waiting_approval" }); } async handleSessionEnded(sessionId) { await this.stopMonitoring(sessionId); } async handlePollingError(sessionId, error) { const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; sessionState.retryCount++; if (sessionState.retryCount >= this.options.maxRetries) { logger.error(`Max retries exceeded for session ${sessionId}, stopping monitoring`); await this.stopMonitoring(sessionId); const event = { type: "error", sessionId, data: { error: error instanceof Error ? error.message : String(error) }, timestamp: /* @__PURE__ */ new Date() }; this.emit("error", event); } else logger.warn(`Polling error for session ${sessionId}, retry ${sessionState.retryCount}/${this.options.maxRetries}`); } async performAutoContinuation(sessionId) { try { const session = await this.sessionManager.getSession(sessionId); if (!session) return; const sessionState = this.sessionStates.get(sessionId); if (!sessionState) return; logger.info(`Performing auto-continuation for session ${sessionId}`); await this.tmuxManager.sendContinueCommand(session.tmuxSession); sessionState.lastContinuationTime = /* @__PURE__ */ new Date(); sessionState.awaitingContinuation = false; sessionState.scheduledResetTime = void 0; sessionState.immediateContinueAttempted = false; await this.sessionManager.updateSession(sessionId, { status: "active" }); await this.safeNotifyDiscord(sessionId, { type: "continued", sessionId, sessionName: session.name, message: "Session automatically continued after limit reset." }); logger.info(`Auto-continuation completed for session ${sessionId}`); } catch (error) { logger.error(`Auto-continuation failed for session ${sessionId}: ${error}`); } } /** * Try to continue immediately - similar to POC logic * Returns success only if Claude has actually continued and is actively responding */ async tryImmediateContinuation(sessionId, _output) { try { const session = await this.sessionManager.getSession(sessionId); if (!session) return { success: false }; logger.info(`Trying immediate continuation for session ${sessionId}`); const outputBefore = await this.tmuxManager.capturePane(session.tmuxSession); await this.tmuxManager.sendContinueCommand(session.tmuxSession); await new Promise((resolve$1) => setTimeout(resolve$1, 3e3)); const outputAfter = await this.tmuxManager.capturePane(session.tmuxSession); const hasLimitInFull = this.hasLimitMessage(outputAfter); const isJustLimitResponse = outputAfter.substring(outputBefore.length).trim().length < 50 && hasLimitInFull; if (hasLimitInFull && isJustLimitResponse) { logger.info("Immediate continuation failed - limit message still present and no substantial new activity"); return { success: false, response: outputAfter }; } else if (hasLimitInFull) { const recentLines = outputAfter.split("\n").slice(-15).join("\n"); if (this.hasLimitMessage(recentLines) && this.isActiveTerminalState(recentLines)) { logger.info("Immediate continuation failed - limit message still active"); return { success: false, response: outputAfter }; } else { logger.info("Immediate continuation successful - limit in history but Claude is active"); return { success: true, response: outputAfter }; } } else { logger.info("Immediate continuation successful - no limit message found"); return { success: true, response: outputAfter }; } } catch (error) { logger.error(`Immediate continuation attempt failed: ${error}`); return { success: false }; } } /** * Extract reset time from limit message */ extractResetTime(output) { for (const pattern of [ /resets (\d{1,2}(?::\d{2})?\s*(?:am|pm)?)/i, /resets at (\d{1,2}(?::\d{2})?\s*(?:am|pm)?)/i, /available again at (\d{1,2}(?::\d{2})?\s*(?:am|pm)?)/i, /ready at (\d{1,2}(?::\d{2})?\s*(?:am|pm)?)/i ]) { const match = output.match(pattern); if (match) return match[1].trim(); } return null; } /** * Parse reset time string into Date object (from POC) */ async parseResetTime(timeStr) { try { const now = /* @__PURE__ */ new Date(); timeStr = timeStr.toLowerCase().trim(); const timeMatch = timeStr.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/); if (!timeMatch) { logger.warn(`No time match found in: ${timeStr}`); return null; } const [, hours, minutes, period] = timeMatch; let numHours = Number.parseInt(hours, 10); const numMinutes = minutes ? Number.parseInt(minutes, 10) : 0; if (period) { if (period === "pm" && numHours !== 12) numHours += 12; else if (period === "am" && numHours === 12) numHours = 0; } const resetTime = new Date(now); resetTime.setHours(numHours, numMinutes, 0, 0); if (resetTime <= now) { resetTime.setDate(resetTime.getDate() + 1); logger.info(`Reset time passed, scheduling for tomorrow: ${resetTime.toLocaleString()}`); } const hoursToReset = (resetTime.getTime() - now.getTime()) / (1e3 * 60 * 60); if (hoursToReset > 5) { logger.warn(`Sanity check failed: Reset time ${hoursToReset.toFixed(1)} hours away exceeds 5-hour window`); return null; } logger.info(`Parsed "${timeStr}" as ${resetTime.toLocaleString()}`); return resetTime; } catch (error) { logger.error(`Failed to parse reset time: ${error} for input: ${timeStr}`); return null; } } /** * Execute a scheduled quota command and schedule the next occurrence */ async executeQuotaSchedule(sessionId, quotaSchedule) { try { const session = await this.sessionManager.getSession(sessionId); if (!session) return; logger.info(`Executing quota schedule for session ${sessionId}: ${quotaSchedule.command}`); await this.tmuxManager.sendRawKeys(session.tmuxSession, "Enter"); const now = /* @__PURE__ */ new Date(); const nextExecution = await this.parseTimeToNextOccurrence(quotaSchedule.time); if (nextExecution) { const newCommand = generateQuotaMessage(nextExecution); await this.sessionManager.updateSession(sessionId, { quotaSchedule: { ...quotaSchedule, command: newCommand, nextExecution: nextExecution.toISOString() } }); const sessionState = this.sessionStates.get(sessionId); if (sessionState) sessionState.quotaCommandSent = false; const hoursUntilNext = (nextExecution.getTime() - now.getTime()) / (1e3 * 60 * 60); logger.info(`Next quota schedule execution in ${hoursUntilNext.toFixed(1)} hours: ${nextExecution.toLocaleString()}`); await this.safeNotifyDiscord(sessionId, { type: "continued", sessionId, sessionName: session.name, message: `🕕 Daily quota window started! Early command executed to align quota timing.`, metadata: { nextScheduledExecution: nextExecution.toISOString(), quotaWindowTime: quotaSchedule.time, timestamp: (/* @__PURE__ */ new Date()).toISOString() } }); } logger.info(`Quota schedule executed successfully for session ${sessionId}`); } catch (error) { logger.error(`Failed to execute quota schedule for session ${sessionId}: ${error}`); } } /** * Parse time string to next occurrence (today if future, tomorrow if past) * Same logic as in schedule command */ async parseTimeToNextOccurrence(timeStr) { try { const now = /* @__PURE__ */ new Date(); timeStr = timeStr.toLowerCase().trim(); const timeMatch = timeStr.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/); if (!timeMatch) return null; const [, hours, minutes, period] = timeMatch; let numHours = Number.parseInt(hours, 10); const numMinutes = minutes ? Number.parseInt(minutes, 10) : 0; if (period) { if (period === "pm" && numHours !== 12) numHours += 12; else if (period === "am" && numHours === 12) numHours = 0; } if (numHours < 0 || numHours > 23 || numMinutes < 0 || numMinutes > 59) return null; const executeAt = new Date(now); executeAt.setHours(numHours, numMinutes, 0, 0); executeAt.setDate(executeAt.getDate() + 1); return executeAt; } catch (error) { logger.error(`Failed to parse time for next occurrence: ${error} for input: ${timeStr}`); return null; } } async stopAll() { const sessionIds = Array.from(this.monitoringIntervals.keys()); for (const sessionId of sessionIds) await this.stopMonitoring(sessionId); } getActiveMonitoring() { return Array.from(this.monitoringIntervals.keys()); } }; if (import.meta.vitest) { const { beforeEach, afterEach, describe, it, expect, vi } = await import("./dist-bz1tWxsS.js"); describe("Monitor", () => { let monitor; let mockSessionManager; let mockTmuxManager; let mockDiscordBot; beforeEach(() => { mockSessionManager = { getSession: vi.fn(), updateSession: vi.fn() }; mockTmuxManager = { sessionExists: vi.fn(), capturePane: vi.fn(), sendKeys: vi.fn(), sendContinueCommand: vi.fn() }; mockDiscordBot = { sendNotification: vi.fn() }; monitor = new Monitor(mockSessionManager, mockTmuxManager, mockDiscordBot); }); afterEach(() => { monitor.stopAll(); }); it("should detect usage limit messages", () => { expect(monitor.hasLimitMessage("5-hour limit reached. Your limit resets at 3:45pm")).toBe(true); }); it("should calculate new output correctly", () => { expect(monitor.getNewOutput("Hello world", "Hello world\nNew line here")).toBe("\nNew line here"); }); describe("Approval Dialog Detection", () => { const tmuxEditFixture = `╭─────────────────────────────────────────────────────────────────────╮ │ Edit file │ │ ╭─────────────────────────────────────────────────────────────────╮ │ │ │ src/core/tmux.ts │ │ │ │ │ │ │ │ 6 export class TmuxManager { │ │ │ │ 7 async createSession(sessionName: string): │ │ │ │ Promise<void> { │ │ │ │ 8 try { │ │ │ │ 9 - // Create new tmux session │ │ │ │ 10 - const createCommand = \`tmux new-session -d -s │ │ │ │ - "\${sessionName}" -c "\${process.cwd()}"; │ │ │ │ 9 + // Create new tmux session │ │ │ │ + with mouse mode enabled │ │ │ │ 10 + const createCommand = \`tmux new-session -d -s │ │ │ │ + "\${sessionName}" -c "\${process.cwd()}" │ │ │ │ + \\; set -g mouse on\`; │ │ │ │ 11 await execAsync(createCommand); │ │ │ │ 12 │ │ │ │ 13 // Start Claude in the session │ │ │ ╰─────────────────────────────────────────────────────────────────╯ │ │ Do you want to make this edit to tmux.ts? │ │ ❯ 1. Yes │ │ 2. Yes, allow all edits during this session (shift+tab) │ │ 3. No, and tell Claude what to do differently (esc) │ │ │ ╰─────────────────────────────────────────────────────────────────────╯`; const tmuxProceedFixture = `╭─────────────────────────────────────────────────╮ │ Warning: This operation may have side effects │ │ Do you want to proceed? │ │ ❯ 1. Yes │ │ 2. No │ ╰─────────────────────────────────────────────────╯`; const tmuxBashFixture = `╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Bash command │ │ │ │ vitest run src/core/monitor.ts │ │ Run vitest on monitor file │ │ │ │ Do you want to proceed? │ │ ❯ 1. Yes │ │ 2. Yes, and don't ask again for vitest run commands in /Users/motin/Dev/Projects/generative-reality/ccremote │ │ 3. No, and tell Claude what to do differently (esc) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯`; const tmuxCreateFileFixture = `│ Do you want to create debug-stop.js? │ │ ❯ 1. Yes │ │ 2. Yes, allow all edits during this session (shift+tab) │ │ 3. No, and tell Claude what to do differently (esc) │ │ │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯`; const pythonFileEditFixture = `╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Edit file │ │ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ src/utils/config_helper.py │ │ │ │ │ │ │ │ 42 config_data = self.load_config(), │ │ │ │ 43 default_timeout = 30, │ │ │ │ 44 # Set up connection parameters │ │ │ │ 45 - connection_params = ConnectionConfig( │ │ │ │ 46 - timeout = default_timeout │ │ │ │ 47 - ), │ │ │ │ 45 + connection_params = {"timeout": default_timeout}, │ │ │ │ 46 # Additional configuration options │ │ │ │ 47 extra_options = [ │ │ │ │ 48 ConfigOption( │ │ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ Do you want to make this edit to config_helper.py? │ │ ❯ 1. Yes │ │ 2. Yes, allow all edits during this session (shift+tab) │ │ 3. No, and tell Claude what to do differently (esc) │ │ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯`; const noApprovalFixture = `Regular tmux output without approval dialog Some command output More text here`; it("should detect file edit approval dialog", () => { expect(monitor.detectApprovalDialog(tmuxEditFixture)).toBe(true); }); it("should detect proceed approval dialog", () => { expect(monitor.detectApprovalDialog(tmuxProceedFixture)).toBe(true); }); it("should detect bash command approval dialog", () => { expect(monitor.detectApprovalDialog(tmuxBashFixture)).toBe(true); }); it("should detect file creation approval dialog", () => { expect(monitor.detectApprovalDialog(tmuxCreateFileFixture)).toBe(true); }); it("should detect python file edit approval dialog", () => { expect(monitor.detectApprovalDialog(pythonFileEditFixture)).toBe(true); }); it("should not detect non-approval output", () => { expect(monitor.detectApprovalDialog(noApprovalFixture)).toBe(false); }); it("should extract approval info from file edit dialog", () => { const result = monitor.extractApprovalInfo(tmuxEditFixture); expect(result.tool).toBe("Edit"); expect(result.action).toBe("Edit tmux.ts"); expect(result.question).toBe("Do you want to make this edit to tmux.ts?"); expect(result.options).toHaveLength(3); expect(result.options[0]).toEqual({ number: 1, text: "Yes" }); expect(result.options[1]).toEqual({ number: 2, text: "Yes, allow all edits during this session", shortcut: "shift+tab" }); expect(result.options[2]).toEqual({ number: 3, text: "No, and tell Claude what to do differently", shortcut: "esc" }); }); it("should extract approval info from proceed dialog", () => { const result = monitor.extractApprovalInfo(tmuxProceedFixture); expect(result.tool).toBe("Tool"); expect(result.action).toBe("Proceed with operation"); expect(result.question).toBe("Do you want to proceed?"); expect(result.options).toHaveLength(2); expect(result.options[0]).toEqual({ number: 1, text: "Yes" }); expect(result.options[1]).toEqual({ number: 2, text: "No" }); }); it("should extract approval info from bash command dialog", () => { const result = monitor.extractApprovalInfo(tmuxBashFixture); expect(result.tool).toBe("Bash"); expect(result.action).toBe("Execute: vitest run src/core/monitor.ts"); expect(result.question).toBe("Do you want to proceed?"); expect(result.options).toHaveLength(3); expect(result.options[0]).toEqual({ number: 1, text: "Yes" }); expect(result.options[1]).toEqual({ number: 2, text: "Yes, and don't ask again for vitest run commands in /Users/motin/Dev/Projects/generative-reality/ccremote" }); expect(result.options[2]).toEqual({ number: 3, text: "No, and tell Claude what to do differently", shortcut: "esc" }); }); it("should extract approval info from file creation dialog", () => { const result = monitor.extractApprovalInfo(tmuxCreateFileFixture); expect(result.tool).toBe("Write"); expect(result.action).toBe("Create debug-stop.js"); expect(result.question).toBe("Do you want to create debug-stop.js?"); expect(result.options).toHaveLength(3); expect(result.options[0]).toEqual({ number: 1, text: "Yes" }); expect(result.options[1]).toEqual({ number: 2, text: "Yes, allow all edits during this session", shortcut: "shift+tab" }); expect(result.options[2]).toEqual({ number: 3, text: "No, and tell Claude what to do differently", shortcut: "esc" }); }); it("should extract approval info from python file edit dialog", () => { const result = monitor.extractApprovalInfo(pythonFileEditFixture); expect(result.tool).toBe("Edit"); expect(result.action).toBe("Edit config_helper.py"); expect(result.question).toBe("Do you want to make this edit to config_helper.py?"); expect(result.options).toHaveLength(3); expect(result.options[0]).toEqual({ number: 1, text: "Yes" }); expect(result.options[1]).toEqual({ number: 2, text: "Yes, allow all edits during this session", shortcut: "shift+tab" }); expect(result.options[2]).toEqual({ number: 3, text: "No, and tell Claude what to do differently", shortcut: "esc" }); }); }); describe("Task Completion Detection", () => { const waitingForInputFixture = `> `; const notProcessingFixture = `Some output that doesn't show processing indicators Command completed successfully > `; const processingFixture = `Processing request... Analyzing data... > `; const busyFixture = `Task is running Working on something > `; it("should detect waiting for input pattern", () => { expect(monitor.patterns.taskCompletion.waitingForInput.test(waitingForInputFixture)).toBe(true); }); it("should detect not processing pattern", () => { expect(monitor.patterns.taskCompletion.notProcessing.test(notProcessingFixture)).toBe(true); }); it("should NOT consider text as not-processing when processing indicators are present", () => { expect(monitor.patterns.taskCompletion.notProcessing.test(processingFixture)).toBe(true); }); it("should NOT consider text as not-processing when busy indicators are present", () => { expect(monitor.patterns.taskCompletion.notProcessing.test(busyFixture)).toBe(true); }); it("should check task completion logic", async () => { const sessionId = "test-session"; const session = { id: sessionId, name: "test", tmuxSession: "test-tmux" }; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); const oldTime = /* @__PURE__ */ new Date(Date.now() - 15e3); monitor.sessionStates.set(sessionId, { lastOutput: "", awaitingContinuation: false, retryCount: 0, lastOutputChangeTime: oldTime, lastTaskCompletionNotification: void 0 }); const checkTaskCompletionSpy = vi.spyOn(monitor, "checkTaskCompletion"); const handleTaskCompletionSpy = vi.spyOn(monitor, "handleTaskCompletion"); const completionOutput = "Task finished\n> "; await monitor.checkTaskCompletion(sessionId, completionOutput); const isWaiting = monitor.patterns.taskCompletion.waitingForInput.test(completionOutput); const notProcessing = monitor.patterns.taskCompletion.notProcessing.test(completionOutput); expect(isWaiting).toBe(true); expect(notProcessing).toBe(true); checkTaskCompletionSpy.mockRestore(); handleTaskCompletionSpy.mockRestore(); }); it("should respect cooldown period for task completion notifications", async () => { const sessionId = "test-session"; const session = { id: sessionId, name: "test", tmuxSession: "test-tmux" }; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); const recentTime = /* @__PURE__ */ new Date(Date.now() - 120 * 1e3); monitor.sessionStates.set(sessionId, { lastOutput: "", awaitingContinuation: false, retryCount: 0, lastOutputChangeTime: /* @__PURE__ */ new Date(Date.now() - 15e3), lastTaskCompletionNotification: recentTime }); const handleTaskCompletionSpy = vi.spyOn(monitor, "handleTaskCompletion"); await monitor.checkTaskCompletion(sessionId, "Task finished\n> "); expect(handleTaskCompletionSpy).not.toHaveBeenCalled(); handleTaskCompletionSpy.mockRestore(); }); it("should not detect task completion if not idle long enough", async () => { const sessionId = "test-session"; const session = { id: sessionId, name: "test", tmuxSession: "test-tmux" }; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); const recentTime = /* @__PURE__ */ new Date(Date.now() - 5e3); monitor.sessionStates.set(sessionId, { lastOutput: "", awaitingContinuation: false, retryCount: 0, lastOutputChangeTime: recentTime, lastTaskCompletionNotification: void 0 }); const handleTaskCompletionSpy = vi.spyOn(monitor, "handleTaskCompletion"); await monitor.checkTaskCompletion(sessionId, "Task finished\n> "); expect(handleTaskCompletionSpy).not.toHaveBeenCalled(); handleTaskCompletionSpy.mockRestore(); }); }); describe("Immediate Continuation Logic", () => { it("should detect continuation failure when limit message persists with minimal new content", async () => { const sessionId = "test-session"; const session = { id: sessionId, name: "test", tmuxSession: "test-tmux" }; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); const beforeOutput = "Some previous output\n> "; const afterOutput = `Some previous output\n> continue\n Session limit reached ∙ resets 8pm\n /upgrade to increase your usage limit.\n\n> `; let callCount = 0; mockTmuxManager.capturePane = vi.fn().mockImplementation(() => { callCount++; return callCount === 1 ? beforeOutput : afterOutput; }); mockTmuxManager.sendKeys = vi.fn(); const result = await monitor.tryImmediateContinuation(sessionId, "limit output"); expect(result.success).toBe(false); expect(result.response).toBe(afterOutput); }); it("should detect continuation success when Claude starts responding", async () => { const sessionId = "test-session"; const session = { id: sessionId, name: "test", tmuxSession: "test-tmux" }; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); const beforeOutput = "Some previous output\n> "; const afterOutput = `Some previous output\n> continue\nLet me help you with that. Here's what I found...\n[substantial response content here that's more than 50 chars]\n\n> `; let callCount = 0; mockTmuxManager.capturePane = vi.fn().mockImplementation(() => { callCount++; return callCount === 1 ? beforeOutput : afterOutput; }); mockTmuxManager.sendKeys = vi.fn(); const result = await monitor.tryImmediateContinuation(sessionId, "limit output"); expect(result.success).toBe(true); expect(result.response).toBe(afterOutput); }); it("should detect continuation success when limit is only in history", async () => { const sessionId = "test-session"; const session = { id: sessionId, name: "test", tmuxSession: "test-tmux" }; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); const manyLinesOfOutput = Array.from({ length: 20 }).fill("Some command output line").join("\n"); const beforeOutput = "Session limit reached ∙ resets 8pm\n> "; const afterOutput = `Session limit reached ∙ resets 8pm\n> continue\n\n⏺ Bash(bun run lint)\n ⎿ Found '/Users/motin/.nvmrc' with version <v22>\n Now using node v22.19.0 (npm v10.9.3)\n $ eslint --cache .\n${manyLinesOfOutput}\n\n> `; let callCount = 0; mockTmuxManager.capturePane = vi.fn().mockImplementation(() => { callCount++; return callCount === 1 ? beforeOutput : afterOutput; }); mockTmuxManager.sendKeys = vi.fn(); const result = await monitor.tryImmediateContinuation(sessionId, "limit output"); expect(result.success).toBe(true); expect(result.response).toBe(afterOutput); }); it("should detect continuation failure when recent output still shows active limit", async () => { const sessionId = "test-session"; const session = { id: sessionId, name: "test", tmuxSession: "test-tmux" }; mockSessionManager.getSession = vi.fn().mockResolvedValue(session); const beforeOutput = "Previous work here\nSession limit reached ∙ resets 8pm\n> "; const afterOutput = `Previous work here\nSession limit reached ∙ resets 8pm\n> continue\nSome text here\nBut then:\n Session limit reached ∙ resets 8pm\n /upgrade to increase your usage limit.\n\n──────────────────────────────────────────────────────────────────────\n>\n──────────────────────────────────────────────────────────────────────`; let callCount = 0; mockTmuxManager.capturePane = vi.fn().mockImplementation(() => { callCount++; return callCount === 1 ? beforeOutput : afterOutput; }); mockTmuxManager.sendKeys = vi.fn(); const result = await monitor.tryImmediateContinuation(sessionId, "limit output"); expect(result.success).toBe(false); expect(result.response).toBe(afterOutput); }); }); describe("Usage Limit Detection Specificity", () => { const claudeV2LimitFixture = ` Session limit reached ∙ resets 4am /upgrade to increase your usage limit. ────────────────────────────────────────────────────────────────────── > ──────────────────────────────────────────────────────────────────────`; const sessionsListFixture = ` Modified Created Msgs Git Branch Summary ❯ 1. 5s ago 14h ago 105 dev Tmux Approval Detection and Daemon Heartbeat Logging 2. 3d ago 3d ago 2 dev Scheduled Quota Window Message Timing Verification 3. 3d ago 3d ago 298 dev This session is being continued from a previo… 4. 3d ago 3d ago 2 dev 🕕 This message will be sent at 9/24/2025, 5:… 5. 3d ago 3d ago 175 dev fix linting issues from bun run release:test 6. 3d ago 3d ago 254 dev it seems that bun link wont make npm deps ava… 7. 3d ago 3d ago 66 dev