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
JavaScript
#!/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