mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
refactor: extract session scanner base class for code reuse
Create BaseSessionScanner abstract class to consolidate common session scanning logic shared between Claude and Codex session scanners. Both scanners now inherit from this base, reducing duplication and providing consistent patterns for file watching, event processing, and state management.
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import { InvalidateSync } from '@/utils/sync';
|
||||
import { startFileWatcher } from '@/modules/watcher/startFileWatcher';
|
||||
import { logger } from '@/ui/logger';
|
||||
import { join, relative, resolve, sep } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import type { CodexSessionEvent } from './codexEventConverter';
|
||||
import { BaseSessionScanner, SessionFileScanEntry, SessionFileScanResult, SessionFileScanStats } from "@/modules/common/session/BaseSessionScanner";
|
||||
import { logger } from "@/ui/logger";
|
||||
import { join, relative, resolve, sep } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import type { CodexSessionEvent } from "./codexEventConverter";
|
||||
|
||||
interface CodexSessionScannerOptions {
|
||||
sessionId: string | null;
|
||||
@@ -34,33 +33,9 @@ type Candidate = {
|
||||
const DEFAULT_SESSION_START_WINDOW_MS = 2 * 60 * 1000;
|
||||
|
||||
export async function createCodexSessionScanner(opts: CodexSessionScannerOptions): Promise<CodexSessionScanner> {
|
||||
const codexHomeDir = process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
const sessionsRoot = join(codexHomeDir, 'sessions');
|
||||
|
||||
const processedLineCounts = new Map<string, number>();
|
||||
const watchers = new Map<string, () => void>();
|
||||
const sessionIdByFile = new Map<string, string>();
|
||||
const sessionCwdByFile = new Map<string, string>();
|
||||
const sessionTimestampByFile = new Map<string, number>();
|
||||
const pendingEventsByFile = new Map<string, PendingEvents>();
|
||||
const sessionMetaParsed = new Set<string>();
|
||||
|
||||
let activeSessionId: string | null = opts.sessionId;
|
||||
let reportedSessionId: string | null = opts.sessionId;
|
||||
let isClosing = false;
|
||||
let matchFailed = false;
|
||||
|
||||
const targetCwd = opts.cwd && opts.cwd.trim().length > 0 ? normalizePath(opts.cwd) : null;
|
||||
const referenceTimestampMs = opts.startupTimestampMs ?? Date.now();
|
||||
const sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS;
|
||||
const matchDeadlineMs = referenceTimestampMs + sessionStartWindowMs;
|
||||
const sessionDatePrefixes = targetCwd
|
||||
? getSessionDatePrefixes(referenceTimestampMs, sessionStartWindowMs)
|
||||
: null;
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Init: targetCwd=${targetCwd ?? 'none'} startupTs=${new Date(referenceTimestampMs).toISOString()} windowMs=${sessionStartWindowMs}`);
|
||||
|
||||
if (!targetCwd && !opts.sessionId) {
|
||||
matchFailed = true;
|
||||
const message = 'No cwd provided for Codex session matching; refusing to fallback.';
|
||||
logger.warn(`[CODEX_SESSION_SCANNER] ${message}`);
|
||||
opts.onSessionMatchFailed?.(message);
|
||||
@@ -70,35 +45,212 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
|
||||
};
|
||||
}
|
||||
|
||||
function reportSessionId(sessionId: string): void {
|
||||
if (reportedSessionId === sessionId) {
|
||||
const scanner = new CodexSessionScannerImpl(opts, targetCwd);
|
||||
await scanner.start();
|
||||
|
||||
return {
|
||||
cleanup: async () => {
|
||||
await scanner.cleanup();
|
||||
},
|
||||
onNewSession: (sessionId: string) => {
|
||||
scanner.onNewSession(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
private readonly sessionsRoot: string;
|
||||
private readonly onEvent: (event: CodexSessionEvent) => void;
|
||||
private readonly onSessionFound?: (sessionId: string) => void;
|
||||
private readonly onSessionMatchFailed?: (message: string) => void;
|
||||
private readonly sessionIdByFile = new Map<string, string>();
|
||||
private readonly sessionCwdByFile = new Map<string, string>();
|
||||
private readonly sessionTimestampByFile = new Map<string, number>();
|
||||
private readonly pendingEventsByFile = new Map<string, PendingEvents>();
|
||||
private readonly sessionMetaParsed = new Set<string>();
|
||||
private readonly fileEpochByPath = new Map<string, number>();
|
||||
private readonly targetCwd: string | null;
|
||||
private readonly referenceTimestampMs: number;
|
||||
private readonly sessionStartWindowMs: number;
|
||||
private readonly matchDeadlineMs: number;
|
||||
private readonly sessionDatePrefixes: Set<string> | null;
|
||||
|
||||
private activeSessionId: string | null;
|
||||
private reportedSessionId: string | null;
|
||||
private matchFailed = false;
|
||||
private bestWithinWindow: Candidate | null = null;
|
||||
|
||||
constructor(opts: CodexSessionScannerOptions, targetCwd: string | null) {
|
||||
super({ intervalMs: 2000 });
|
||||
const codexHomeDir = process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
this.sessionsRoot = join(codexHomeDir, 'sessions');
|
||||
this.onEvent = opts.onEvent;
|
||||
this.onSessionFound = opts.onSessionFound;
|
||||
this.onSessionMatchFailed = opts.onSessionMatchFailed;
|
||||
this.activeSessionId = opts.sessionId;
|
||||
this.reportedSessionId = opts.sessionId;
|
||||
this.targetCwd = targetCwd;
|
||||
this.referenceTimestampMs = opts.startupTimestampMs ?? Date.now();
|
||||
this.sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS;
|
||||
this.matchDeadlineMs = this.referenceTimestampMs + this.sessionStartWindowMs;
|
||||
this.sessionDatePrefixes = this.targetCwd
|
||||
? getSessionDatePrefixes(this.referenceTimestampMs, this.sessionStartWindowMs)
|
||||
: null;
|
||||
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Init: targetCwd=${this.targetCwd ?? 'none'} startupTs=${new Date(this.referenceTimestampMs).toISOString()} windowMs=${this.sessionStartWindowMs}`);
|
||||
}
|
||||
|
||||
public onNewSession(sessionId: string): void {
|
||||
if (this.activeSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
reportedSessionId = sessionId;
|
||||
opts.onSessionFound?.(sessionId);
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Switching to new session: ${sessionId}`);
|
||||
this.setActiveSessionId(sessionId);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
function setActiveSessionId(sessionId: string): void {
|
||||
activeSessionId = sessionId;
|
||||
reportSessionId(sessionId);
|
||||
if (targetCwd) {
|
||||
flushPendingEventsForSession(sessionId);
|
||||
} else {
|
||||
pendingEventsByFile.clear();
|
||||
protected shouldScan(): boolean {
|
||||
return !this.matchFailed;
|
||||
}
|
||||
|
||||
protected shouldWatchFile(filePath: string): boolean {
|
||||
if (!this.activeSessionId) {
|
||||
if (!this.targetCwd) {
|
||||
return false;
|
||||
}
|
||||
return this.getCandidateForFile(filePath) !== null;
|
||||
}
|
||||
const fileSessionId = this.sessionIdByFile.get(filePath);
|
||||
if (fileSessionId) {
|
||||
return fileSessionId === this.activeSessionId;
|
||||
}
|
||||
return filePath.endsWith(`-${this.activeSessionId}.jsonl`);
|
||||
}
|
||||
|
||||
protected async initialize(): Promise<void> {
|
||||
const files = await this.listSessionFiles(this.sessionsRoot);
|
||||
for (const filePath of files) {
|
||||
const { nextCursor } = await this.readSessionFile(filePath, 0);
|
||||
this.setCursor(filePath, nextCursor);
|
||||
if (this.shouldWatchFile(filePath)) {
|
||||
this.ensureWatcher(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listSessionFiles(dir: string): Promise<string[]> {
|
||||
protected async beforeScan(): Promise<void> {
|
||||
this.bestWithinWindow = null;
|
||||
}
|
||||
|
||||
protected async findSessionFiles(): Promise<string[]> {
|
||||
const files = await this.listSessionFiles(this.sessionsRoot);
|
||||
return sortFilesByMtime(files);
|
||||
}
|
||||
|
||||
protected async parseSessionFile(filePath: string, cursor: number): Promise<SessionFileScanResult<CodexSessionEvent>> {
|
||||
if (this.shouldSkipFile(filePath)) {
|
||||
return { events: [], nextCursor: cursor };
|
||||
}
|
||||
return this.readSessionFile(filePath, cursor);
|
||||
}
|
||||
|
||||
protected generateEventKey(event: CodexSessionEvent, context: { filePath: string; lineIndex?: number }): string {
|
||||
const epoch = this.fileEpochByPath.get(context.filePath) ?? 0;
|
||||
const lineIndex = context.lineIndex ?? -1;
|
||||
return `${context.filePath}:${epoch}:${lineIndex}`;
|
||||
}
|
||||
|
||||
protected async handleFileScan(stats: SessionFileScanStats<CodexSessionEvent>): Promise<void> {
|
||||
const filePath = stats.filePath;
|
||||
const fileSessionId = this.sessionIdByFile.get(filePath) ?? null;
|
||||
|
||||
if (!this.activeSessionId && this.targetCwd) {
|
||||
this.appendPendingEvents(filePath, stats.events, fileSessionId);
|
||||
const candidate = this.getCandidateForFile(filePath);
|
||||
if (candidate) {
|
||||
if (!this.bestWithinWindow || candidate.score < this.bestWithinWindow.score) {
|
||||
this.bestWithinWindow = candidate;
|
||||
}
|
||||
}
|
||||
if (stats.newCount > 0) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Buffered ${stats.newCount} pending events from ${filePath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const emittedForFile = this.emitEvents(stats.events, fileSessionId);
|
||||
if (emittedForFile > 0) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async afterScan(): Promise<void> {
|
||||
if (!this.activeSessionId && this.targetCwd) {
|
||||
if (this.bestWithinWindow) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${this.bestWithinWindow.sessionId} within start window`);
|
||||
this.setActiveSessionId(this.bestWithinWindow.sessionId);
|
||||
} else if (Date.now() > this.matchDeadlineMs) {
|
||||
this.matchFailed = true;
|
||||
this.pendingEventsByFile.clear();
|
||||
const message = `No Codex session found within ${this.sessionStartWindowMs}ms for cwd ${this.targetCwd}; refusing fallback.`;
|
||||
logger.warn(`[CODEX_SESSION_SCANNER] ${message}`);
|
||||
this.onSessionMatchFailed?.(message);
|
||||
} else if (this.pendingEventsByFile.size > 0) {
|
||||
logger.debug('[CODEX_SESSION_SCANNER] No session candidate matched yet; pending events buffered');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSkipFile(filePath: string): boolean {
|
||||
if (!this.activeSessionId) {
|
||||
return false;
|
||||
}
|
||||
const fileSessionId = this.sessionIdByFile.get(filePath);
|
||||
if (fileSessionId && fileSessionId !== this.activeSessionId) {
|
||||
return true;
|
||||
}
|
||||
if (!fileSessionId && !filePath.endsWith(`-${this.activeSessionId}.jsonl`)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private reportSessionId(sessionId: string): void {
|
||||
if (this.reportedSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
this.reportedSessionId = sessionId;
|
||||
this.onSessionFound?.(sessionId);
|
||||
}
|
||||
|
||||
private setActiveSessionId(sessionId: string): void {
|
||||
this.activeSessionId = sessionId;
|
||||
this.reportSessionId(sessionId);
|
||||
const candidateFiles = this.getFilesForSession(sessionId);
|
||||
for (const filePath of candidateFiles) {
|
||||
if (this.shouldWatchFile(filePath)) {
|
||||
this.ensureWatcher(filePath);
|
||||
}
|
||||
}
|
||||
this.pruneWatchers(this.getWatchedFiles().filter((filePath) => this.shouldWatchFile(filePath)));
|
||||
if (this.targetCwd) {
|
||||
this.flushPendingEventsForSession(sessionId);
|
||||
} else {
|
||||
this.pendingEventsByFile.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private async listSessionFiles(dir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (!shouldIncludeSessionPath(full, sessionsRoot, sessionDatePrefixes)) {
|
||||
if (!shouldIncludeSessionPath(full, this.sessionsRoot, this.sessionDatePrefixes)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...await listSessionFiles(full));
|
||||
results.push(...await this.listSessionFiles(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
results.push(full);
|
||||
}
|
||||
@@ -109,24 +261,26 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
|
||||
}
|
||||
}
|
||||
|
||||
async function readSessionFile(filePath: string, startLine: number): Promise<{ events: CodexSessionEvent[]; totalLines: number }> {
|
||||
private async readSessionFile(filePath: string, startLine: number): Promise<SessionFileScanResult<CodexSessionEvent>> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
return { events: [], totalLines: startLine };
|
||||
return { events: [], nextCursor: startLine };
|
||||
}
|
||||
|
||||
const events: CodexSessionEvent[] = [];
|
||||
const events: SessionFileScanEntry<CodexSessionEvent>[] = [];
|
||||
const lines = content.split('\n');
|
||||
const hasTrailingEmpty = lines.length > 0 && lines[lines.length - 1] === '';
|
||||
const totalLines = hasTrailingEmpty ? lines.length - 1 : lines.length;
|
||||
let effectiveStartLine = startLine;
|
||||
if (effectiveStartLine > totalLines) {
|
||||
effectiveStartLine = 0;
|
||||
const nextEpoch = (this.fileEpochByPath.get(filePath) ?? 0) + 1;
|
||||
this.fileEpochByPath.set(filePath, nextEpoch);
|
||||
}
|
||||
|
||||
const hasSessionMeta = sessionMetaParsed.has(filePath);
|
||||
const hasSessionMeta = this.sessionMetaParsed.has(filePath);
|
||||
const parseFrom = hasSessionMeta ? effectiveStartLine : 0;
|
||||
|
||||
for (let index = parseFrom; index < lines.length; index += 1) {
|
||||
@@ -135,66 +289,55 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const parsed = JSON.parse(trimmed) as CodexSessionEvent;
|
||||
if (parsed?.type === 'session_meta') {
|
||||
const payload = asRecord(parsed.payload);
|
||||
const sessionId = payload ? asString(payload.id) : null;
|
||||
if (sessionId) {
|
||||
sessionIdByFile.set(filePath, sessionId);
|
||||
this.sessionIdByFile.set(filePath, sessionId);
|
||||
}
|
||||
const sessionCwd = payload ? asString(payload.cwd) : null;
|
||||
const normalizedCwd = sessionCwd ? normalizePath(sessionCwd) : null;
|
||||
if (normalizedCwd) {
|
||||
sessionCwdByFile.set(filePath, normalizedCwd);
|
||||
this.sessionCwdByFile.set(filePath, normalizedCwd);
|
||||
}
|
||||
const rawTimestamp = payload ? payload.timestamp : null;
|
||||
const sessionTimestamp = payload ? parseTimestamp(payload.timestamp) : null;
|
||||
if (sessionTimestamp !== null) {
|
||||
sessionTimestampByFile.set(filePath, sessionTimestamp);
|
||||
this.sessionTimestampByFile.set(filePath, sessionTimestamp);
|
||||
}
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Session meta: file=${filePath} cwd=${sessionCwd ?? 'none'} normalizedCwd=${normalizedCwd ?? 'none'} timestamp=${rawTimestamp ?? 'none'} parsedTs=${sessionTimestamp ?? 'none'}`);
|
||||
sessionMetaParsed.add(filePath);
|
||||
this.sessionMetaParsed.add(filePath);
|
||||
}
|
||||
if (index >= effectiveStartLine) {
|
||||
events.push(parsed);
|
||||
events.push({ event: parsed, lineIndex: index });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Failed to parse line: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { events, totalLines };
|
||||
return { events, nextCursor: totalLines };
|
||||
}
|
||||
|
||||
async function initializeProcessedMessages(): Promise<void> {
|
||||
const files = await listSessionFiles(sessionsRoot);
|
||||
for (const filePath of files) {
|
||||
const { totalLines } = await readSessionFile(filePath, 0);
|
||||
processedLineCounts.set(filePath, totalLines);
|
||||
if (!isClosing && !watchers.has(filePath)) {
|
||||
watchers.set(filePath, startFileWatcher(filePath, () => sync.invalidate()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCandidateForFile(filePath: string): Candidate | null {
|
||||
const sessionId = sessionIdByFile.get(filePath);
|
||||
private getCandidateForFile(filePath: string): Candidate | null {
|
||||
const sessionId = this.sessionIdByFile.get(filePath);
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileCwd = sessionCwdByFile.get(filePath);
|
||||
if (targetCwd && fileCwd !== targetCwd) {
|
||||
const fileCwd = this.sessionCwdByFile.get(filePath);
|
||||
if (this.targetCwd && fileCwd !== this.targetCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionTimestamp = sessionTimestampByFile.get(filePath);
|
||||
const sessionTimestamp = this.sessionTimestampByFile.get(filePath);
|
||||
if (sessionTimestamp === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diff = Math.abs(sessionTimestamp - referenceTimestampMs);
|
||||
if (diff > sessionStartWindowMs) {
|
||||
const diff = Math.abs(sessionTimestamp - this.referenceTimestampMs);
|
||||
if (diff > this.sessionStartWindowMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -204,11 +347,25 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
|
||||
};
|
||||
}
|
||||
|
||||
function appendPendingEvents(filePath: string, events: CodexSessionEvent[], fileSessionId: string | null): void {
|
||||
private getFilesForSession(sessionId: string): string[] {
|
||||
const matches: string[] = [];
|
||||
for (const [filePath, storedSessionId] of this.sessionIdByFile.entries()) {
|
||||
if (storedSessionId === sessionId) {
|
||||
matches.push(filePath);
|
||||
}
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
return matches;
|
||||
}
|
||||
const suffix = `-${sessionId}.jsonl`;
|
||||
return this.getWatchedFiles().filter((filePath) => filePath.endsWith(suffix));
|
||||
}
|
||||
|
||||
private appendPendingEvents(filePath: string, events: CodexSessionEvent[], fileSessionId: string | null): void {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existing = pendingEventsByFile.get(filePath);
|
||||
const existing = this.pendingEventsByFile.get(filePath);
|
||||
if (existing) {
|
||||
existing.events.push(...events);
|
||||
if (!existing.fileSessionId && fileSessionId) {
|
||||
@@ -216,131 +373,47 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
|
||||
}
|
||||
return;
|
||||
}
|
||||
pendingEventsByFile.set(filePath, {
|
||||
this.pendingEventsByFile.set(filePath, {
|
||||
events: [...events],
|
||||
fileSessionId
|
||||
});
|
||||
}
|
||||
|
||||
function emitEvents(events: CodexSessionEvent[], fileSessionId: string | null): number {
|
||||
private emitEvents(events: CodexSessionEvent[], fileSessionId: string | null): number {
|
||||
let emittedForFile = 0;
|
||||
for (const event of events) {
|
||||
const payload = asRecord(event.payload);
|
||||
const payloadSessionId = payload ? asString(payload.id) : null;
|
||||
const eventSessionId = payloadSessionId ?? fileSessionId ?? null;
|
||||
|
||||
if (activeSessionId && eventSessionId && eventSessionId !== activeSessionId) {
|
||||
if (this.activeSessionId && eventSessionId && eventSessionId !== this.activeSessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
opts.onEvent(event);
|
||||
this.onEvent(event);
|
||||
emittedForFile += 1;
|
||||
}
|
||||
return emittedForFile;
|
||||
}
|
||||
|
||||
function flushPendingEventsForSession(sessionId: string): void {
|
||||
if (pendingEventsByFile.size === 0) {
|
||||
private flushPendingEventsForSession(sessionId: string): void {
|
||||
if (this.pendingEventsByFile.size === 0) {
|
||||
return;
|
||||
}
|
||||
let emitted = 0;
|
||||
for (const [filePath, pending] of pendingEventsByFile.entries()) {
|
||||
for (const [filePath, pending] of this.pendingEventsByFile.entries()) {
|
||||
const matches = (pending.fileSessionId && pending.fileSessionId === sessionId)
|
||||
|| filePath.endsWith(`-${sessionId}.jsonl`);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
emitted += emitEvents(pending.events, pending.fileSessionId);
|
||||
emitted += this.emitEvents(pending.events, pending.fileSessionId);
|
||||
}
|
||||
pendingEventsByFile.clear();
|
||||
this.pendingEventsByFile.clear();
|
||||
if (emitted > 0) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emitted} pending events for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sync = new InvalidateSync(async () => {
|
||||
if (isClosing || matchFailed) {
|
||||
return;
|
||||
}
|
||||
const files = await listSessionFiles(sessionsRoot);
|
||||
const sortedFiles = await sortFilesByMtime(files);
|
||||
let bestWithinWindow: Candidate | null = null;
|
||||
|
||||
for (const filePath of sortedFiles) {
|
||||
if (isClosing) {
|
||||
return;
|
||||
}
|
||||
if (!watchers.has(filePath)) {
|
||||
watchers.set(filePath, startFileWatcher(filePath, () => sync.invalidate()));
|
||||
}
|
||||
|
||||
const fileSessionId = sessionIdByFile.get(filePath);
|
||||
if (activeSessionId && fileSessionId && fileSessionId !== activeSessionId) {
|
||||
continue;
|
||||
}
|
||||
if (activeSessionId && !fileSessionId && !filePath.endsWith(`-${activeSessionId}.jsonl`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastProcessedLine = processedLineCounts.get(filePath) ?? 0;
|
||||
const { events, totalLines } = await readSessionFile(filePath, lastProcessedLine);
|
||||
processedLineCounts.set(filePath, totalLines);
|
||||
const candidate = !activeSessionId && targetCwd ? getCandidateForFile(filePath) : null;
|
||||
if (!activeSessionId && targetCwd) {
|
||||
appendPendingEvents(filePath, events, fileSessionId ?? null);
|
||||
if (candidate) {
|
||||
if (!bestWithinWindow || candidate.score < bestWithinWindow.score) {
|
||||
bestWithinWindow = candidate;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const emittedForFile = emitEvents(events, fileSessionId ?? null);
|
||||
if (emittedForFile > 0) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId && targetCwd) {
|
||||
if (bestWithinWindow) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${bestWithinWindow.sessionId} within start window`);
|
||||
setActiveSessionId(bestWithinWindow.sessionId);
|
||||
} else if (Date.now() > matchDeadlineMs) {
|
||||
matchFailed = true;
|
||||
pendingEventsByFile.clear();
|
||||
const message = `No Codex session found within ${sessionStartWindowMs}ms for cwd ${targetCwd}; refusing fallback.`;
|
||||
logger.warn(`[CODEX_SESSION_SCANNER] ${message}`);
|
||||
opts.onSessionMatchFailed?.(message);
|
||||
} else if (pendingEventsByFile.size > 0) {
|
||||
logger.debug('[CODEX_SESSION_SCANNER] No session candidate matched yet; pending events buffered');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await initializeProcessedMessages();
|
||||
await sync.invalidateAndAwait();
|
||||
const intervalId = setInterval(() => sync.invalidate(), 2000);
|
||||
|
||||
return {
|
||||
cleanup: async () => {
|
||||
isClosing = true;
|
||||
clearInterval(intervalId);
|
||||
sync.stop();
|
||||
for (const stop of watchers.values()) {
|
||||
stop();
|
||||
}
|
||||
watchers.clear();
|
||||
},
|
||||
onNewSession: (sessionId: string) => {
|
||||
if (activeSessionId === sessionId) {
|
||||
return;
|
||||
}
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Switching to new session: ${sessionId}`);
|
||||
setActiveSessionId(sessionId);
|
||||
sync.invalidate();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function sortFilesByMtime(files: string[]): Promise<string[]> {
|
||||
|
||||
Reference in New Issue
Block a user