feat: add intelligent session matching based on cwd and timestamp

This commit is contained in:
weishu
2025-12-26 21:11:43 +08:00
parent 09faf443ce
commit f03c7f2cc8
2 changed files with 226 additions and 23 deletions
+2
View File
@@ -9,6 +9,8 @@ import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy';
export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
const scanner = await createCodexSessionScanner({
sessionId: session.sessionId,
cwd: session.path,
startupTimestampMs: Date.now(),
onSessionFound: (sessionId) => {
session.onSessionFound(sessionId);
},
+224 -23
View File
@@ -1,7 +1,7 @@
import { InvalidateSync } from '@/utils/sync';
import { startFileWatcher } from '@/modules/watcher/startFileWatcher';
import { logger } from '@/ui/logger';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { readFile, readdir, stat } from 'node:fs/promises';
import type { CodexSessionEvent } from './codexEventConverter';
@@ -10,6 +10,9 @@ interface CodexSessionScannerOptions {
sessionId: string | null;
onEvent: (event: CodexSessionEvent) => void;
onSessionFound?: (sessionId: string) => void;
cwd?: string;
startupTimestampMs?: number;
sessionStartWindowMs?: number;
}
interface CodexSessionScanner {
@@ -17,6 +20,22 @@ interface CodexSessionScanner {
onNewSession: (sessionId: string) => void;
}
type PendingEvents = {
events: CodexSessionEvent[];
fileSessionId: string | null;
};
type CandidateReason = 'within-window' | 'outside-window' | 'no-timestamp' | 'unknown-cwd';
type Candidate = {
sessionId: string;
filePath: string;
score: number;
reason: CandidateReason;
};
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');
@@ -24,23 +43,37 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
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;
const reportSessionId = (sessionId: string) => {
const targetCwd = opts.cwd ? normalizePath(opts.cwd) : null;
const referenceTimestampMs = opts.startupTimestampMs ?? Date.now();
const sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS;
logger.debug(`[CODEX_SESSION_SCANNER] Init: targetCwd=${targetCwd ?? 'none'} startupTs=${new Date(referenceTimestampMs).toISOString()} windowMs=${sessionStartWindowMs}`);
function reportSessionId(sessionId: string): void {
if (reportedSessionId === sessionId) {
return;
}
reportedSessionId = sessionId;
opts.onSessionFound?.(sessionId);
};
}
const setActiveSessionId = (sessionId: string) => {
function setActiveSessionId(sessionId: string): void {
activeSessionId = sessionId;
reportSessionId(sessionId);
};
if (targetCwd) {
flushPendingEventsForSession(sessionId);
} else {
pendingEventsByFile.clear();
}
}
async function listSessionFiles(dir: string): Promise<string[]> {
try {
@@ -77,8 +110,8 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
effectiveStartLine = 0;
}
const hasSessionId = sessionIdByFile.has(filePath);
const parseFrom = hasSessionId ? effectiveStartLine : 0;
const hasSessionMeta = sessionMetaParsed.has(filePath);
const parseFrom = hasSessionMeta ? effectiveStartLine : 0;
for (let index = parseFrom; index < lines.length; index += 1) {
const trimmed = lines[index].trim();
@@ -93,6 +126,18 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
if (sessionId) {
sessionIdByFile.set(filePath, sessionId);
}
const sessionCwd = payload ? asString(payload.cwd) : null;
const normalizedCwd = sessionCwd ? normalizePath(sessionCwd) : null;
if (normalizedCwd) {
sessionCwdByFile.set(filePath, normalizedCwd);
}
const rawTimestamp = payload ? payload.timestamp : null;
const sessionTimestamp = payload ? parseTimestamp(payload.timestamp) : null;
if (sessionTimestamp !== null) {
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);
}
if (index >= effectiveStartLine) {
events.push(parsed);
@@ -116,12 +161,122 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
}
}
function getCandidateForFile(filePath: string): Candidate | null {
const sessionId = sessionIdByFile.get(filePath);
if (!sessionId) {
return null;
}
const fileCwd = sessionCwdByFile.get(filePath);
if (targetCwd && fileCwd && fileCwd !== targetCwd) {
return null;
}
if (targetCwd && !fileCwd) {
return {
sessionId,
filePath,
score: Number.POSITIVE_INFINITY,
reason: 'unknown-cwd'
};
}
const sessionTimestamp = sessionTimestampByFile.get(filePath);
if (sessionTimestamp === undefined) {
return {
sessionId,
filePath,
score: Number.POSITIVE_INFINITY,
reason: 'no-timestamp'
};
}
const diff = Math.abs(sessionTimestamp - referenceTimestampMs);
if (diff > sessionStartWindowMs) {
return {
sessionId,
filePath,
score: diff,
reason: 'outside-window'
};
}
return {
sessionId,
filePath,
score: diff,
reason: 'within-window'
};
}
function appendPendingEvents(filePath: string, events: CodexSessionEvent[], fileSessionId: string | null): void {
if (events.length === 0) {
return;
}
const existing = pendingEventsByFile.get(filePath);
if (existing) {
existing.events.push(...events);
if (!existing.fileSessionId && fileSessionId) {
existing.fileSessionId = fileSessionId;
}
return;
}
pendingEventsByFile.set(filePath, {
events: [...events],
fileSessionId
});
}
function 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 && !targetCwd && eventSessionId) {
setActiveSessionId(eventSessionId);
}
if (activeSessionId && eventSessionId && eventSessionId !== activeSessionId) {
continue;
}
opts.onEvent(event);
emittedForFile += 1;
}
return emittedForFile;
}
function flushPendingEventsForSession(sessionId: string): void {
if (pendingEventsByFile.size === 0) {
return;
}
let emitted = 0;
for (const [filePath, pending] of pendingEventsByFile.entries()) {
const matches = (pending.fileSessionId && pending.fileSessionId === sessionId)
|| filePath.endsWith(`-${sessionId}.jsonl`);
if (!matches) {
continue;
}
emitted += emitEvents(pending.events, pending.fileSessionId);
}
pendingEventsByFile.clear();
if (emitted > 0) {
logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emitted} pending events for session ${sessionId}`);
}
}
const sync = new InvalidateSync(async () => {
if (isClosing) {
return;
}
const files = await listSessionFiles(sessionsRoot);
const sortedFiles = await sortFilesByMtime(files);
let bestWithinWindow: Candidate | null = null;
let bestOutsideWindow: Candidate | null = null;
let bestNoTimestamp: Candidate | null = null;
let bestUnknownCwd: Candidate | null = null;
for (const filePath of sortedFiles) {
if (isClosing) {
@@ -142,29 +297,59 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
const lastProcessedLine = processedLineCounts.get(filePath) ?? 0;
const { events, totalLines } = await readSessionFile(filePath, lastProcessedLine);
processedLineCounts.set(filePath, totalLines);
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) {
setActiveSessionId(eventSessionId);
const candidate = !activeSessionId && targetCwd ? getCandidateForFile(filePath) : null;
if (!activeSessionId && targetCwd) {
appendPendingEvents(filePath, events, fileSessionId ?? candidate?.sessionId ?? null);
if (candidate) {
switch (candidate.reason) {
case 'within-window':
if (!bestWithinWindow || candidate.score < bestWithinWindow.score) {
bestWithinWindow = candidate;
}
break;
case 'outside-window':
if (!bestOutsideWindow || candidate.score < bestOutsideWindow.score) {
bestOutsideWindow = candidate;
}
break;
case 'no-timestamp':
if (!bestNoTimestamp) {
bestNoTimestamp = candidate;
}
break;
case 'unknown-cwd':
if (!bestUnknownCwd) {
bestUnknownCwd = candidate;
}
break;
}
}
if (activeSessionId && eventSessionId && eventSessionId !== activeSessionId) {
continue;
}
opts.onEvent(event);
emittedForFile += 1;
continue;
}
const emittedForFile = emitEvents(events, fileSessionId ?? null);
if (emittedForFile > 0) {
logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`);
}
}
if (!activeSessionId && targetCwd) {
const selectedCandidate = bestWithinWindow
?? bestOutsideWindow
?? bestNoTimestamp
?? bestUnknownCwd
?? null;
if (selectedCandidate) {
if (selectedCandidate.reason === 'within-window') {
logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${selectedCandidate.sessionId} within start window`);
} else {
logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${selectedCandidate.sessionId} via fallback (${selectedCandidate.reason})`);
}
setActiveSessionId(selectedCandidate.sessionId);
} else if (pendingEventsByFile.size > 0) {
logger.debug('[CODEX_SESSION_SCANNER] No session candidate matched yet; pending events buffered');
}
}
});
await initializeProcessedMessages();
@@ -217,3 +402,19 @@ function asRecord(value: unknown): Record<string, unknown> | null {
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function parseTimestamp(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.length > 0) {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}
function normalizePath(value: string): string {
const resolved = resolve(value);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}