File size: 6,755 Bytes
ab90450 b6ecafa ab90450 b6ecafa ab90450 b6ecafa ab90450 b6ecafa ab90450 b6ecafa ab90450 b6ecafa ab90450 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | import { readdirSync, readFileSync, statSync } from 'fs'
import { basename, join } from 'path'
import { config } from './config'
import { logger } from './logger'
const ACTIVE_THRESHOLD_MS = 90 * 60 * 1000
const DEFAULT_FILE_SCAN_LIMIT = 120
const FUTURE_TOLERANCE_MS = 60 * 1000
export interface CodexSessionStats {
sessionId: string
projectSlug: string
projectPath: string | null
model: string | null
userMessages: number
assistantMessages: number
inputTokens: number
outputTokens: number
totalTokens: number
firstMessageAt: string | null
lastMessageAt: string | null
isActive: boolean
}
interface ParsedFile {
path: string
mtimeMs: number
}
function asObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function deriveSessionId(filePath: string): string {
const name = basename(filePath, '.jsonl')
const match = name.match(/([0-9a-f]{8,}-[0-9a-f-]{8,})$/i)
return match?.[1] || name
}
function listRecentCodexSessionFiles(limit: number): ParsedFile[] {
const root = join(config.homeDir, '.codex', 'sessions')
const files: ParsedFile[] = []
const stack = [root]
while (stack.length > 0) {
const dir = stack.pop()
if (!dir) continue
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
continue
}
for (const entry of entries) {
const fullPath = join(dir, entry)
let stat
try {
stat = statSync(fullPath)
} catch {
continue
}
if (stat.isDirectory()) {
stack.push(fullPath)
continue
}
if (!stat.isFile() || !fullPath.endsWith('.jsonl')) continue
files.push({ path: fullPath, mtimeMs: stat.mtimeMs })
}
}
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
return files.slice(0, Math.max(1, limit))
}
function clampTimestamp(ms: number): number {
if (!Number.isFinite(ms) || ms <= 0) return 0
const now = Date.now()
// Guard against timezone/clock skew in session logs.
if (ms > now + FUTURE_TOLERANCE_MS) return now
return ms
}
function parseCodexSessionFile(filePath: string, fileMtimeMs: number): CodexSessionStats | null {
let content: string
try {
content = readFileSync(filePath, 'utf-8')
} catch {
return null
}
const lines = content.split('\n').filter(Boolean)
if (lines.length === 0) return null
let sessionId = deriveSessionId(filePath)
let projectPath: string | null = null
let model: string | null = null
let userMessages = 0
let assistantMessages = 0
let inputTokens = 0
let outputTokens = 0
let totalTokens = 0
let firstMessageAt: string | null = null
let lastMessageAt: string | null = null
for (const line of lines) {
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
continue
}
const entry = asObject(parsed)
if (!entry) continue
const timestamp = asString(entry.timestamp)
if (timestamp) {
if (!firstMessageAt) firstMessageAt = timestamp
lastMessageAt = timestamp
}
const entryType = asString(entry.type)
const payload = asObject(entry.payload)
if (entryType === 'session_meta' && payload) {
const metaId = asString(payload.id)
if (metaId) sessionId = metaId
const cwd = asString(payload.cwd)
if (cwd) projectPath = cwd
const metaModel = asString(payload.model)
if (metaModel) model = metaModel
const startedAt = asString(payload.timestamp)
if (startedAt && !firstMessageAt) firstMessageAt = startedAt
continue
}
if (entryType === 'response_item' && payload) {
const payloadType = asString(payload.type)
const role = asString(payload.role)
if (payloadType === 'message' && role === 'user') userMessages++
if (payloadType === 'message' && role === 'assistant') assistantMessages++
continue
}
if (entryType === 'event_msg' && payload) {
const msgType = asString(payload.type)
if (msgType !== 'token_count') continue
const info = asObject(payload.info)
const totals = info ? asObject(info.total_token_usage) : null
if (totals) {
const inTokens = asNumber(totals.input_tokens) || 0
const cached = asNumber(totals.cached_input_tokens) || 0
const outTokens = asNumber(totals.output_tokens) || 0
const allTokens = asNumber(totals.total_tokens) || (inTokens + cached + outTokens)
inputTokens = Math.max(inputTokens, inTokens + cached)
outputTokens = Math.max(outputTokens, outTokens)
totalTokens = Math.max(totalTokens, allTokens)
}
const limits = asObject(payload.rate_limits)
const limitName = limits ? asString(limits.limit_name) : null
if (!model && limitName) model = limitName
}
}
if (!lastMessageAt && !firstMessageAt) return null
const projectSlug = projectPath
? basename(projectPath)
: 'codex-local'
const parsedFirstMs = firstMessageAt ? clampTimestamp(new Date(firstMessageAt).getTime()) : 0
const parsedLastMs = lastMessageAt ? clampTimestamp(new Date(lastMessageAt).getTime()) : 0
const mtimeMs = clampTimestamp(fileMtimeMs)
const effectiveLastMs = Math.max(parsedLastMs, mtimeMs)
const effectiveFirstMs = parsedFirstMs || mtimeMs
const isActive = effectiveLastMs > 0 && (Date.now() - effectiveLastMs) < ACTIVE_THRESHOLD_MS
return {
sessionId,
projectSlug,
projectPath,
model,
userMessages,
assistantMessages,
inputTokens,
outputTokens,
totalTokens,
firstMessageAt: effectiveFirstMs ? new Date(effectiveFirstMs).toISOString() : null,
lastMessageAt: effectiveLastMs ? new Date(effectiveLastMs).toISOString() : null,
isActive,
}
}
export function scanCodexSessions(limit = DEFAULT_FILE_SCAN_LIMIT): CodexSessionStats[] {
try {
const files = listRecentCodexSessionFiles(limit)
const sessions: CodexSessionStats[] = []
for (const file of files) {
const parsed = parseCodexSessionFile(file.path, file.mtimeMs)
if (parsed) sessions.push(parsed)
}
sessions.sort((a, b) => {
const aTs = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0
const bTs = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0
return bTs - aTs
})
return sessions
} catch (err) {
logger.warn({ err }, 'Failed to scan Codex sessions')
return []
}
}
|