From f03c7f2cc8d3a628be15b1a9cca026856b1e1970 Mon Sep 17 00:00:00 2001 From: weishu Date: Fri, 26 Dec 2025 21:10:51 +0800 Subject: [PATCH] feat: add intelligent session matching based on cwd and timestamp --- cli/src/codex/codexLocalLauncher.ts | 2 + cli/src/codex/utils/codexSessionScanner.ts | 247 +++++++++++++++++++-- 2 files changed, 226 insertions(+), 23 deletions(-) diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 9d15ff53..a2b4986a 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -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); }, diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 900d307b..041faaef 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -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 { 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(); const watchers = new Map void>(); const sessionIdByFile = new Map(); + const sessionCwdByFile = new Map(); + const sessionTimestampByFile = new Map(); + const pendingEventsByFile = new Map(); + const sessionMetaParsed = new Set(); 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 { 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 | 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; +}