/**
 * Session Handoff Service
 *
 * Generates a structured session summary on shutdown and pushes it to agents
 * on restart. Replaces the old Slack-only pushRecentSlackHistory approach with
 * a channel-agnostic summary that covers Slack, Google Chat, and Chat UI.
 *
 * Summary is stored at ~/.crewly/session-summaries/latest.md and is limited
 * to 50 lines for prompt-budget friendliness.
 *
 * @module services/session/session-handoff.service
 */
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { LoggerService } from '../core/logger.service.js';
// Issue: SessionHandoff's resume-notification filter previously dynamic-
// imported the thread-status-queue + isTerminalStatus, with a catch
// block that silently swallowed any error. When the import / lookup
// path threw (verified empirically 2026-05-16: the `Filtered terminal
// threads` log line was missing from the post-restart cycle), terminal-
// status threads slipped through the filter and orc was prompted to
// re-reply to already-answered threads.
//
// Switch to static imports so module-resolution errors surface at boot
// (not at first filter-call), and the catch now logs warn-level so any
// remaining runtime failure is visible.
import { ThreadStatusQueueService } from '../messaging/thread-status-queue.service.js';
import { isTerminalStatus } from '../../types/thread-status.types.js';
import { CREWLY_CONSTANTS, SLACK_THREAD_CONSTANTS, GCHAT_THREAD_CONSTANTS } from '../../constants.js';
import { RUNTIME_TYPES } from '../../constants.js';
/** Maximum number of lines the summary may contain */
const MAX_SUMMARY_LINES = 80;
/** Maximum number of pending tasks to include in the summary */
const MAX_PENDING_TASKS = 10;
/** Maximum age in ms for a thread to be considered for resume notification (24 hours) */
const RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
/** Maximum number of recent user messages to include per thread */
const MAX_MESSAGES_PER_THREAD = 3;
/** Maximum number of active threads to include in the summary */
const MAX_ACTIVE_THREADS = 6;
/** Directory name for session summaries under crewly home */
const SUMMARIES_DIR = 'session-summaries';
/** Summary filename */
const LATEST_SUMMARY = 'latest.md';
/**
 * Manages generation of session summaries on shutdown and delivery on restart.
 *
 * @example
 * ```typescript
 * const handoff = SessionHandoffService.getInstance();
 *
 * // On shutdown:
 * await handoff.generateSummary(storageService);
 *
 * // On agent registration:
 * await handoff.pushSessionSummary(agentService, 'crewly-orc');
 * ```
 */
export class SessionHandoffService {
    static instance = null;
    logger = LoggerService.getInstance().createComponentLogger('SessionHandoffService');
    constructor() { }
    /**
     * Gets the singleton instance.
     *
     * @returns SessionHandoffService instance
     */
    static getInstance() {
        if (!SessionHandoffService.instance) {
            SessionHandoffService.instance = new SessionHandoffService();
        }
        return SessionHandoffService.instance;
    }
    /**
     * Resets the singleton instance (for testing).
     */
    static resetInstance() {
        SessionHandoffService.instance = null;
    }
    /**
     * Returns the directory path for session summaries.
     *
     * @returns Absolute path to ~/.crewly/session-summaries/
     */
    getSummariesDir() {
        return path.join(os.homedir(), CREWLY_CONSTANTS.PATHS.CREWLY_HOME, SUMMARIES_DIR);
    }
    /**
     * Returns the path to the latest summary file.
     *
     * @returns Absolute path to ~/.crewly/session-summaries/latest.md
     */
    getLatestSummaryPath() {
        return path.join(this.getSummariesDir(), LATEST_SUMMARY);
    }
    /**
     * Scans a thread directory for the most recently modified .md files.
     * Works for both Slack (~/.crewly/slack-threads/) and GChat (~/.crewly/gchat-threads/).
     *
     * @param baseDir - Base directory to scan (e.g., ~/.crewly/slack-threads/)
     * @param channelType - Type of channel ('slack' or 'gchat')
     * @param maxThreads - Maximum number of threads to return
     * @returns Array of ActiveThread info sorted by recency
     */
    async scanThreadDirectory(baseDir, channelType, maxThreads = MAX_ACTIVE_THREADS) {
        const threads = [];
        try {
            const entries = await fs.readdir(baseDir).catch(() => []);
            // Collect all .md files across subdirectories
            const candidates = [];
            for (const entry of entries) {
                if (entry.endsWith('.json'))
                    continue; // Skip index files
                const entryPath = path.join(baseDir, entry);
                const stat = await fs.stat(entryPath).catch(() => null);
                if (stat?.isDirectory()) {
                    const files = await fs.readdir(entryPath).catch(() => []);
                    for (const file of files) {
                        if (!file.endsWith('.md'))
                            continue;
                        const filePath = path.join(entryPath, file);
                        const fileStat = await fs.stat(filePath).catch(() => null);
                        if (fileStat) {
                            candidates.push({ filePath, channelId: entry, mtimeMs: fileStat.mtimeMs });
                        }
                    }
                }
            }
            // Sort by modification time (most recent first) and take top N
            candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
            for (const candidate of candidates.slice(0, maxThreads)) {
                const recentMessages = await this.extractRecentMessages(candidate.filePath);
                threads.push({
                    channelType,
                    channelId: candidate.channelId,
                    filePath: candidate.filePath,
                    lastActiveAt: new Date(candidate.mtimeMs).toISOString(),
                    recentMessages,
                });
            }
        }
        catch (error) {
            this.logger.debug('Failed to scan thread directory', {
                baseDir,
                error: error instanceof Error ? error.message : String(error),
            });
        }
        return threads;
    }
    /**
     * Scans Chat UI conversation storage for active conversations.
     *
     * @param chatDir - Path to chat storage directory (default: ~/.crewly/chat/)
     * @param maxConversations - Maximum number of conversations to return
     * @returns Array of ActiveThread info for Chat UI conversations
     */
    async scanChatUiConversations(chatDir, maxConversations = MAX_ACTIVE_THREADS) {
        const threads = [];
        const dir = chatDir || path.join(os.homedir(), CREWLY_CONSTANTS.PATHS.CREWLY_HOME, 'chat');
        try {
            const files = await fs.readdir(dir).catch(() => []);
            const jsonFiles = files.filter(f => f.endsWith('.json'));
            const candidates = [];
            for (const file of jsonFiles) {
                const filePath = path.join(dir, file);
                const stat = await fs.stat(filePath).catch(() => null);
                if (stat) {
                    candidates.push({ filePath, id: file.replace('.json', ''), mtimeMs: stat.mtimeMs });
                }
            }
            candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
            for (const candidate of candidates.slice(0, maxConversations)) {
                const recentMessages = await this.extractChatUiMessages(candidate.filePath);
                threads.push({
                    channelType: 'chat-ui',
                    channelId: candidate.id,
                    filePath: candidate.filePath,
                    lastActiveAt: new Date(candidate.mtimeMs).toISOString(),
                    recentMessages,
                });
            }
        }
        catch (error) {
            this.logger.debug('Failed to scan Chat UI conversations', {
                error: error instanceof Error ? error.message : String(error),
            });
        }
        return threads;
    }
    /**
     * Extracts recent message summaries from a markdown thread file.
     * Reads the last few message blocks (lines starting with **Name**).
     *
     * @param filePath - Absolute path to the thread .md file
     * @returns Array of recent message summary strings
     */
    async extractRecentMessages(filePath) {
        try {
            const content = await fs.readFile(filePath, 'utf-8');
            // Skip frontmatter
            const frontmatterEnd = content.indexOf('---', content.indexOf('---') + 3);
            const body = frontmatterEnd > 0 ? content.slice(frontmatterEnd + 3).trim() : content;
            // Extract lines that look like message headers (e.g., "**Steve** (2025-03-16 12:00):")
            const messagePattern = /^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*$/;
            const lines = body.split('\n');
            const messages = [];
            for (let i = 0; i < lines.length; i++) {
                const match = lines[i].match(messagePattern);
                if (match) {
                    const sender = match[1];
                    // Grab the next non-empty line as content preview
                    let preview = '';
                    for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
                        const trimmed = lines[j].trim();
                        if (trimmed && !trimmed.startsWith('**')) {
                            preview = trimmed.slice(0, 120);
                            break;
                        }
                    }
                    messages.push(`${sender}: ${preview || '(empty)'}`);
                }
            }
            return messages.slice(-MAX_MESSAGES_PER_THREAD);
        }
        catch {
            return [];
        }
    }
    /**
     * Extracts recent messages from a Chat UI JSON conversation file.
     *
     * @param filePath - Absolute path to the conversation .json file
     * @returns Array of recent message summary strings
     */
    async extractChatUiMessages(filePath) {
        try {
            const raw = await fs.readFile(filePath, 'utf-8');
            const data = JSON.parse(raw);
            if (!data.messages || data.messages.length === 0)
                return [];
            return data.messages
                .slice(-MAX_MESSAGES_PER_THREAD)
                .map(m => {
                const sender = m.from?.name || m.from?.type || 'unknown';
                const content = (m.content || '').slice(0, 120);
                return `${sender}: ${content || '(empty)'}`;
            });
        }
        catch {
            return [];
        }
    }
    /**
     * Collects active agent info from team storage.
     *
     * @param teamReader - Service to read team data
     * @returns Array of active agent info
     */
    async collectActiveAgents(teamReader) {
        const agents = [];
        try {
            const teams = await teamReader.getTeams();
            for (const team of teams) {
                for (const member of team.members) {
                    if (member.agentStatus === 'active' || member.agentStatus === 'started') {
                        agents.push({
                            sessionName: member.sessionName,
                            role: member.role,
                            workingStatus: member.workingStatus || 'idle',
                            currentTask: member.currentTickets?.[0],
                        });
                    }
                }
            }
        }
        catch (error) {
            this.logger.debug('Failed to collect active agents', {
                error: error instanceof Error ? error.message : String(error),
            });
        }
        return agents;
    }
    /**
     * Returns pending (non-terminal) tasks from the V3 task-pool. Replaces the
     * legacy `.crewly/tasks/delegated/{open,in_progress}/*.md` filesystem scan
     * (retired per `specs/2026-05-06-projecttask-md-deprecation.md` —
     * V3 WorkItem is the sole source of truth for delegated work).
     *
     * Selection: any WorkItem in a non-terminal status (`queued`, `scheduled`,
     * `proposed`, `accepted`, `running`, `blocked`, `escalated`,
     * `done_by_worker`, `rejected`) — i.e. anything that still demands
     * attention on a session resume.
     *
     * Sort: running-first (workers actively engaged), then high-priority first.
     *
     * The legacy `tasksBaseDir` parameter is accepted for backwards compat
     * with existing test fixtures but is unused — the source is now the
     * task-pool singleton.
     *
     * @param _tasksBaseDir - Ignored. Kept so callers (e.g. session.controller)
     *                        don't need to be updated in lockstep.
     * @returns Array of PendingTaskInfo sorted, capped at {@link MAX_PENDING_TASKS}
     */
    async scanPendingTasks(_tasksBaseDir) {
        let workItems;
        try {
            // Lazy-import to avoid a hard load-order dependency between session-
            // handoff and the V3 task-pool boot. Both are singletons; this dynamic
            // import resolves once and is cached by the module loader.
            const { TaskPoolService } = await import('../task-pool/task-pool.service.js');
            workItems = await TaskPoolService.getInstance().getAllItems();
        }
        catch (error) {
            this.logger.debug('Failed to read V3 task-pool for pending tasks', {
                error: error instanceof Error ? error.message : String(error),
            });
            return [];
        }
        const NON_TERMINAL = new Set([
            'queued',
            'scheduled',
            'proposed',
            'accepted',
            'running',
            'blocked',
            'escalated',
            'done_by_worker',
            'rejected',
        ]);
        const pending = workItems
            .filter((wi) => NON_TERMINAL.has(wi.status))
            .map((wi) => {
            // Truncate long titles to keep the handoff summary readable.
            let title = wi.title || wi.id;
            if (title.length > 100)
                title = title.slice(0, 97) + '...';
            return {
                title,
                assignedTo: wi.target ?? 'unassigned',
                status: wi.status,
                priority: (wi.priority ?? '').toLowerCase(),
                filePath: wi.id,
            };
        });
        // Sort: running-first (workers actively engaged), then high-priority.
        const priorityOrder = { high: 0, medium: 1, low: 2 };
        const statusOrder = {
            running: 0,
            proposed: 1,
            accepted: 1,
            queued: 2,
            scheduled: 2,
            done_by_worker: 3,
            blocked: 4,
            escalated: 4,
            rejected: 5,
        };
        pending.sort((a, b) => {
            const statusDiff = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
            if (statusDiff !== 0)
                return statusDiff;
            return (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1);
        });
        return pending.slice(0, MAX_PENDING_TASKS);
    }
    /**
     * Generates a full session handoff summary by scanning all thread stores
     * and collecting active agent status. Saves to ~/.crewly/session-summaries/latest.md.
     *
     * @param teamReader - Service to read team data for agent status
     * @param tasksBaseDir - Override for the tasks base directory (for testing)
     * @returns The generated summary data
     */
    async generateSummary(teamReader, tasksBaseDir) {
        this.logger.info('Generating session handoff summary...');
        const crewlyHome = path.join(os.homedir(), CREWLY_CONSTANTS.PATHS.CREWLY_HOME);
        // Scan all thread stores and pending tasks in parallel
        const [slackThreads, gchatThreads, chatUiThreads, activeAgents, pendingTasks] = await Promise.all([
            this.scanThreadDirectory(path.join(crewlyHome, SLACK_THREAD_CONSTANTS.STORAGE_DIR), 'slack', 3),
            this.scanThreadDirectory(path.join(crewlyHome, GCHAT_THREAD_CONSTANTS.STORAGE_DIR), 'gchat', 3),
            this.scanChatUiConversations(undefined, 2),
            this.collectActiveAgents(teamReader),
            this.scanPendingTasks(tasksBaseDir),
        ]);
        const allThreads = [...slackThreads, ...gchatThreads, ...chatUiThreads]
            .sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime())
            .slice(0, MAX_ACTIVE_THREADS);
        const summary = {
            generatedAt: new Date().toISOString(),
            activeThreads: allThreads,
            activeAgents,
            pendingTasks,
        };
        // Format and save
        const markdown = this.formatSummaryMarkdown(summary);
        const summariesDir = this.getSummariesDir();
        await fs.mkdir(summariesDir, { recursive: true });
        await fs.writeFile(this.getLatestSummaryPath(), markdown, 'utf-8');
        this.logger.info('Session handoff summary saved', {
            threadCount: allThreads.length,
            agentCount: activeAgents.length,
            pendingTaskCount: pendingTasks.length,
            path: this.getLatestSummaryPath(),
        });
        return summary;
    }
    /**
     * Formats a HandoffSummary into a markdown string, capped at MAX_SUMMARY_LINES.
     *
     * @param summary - The summary data
     * @returns Markdown string
     */
    formatSummaryMarkdown(summary) {
        const lines = [];
        lines.push('# Session Handoff Summary');
        lines.push(`Generated: ${summary.generatedAt}`);
        lines.push('');
        // Active threads section
        if (summary.activeThreads.length > 0) {
            lines.push('## Active Conversations');
            for (const thread of summary.activeThreads) {
                lines.push(`### ${thread.channelType.toUpperCase()} — ${thread.channelId}`);
                lines.push(`- File: \`${thread.filePath}\``);
                lines.push(`- Last active: ${thread.lastActiveAt}`);
                if (thread.recentMessages.length > 0) {
                    lines.push('- Recent:');
                    for (const msg of thread.recentMessages) {
                        lines.push(`  - ${msg}`);
                    }
                }
                lines.push('');
            }
        }
        else {
            lines.push('## Active Conversations');
            lines.push('No active conversations found.');
            lines.push('');
        }
        // Active agents section
        if (summary.activeAgents.length > 0) {
            lines.push('## Active Agents');
            for (const agent of summary.activeAgents) {
                const taskInfo = agent.currentTask ? ` — task: ${agent.currentTask}` : '';
                lines.push(`- **${agent.sessionName}** (${agent.role}): ${agent.workingStatus}${taskInfo}`);
            }
            lines.push('');
        }
        // Pending tasks section
        if (summary.pendingTasks && summary.pendingTasks.length > 0) {
            lines.push('## Pending Tasks');
            lines.push(`${summary.pendingTasks.length} task(s) require attention after restart:`);
            lines.push('');
            for (const task of summary.pendingTasks) {
                const statusLabel = task.status === 'in_progress' ? '🔄 IN PROGRESS' : '📋 OPEN';
                lines.push(`- ${statusLabel} [${task.priority}] **${task.title}**`);
                lines.push(`  - Assigned to: ${task.assignedTo}`);
                lines.push(`  - File: \`${task.filePath}\``);
            }
            lines.push('');
        }
        // Truncate to max lines
        return lines.slice(0, MAX_SUMMARY_LINES).join('\n');
    }
    /**
     * Pushes the latest session summary to an agent after registration.
     * Replaces the old pushRecentSlackHistory function.
     *
     * Reads ~/.crewly/session-summaries/latest.md and sends it as a
     * [SESSION_CONTEXT] system message. Only sends if the summary exists
     * and is non-empty.
     *
     * @param agentService - Service to send messages to agents
     * @param sessionName - Target agent session name
     */
    async pushSessionSummary(agentService, sessionName) {
        const summaryPath = this.getLatestSummaryPath();
        try {
            const content = await fs.readFile(summaryPath, 'utf-8');
            if (!content || content.trim().length === 0) {
                this.logger.debug('No session summary to push', { sessionName });
                return;
            }
            const message = `[SESSION_CONTEXT] Previous session context for restart recovery:\n\n${content.trim()}`;
            await agentService.sendMessageToAgent(sessionName, message, RUNTIME_TYPES.CLAUDE_CODE);
            this.logger.info('Pushed session summary to agent', {
                sessionName,
                summaryLength: content.length,
            });
        }
        catch (error) {
            this.logger.warn('Failed to push session summary', {
                sessionName,
                error: error instanceof Error ? error.message : String(error),
            });
        }
    }
    /**
     * Scans a thread directory (Slack or GChat format) for threads modified within maxAge.
     * Filters by file modification time and returns ResumeThread objects.
     *
     * @param baseDir - Directory to scan (e.g., ~/.crewly/slack-threads/)
     * @param channelType - Channel type ('slack' or 'gchat')
     * @param cutoffMs - Cutoff timestamp in ms (threads older than this are excluded)
     * @returns Array of ResumeThread for qualifying threads
     */
    async scanRecentMdThreads(baseDir, channelType, cutoffMs) {
        const threads = [];
        try {
            const entries = await fs.readdir(baseDir).catch(() => []);
            for (const entry of entries) {
                if (entry.endsWith('.json'))
                    continue;
                const channelDir = path.join(baseDir, entry);
                const stat = await fs.stat(channelDir).catch(() => null);
                if (!stat?.isDirectory())
                    continue;
                const files = await fs.readdir(channelDir).catch(() => []);
                for (const file of files) {
                    if (!file.endsWith('.md'))
                        continue;
                    const filePath = path.join(channelDir, file);
                    const fileStat = await fs.stat(filePath).catch(() => null);
                    if (!fileStat || fileStat.mtimeMs < cutoffMs)
                        continue;
                    const threadId = file.replace('.md', '');
                    const recentMessages = await this.extractRecentMessages(filePath);
                    threads.push({
                        channelType,
                        channelId: entry,
                        threadId,
                        filePath,
                        lastActiveAt: new Date(fileStat.mtimeMs).toISOString(),
                        recentMessages,
                    });
                }
            }
        }
        catch (error) {
            this.logger.debug('Failed to scan recent threads', {
                baseDir,
                channelType,
                error: error instanceof Error ? error.message : String(error),
            });
        }
        return threads;
    }
    /**
     * Scans Chat UI conversations for those modified within maxAge.
     *
     * @param chatDir - Chat UI directory path
     * @param cutoffMs - Cutoff timestamp in ms
     * @returns Array of ResumeThread for qualifying conversations
     */
    async scanRecentChatUiThreads(chatDir, cutoffMs) {
        const threads = [];
        try {
            const files = await fs.readdir(chatDir).catch(() => []);
            for (const file of files) {
                if (!file.endsWith('.json'))
                    continue;
                const filePath = path.join(chatDir, file);
                const fileStat = await fs.stat(filePath).catch(() => null);
                if (!fileStat || fileStat.mtimeMs < cutoffMs)
                    continue;
                const conversationId = file.replace('.json', '');
                const recentMessages = await this.extractChatUiMessages(filePath);
                threads.push({
                    channelType: 'chat-ui',
                    channelId: conversationId,
                    threadId: conversationId,
                    filePath,
                    lastActiveAt: new Date(fileStat.mtimeMs).toISOString(),
                    recentMessages,
                });
            }
        }
        catch (error) {
            this.logger.debug('Failed to scan recent Chat UI conversations', {
                error: error instanceof Error ? error.message : String(error),
            });
        }
        return threads;
    }
    /**
     * Finds threads across all chat channels (Slack, GChat, Chat UI) that were
     * active within the specified time window.
     *
     * @param maxAge - Maximum age in ms for a thread to qualify (default: 24h)
     * @param dirOverrides - Optional directory overrides for testing
     * @returns Array of ResumeThread sorted by most recent first
     */
    async findRecentThreads(maxAge = RESUME_MAX_AGE_MS, dirOverrides) {
        const crewlyHome = path.join(os.homedir(), CREWLY_CONSTANTS.PATHS.CREWLY_HOME);
        const slackDir = dirOverrides?.slackDir || path.join(crewlyHome, SLACK_THREAD_CONSTANTS.STORAGE_DIR);
        const gchatDir = dirOverrides?.gchatDir || path.join(crewlyHome, GCHAT_THREAD_CONSTANTS.STORAGE_DIR);
        const chatDir = dirOverrides?.chatDir || path.join(crewlyHome, 'chat');
        const cutoff = Date.now() - maxAge;
        const [slackThreads, gchatThreads, chatUiThreads] = await Promise.all([
            this.scanRecentMdThreads(slackDir, 'slack', cutoff),
            this.scanRecentMdThreads(gchatDir, 'gchat', cutoff),
            this.scanRecentChatUiThreads(chatDir, cutoff),
        ]);
        const all = [...slackThreads, ...gchatThreads, ...chatUiThreads];
        return all.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime());
    }
    /**
     * @deprecated Use findRecentThreads() instead. Kept for backward compatibility.
     * Finds Slack threads that were active within the last 24 hours.
     *
     * @param maxAge - Maximum age in ms for a thread to qualify (default: 24h)
     * @param slackDirOverride - Optional directory override (for testing)
     * @returns Array of ResumeThread sorted by most recent first (Slack only)
     */
    async findRecentSlackThreads(maxAge = RESUME_MAX_AGE_MS, slackDirOverride) {
        return this.findRecentThreads(maxAge, {
            slackDir: slackDirOverride,
            gchatDir: '/nonexistent',
            chatDir: '/nonexistent',
        });
    }
    /**
     * Pushes a [CHAT_RESUME] notification to the orchestrator after registration,
     * containing info about recently active threads across all channels (Slack,
     * GChat, Chat UI) so it can proactively send a greeting message.
     *
     * @param agentService - Service to send messages to agents
     * @param sessionName - Target agent session name (typically the orchestrator)
     */
    async pushResumeNotification(agentService, sessionName) {
        try {
            const allThreads = await this.findRecentThreads();
            if (allThreads.length === 0) {
                this.logger.debug('No recent threads for resume notification', { sessionName });
                return;
            }
            // Filter out threads that are already completed in the Thread Status Queue.
            // This prevents the orchestrator from re-engaging with threads it already
            // handled before the restart (e.g. email processing, answered questions).
            let threads = allThreads;
            try {
                // Static-imported above so module-resolution errors surface at
                // boot. The catch below is now a real fail-loud signal, not a
                // shrug-and-include-all.
                const tsq = ThreadStatusQueueService.getInstance();
                threads = allThreads.filter(thread => {
                    // Build the threadKey the same way the Slack bridge does
                    const threadKey = `${thread.channelId}:${thread.threadId}`;
                    const entry = tsq.get(threadKey);
                    if (entry && isTerminalStatus(entry.status)) {
                        this.logger.debug('Filtering out terminal thread from resume', {
                            threadKey,
                            status: entry.status,
                        });
                        return false;
                    }
                    // For chat-ui threads: also check by conversationId since chat-ui
                    // files use the conversation ID as the channelId, and the TSQ
                    // entries store the matching sourceConversationItemId.
                    if (thread.channelType === 'chat-ui') {
                        const byConvId = tsq.getByConversationId(thread.channelId);
                        if (byConvId && isTerminalStatus(byConvId.status)) {
                            this.logger.debug('Filtering out terminal chat-ui thread from resume', {
                                channelId: thread.channelId,
                                status: byConvId.status,
                            });
                            return false;
                        }
                    }
                    return true;
                });
                if (allThreads.length !== threads.length) {
                    this.logger.info('Filtered terminal threads from resume notification', {
                        total: allThreads.length,
                        kept: threads.length,
                        filtered: allThreads.length - threads.length,
                    });
                }
            }
            catch (err) {
                // Was previously a silent `catch {}` — empirically masked a real
                // failure (2026-05-16: filter never executed at the 17:07 restart,
                // and orc was prompted with 5 threads including one already in
                // replied_completed status, leading to a duplicate file upload).
                // Now surfaces the error and includes the thread count we
                // would have filtered against so the next debug pass has a
                // starting point.
                this.logger.warn('SessionHandoff resume-notification filter threw — including all threads (potential duplicate-reply risk)', {
                    sessionName,
                    allThreadsCount: allThreads.length,
                    error: err instanceof Error ? err.message : String(err),
                    stack: err instanceof Error ? err.stack : undefined,
                });
            }
            if (threads.length === 0) {
                this.logger.debug('All recent threads are terminal, skipping resume notification', { sessionName });
                return;
            }
            const lines = [
                '[CHAT_RESUME] You just restarted. The following threads have PENDING or UNREPLIED messages.',
                'Review them and reply where needed. Do NOT re-process threads that were already handled.',
                '',
            ];
            for (const thread of threads) {
                const label = thread.channelType.toUpperCase();
                lines.push(`## ${label} Thread: ${thread.channelId} / ${thread.threadId}`);
                lines.push(`- Source: ${thread.channelType}`);
                lines.push(`- File: \`${thread.filePath}\``);
                lines.push(`- Channel: ${thread.channelId}`);
                lines.push(`- Thread ID: ${thread.threadId}`);
                lines.push(`- Last active: ${thread.lastActiveAt}`);
                if (thread.recentMessages.length > 0) {
                    lines.push('- Recent messages:');
                    for (const msg of thread.recentMessages) {
                        lines.push(`  - ${msg}`);
                    }
                }
                lines.push('');
            }
            const message = lines.join('\n');
            await agentService.sendMessageToAgent(sessionName, message, RUNTIME_TYPES.CLAUDE_CODE);
            this.logger.info('Pushed resume notification', {
                sessionName,
                threadCount: threads.length,
                channels: [...new Set(threads.map(t => t.channelType))],
            });
        }
        catch (error) {
            this.logger.warn('Failed to push resume notification', {
                sessionName,
                error: error instanceof Error ? error.message : String(error),
            });
        }
    }
    /**
     * @deprecated Use pushResumeNotification() instead.
     */
    async pushSlackResumeNotification(agentService, sessionName) {
        return this.pushResumeNotification(agentService, sessionName);
    }
}
//# sourceMappingURL=session-handoff.service.js.map