mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-27 18:05:12 +00:00
* Fix Antigravity provider detection, Codex fork double-counting, and tab ordering
Antigravity:
- Handle ephemeral port (--https_server_port 0) via lsof fallback
- Force reparse when cached turns are 0 (server may have been unavailable)
- Persist precomputed costUSD in session cache for correct pricing
- Map gemini-pro-agent to gemini-3.1-pro pricing
- Add display names for gemini-pro-agent and gemini-3.5-flash-low
Codex:
- Detect forked sessions via forked_from_id in session_meta
- Skip replayed parent events within 5s of fork creation time
- Use parent session ID in dedup key so parent+fork don't double-count
Menubar:
- Sort provider tabs by cost descending instead of enum declaration order
* Add tooling breakdowns to CLI dashboard and menubar
Surface skills, subagents, tools, and MCP server usage in both the
text dashboard and menubar app. Parse subagent types from Agent/Task
tool calls, aggregate with skills into a merged "Skills & Agents"
panel. Remove Top Sessions panel from CLI dashboard.
* Fix Codex always reporting 100% one-shot rate
Codex parser never set turnId or toolSequence, so retry detection
could not see Edit→Bash→Edit patterns across API calls. Now each
tool event builds a toolSequence step, and user messages generate
a new turnId to group calls into logical turns. Bump Codex cache
version to force re-parse of existing sessions.
* File-aware retry detection with typed ToolCall
Replace name-only Edit→Bash→Edit retry detection with file-aware
tracking. A retry now requires the SAME file to be re-edited after
a bash step, not just any edit after any bash.
- Add ToolCall type ({ tool, file?, command? }) replacing string[][]
for toolSequence across all types, providers, and cache
- Generate per-tool toolSequence in Claude parser with file paths
from Edit/Write tool inputs
- Extract file_path for Edit/Write tools in all three parser paths
(string, buffer, compact)
- Fix apiCallToCachedCall missing toolSequence (Claude data was never
cached with tool sequence info)
- Update Codex, Goose, Kiro parsers for typed ToolCall
- Bump session cache version to 3, codex cache version to 3
* Extract file paths from Codex patch_apply_end and function_call args
Codex stores file paths in patch_apply_end.changes keys and function
call arguments as a JSON string. Parse both to populate ToolCall.file,
enabling file-aware retry detection for Codex sessions.
* Fix retry detection fallback for file-less providers, update docs
- Use __no_file__ sentinel in countRetries so providers without file
paths (Kiro, Gemini, Vibe) fall back to name-based retry detection
- Reset pendingToolSequence on Codex dedup skip to prevent leak
- Update classifier tests to ToolCall[][] format
- Document file-aware one-shot rate in README
- Add unreleased changelog entries for tooling breakdowns and fixes
143 lines
3.7 KiB
TypeScript
143 lines
3.7 KiB
TypeScript
import { readFile, mkdir, stat, open, rename, unlink } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { randomBytes } from 'crypto'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
|
|
import type { ParsedProviderCall } from './providers/types.js'
|
|
|
|
const CODEX_CACHE_VERSION = 3
|
|
const CACHE_FILE = 'codex-results.json'
|
|
|
|
type FileFingerprint = { mtimeMs: number; sizeBytes: number }
|
|
|
|
type FileEntry = {
|
|
mtimeMs: number
|
|
sizeBytes: number
|
|
project: string
|
|
calls: ParsedProviderCall[]
|
|
}
|
|
|
|
type ResultCache = {
|
|
version: number
|
|
files: Record<string, FileEntry>
|
|
}
|
|
|
|
function getCacheDir(): string {
|
|
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
|
|
}
|
|
|
|
function getCachePath(): string {
|
|
return join(getCacheDir(), CACHE_FILE)
|
|
}
|
|
|
|
let memCache: ResultCache | null = null
|
|
|
|
async function loadCache(): Promise<ResultCache> {
|
|
if (memCache) return memCache
|
|
try {
|
|
const raw = await readFile(getCachePath(), 'utf-8')
|
|
const cache = JSON.parse(raw) as ResultCache
|
|
if (cache.version === CODEX_CACHE_VERSION && cache.files && typeof cache.files === 'object') {
|
|
memCache = cache
|
|
return cache
|
|
}
|
|
} catch {}
|
|
memCache = { version: CODEX_CACHE_VERSION, files: {} }
|
|
return memCache
|
|
}
|
|
|
|
function getEntry(cache: ResultCache, filePath: string, fp: FileFingerprint): FileEntry | null {
|
|
if (!Object.hasOwn(cache.files, filePath)) return null
|
|
const entry = cache.files[filePath]
|
|
if (entry && entry.mtimeMs === fp.mtimeMs && entry.sizeBytes === fp.sizeBytes) {
|
|
return entry
|
|
}
|
|
return null
|
|
}
|
|
|
|
export async function readCachedCodexResults(
|
|
filePath: string,
|
|
): Promise<ParsedProviderCall[] | null> {
|
|
try {
|
|
const s = await stat(filePath)
|
|
const cache = await loadCache()
|
|
const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size })
|
|
return entry?.calls ?? null
|
|
} catch {}
|
|
return null
|
|
}
|
|
|
|
export async function getCachedCodexProject(
|
|
filePath: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const s = await stat(filePath)
|
|
const cache = await loadCache()
|
|
const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size })
|
|
return entry?.project ?? null
|
|
} catch {}
|
|
return null
|
|
}
|
|
|
|
export async function fingerprintFile(
|
|
filePath: string,
|
|
): Promise<FileFingerprint | null> {
|
|
try {
|
|
const s = await stat(filePath)
|
|
return { mtimeMs: s.mtimeMs, sizeBytes: s.size }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function writeCachedCodexResults(
|
|
filePath: string,
|
|
project: string,
|
|
calls: ParsedProviderCall[],
|
|
fingerprint: FileFingerprint,
|
|
): Promise<void> {
|
|
try {
|
|
const cache = await loadCache()
|
|
cache.files[filePath] = {
|
|
mtimeMs: fingerprint.mtimeMs,
|
|
sizeBytes: fingerprint.sizeBytes,
|
|
project,
|
|
calls,
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
export async function flushCodexCache(): Promise<void> {
|
|
if (!memCache) return
|
|
try {
|
|
// Evict entries for files that no longer exist on disk
|
|
const paths = Object.keys(memCache.files)
|
|
for (const p of paths) {
|
|
try {
|
|
await stat(p)
|
|
} catch {
|
|
delete memCache.files[p]
|
|
}
|
|
}
|
|
|
|
const dir = getCacheDir()
|
|
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
|
|
const finalPath = getCachePath()
|
|
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
|
|
const payload = JSON.stringify(memCache)
|
|
const handle = await open(tempPath, 'w', 0o600)
|
|
try {
|
|
await handle.writeFile(payload, { encoding: 'utf-8' })
|
|
await handle.sync()
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
try {
|
|
await rename(tempPath, finalPath)
|
|
} catch (err) {
|
|
try { await unlink(tempPath) } catch {}
|
|
throw err
|
|
}
|
|
} catch {}
|
|
}
|