blob: 16ac0462fede34db15eb02cea39ac1b6ceb0f46b [file]
/**
* Read-only scanner + digest reader over foreign agent session stores
* (#1057): Claude Code (~/.claude/projects) and Codex (~/.codex).
*
* Boundary rules, in order of importance:
*
* 1. READ-ONLY. This store never writes, renames, locks, or truncates
* anything. It deliberately does NOT take the root-authority
* capability — that contract exists for Maka's own workspace; foreign
* stores belong to other tools and must stay byte-identical.
* 2. SCOPED. All reads resolve under the configured home directory's
* known subtrees (`.claude/projects`, `.codex`). Paths obtained from
* foreign metadata (Codex `rollout_path`) are realpath-checked to
* still live inside the source root — a hostile row cannot point the
* reader at ~/.ssh.
* 3. BOUNDED. Byte caps from @maka/core/foreign-session apply to every
* read (head window for metadata, head+tail window for titles, hard
* cap for digests); scan results cap at 50 sessions / 30 days.
* 4. UNTRUSTED. All extracted text passes the core sanitize/redact gate;
* malformed lines and unreadable files are skipped, never fatal.
*
* Codex is read SQLite-first (node:sqlite, readOnly; column availability
* introspected via PRAGMA so version drift degrades gracefully) with a
* rollout-file directory walk as fallback.
*/
import { open, readdir, realpath, stat, type FileHandle } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join, resolve, sep } from 'node:path';
import {
FOREIGN_SESSION_DIGEST_MAX_READ_BYTES,
FOREIGN_SESSION_HEAD_BYTES,
FOREIGN_SESSION_SCAN_MAX_AGE_MS,
FOREIGN_SESSION_SCAN_MAX_SESSIONS,
FOREIGN_SESSION_TITLE_WINDOW_BYTES,
claudeAssistantText,
claudeToolFilePaths,
claudeUserAuthoredText,
codexRolloutMessage,
codexRolloutSessionMeta,
collectClaudeMeta,
collectClaudeTitle,
createDigestAccumulator,
finishDigest,
isSafeForeignId,
normalizeCodexThreadRow,
parseForeignJsonLine,
pickClaudeTitle,
pushDigestFile,
pushDigestMessage,
sanitizeForeignMessage,
sanitizeForeignTitle,
type ClaudeTitleCandidates,
type ClaudeTranscriptMeta,
type CodexThreadRow,
type ForeignSessionDigest,
type ForeignSessionSource,
type ForeignSessionSummary,
} from '@maka/core/foreign-session';
export interface ForeignSessionScanOptions {
/** Only sessions whose recorded cwd equals this path (after realpath-free
* string normalization). Empty/undefined lists across all cwds. */
cwd?: string;
}
export interface ForeignSessionStore {
/** Which sources are enabled AND present on this machine. */
availableSources(): Promise<ForeignSessionSource[]>;
listSessions(options?: ForeignSessionScanOptions): Promise<ForeignSessionSummary[]>;
readDigest(summary: ForeignSessionSummary): Promise<ForeignSessionDigest>;
}
export interface ForeignSessionStoreOptions {
/** Overridable for tests. Defaults to os.homedir(). */
homeDir?: string;
/** Env for per-source enable flags. Defaults to process.env. */
env?: Record<string, string | undefined>;
}
/** Default on; set to '0' to disable (cloak-flag convention). */
export function isClaudeCodeImportEnabled(
env: Record<string, string | undefined> = process.env,
): boolean {
return env.MAKA_IMPORT_CLAUDE_CODE !== '0';
}
export function isCodexImportEnabled(
env: Record<string, string | undefined> = process.env,
): boolean {
return env.MAKA_IMPORT_CODEX !== '0';
}
export function createForeignSessionStore(
options: ForeignSessionStoreOptions = {},
): ForeignSessionStore {
return new FileForeignSessionStore(options.homeDir ?? homedir(), options.env ?? process.env);
}
class FileForeignSessionStore implements ForeignSessionStore {
constructor(
private readonly homeDir: string,
private readonly env: Record<string, string | undefined>,
) {}
private get claudeRoot(): string {
return join(this.homeDir, '.claude', 'projects');
}
private get codexRoot(): string {
return join(this.homeDir, '.codex');
}
async availableSources(): Promise<ForeignSessionSource[]> {
const sources: ForeignSessionSource[] = [];
if (isClaudeCodeImportEnabled(this.env) && (await isDirectory(this.claudeRoot))) {
sources.push('claude-code');
}
if (isCodexImportEnabled(this.env) && (await isDirectory(this.codexRoot))) {
sources.push('codex');
}
return sources;
}
async listSessions(options: ForeignSessionScanOptions = {}): Promise<ForeignSessionSummary[]> {
const sources = await this.availableSources();
const now = Date.now();
const results: ForeignSessionSummary[] = [];
if (sources.includes('claude-code')) {
results.push(...(await this.listClaudeSessions(options, now)));
}
if (sources.includes('codex')) {
results.push(...(await this.listCodexSessions(options, now)));
}
results.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
// Sanitize + redact display metadata at the single return choke point.
// cwd matching upstream used the raw values, so it is safe to scrub the
// returned cwd/gitBranch here — a TUI consumer must never receive terminal
// control characters, bidi overrides, or secrets in these fields. (title
// and id are already gated at their source; transcriptPath stays raw as an
// internal lookup key confined to the source roots.)
return results.slice(0, FOREIGN_SESSION_SCAN_MAX_SESSIONS).map((s) => ({
...s,
cwd: sanitizeForeignMessage(s.cwd),
...(s.gitBranch !== undefined ? { gitBranch: sanitizeForeignTitle(s.gitBranch) } : {}),
}));
}
/* ------------------------------ Claude ------------------------------ */
private async listClaudeSessions(
options: ForeignSessionScanOptions,
now: number,
): Promise<ForeignSessionSummary[]> {
const projectDirs = await listSubdirectories(this.claudeRoot);
const candidates: { path: string; mtimeMs: number }[] = [];
for (const dir of projectDirs) {
for (const entry of await listFilesWithSuffix(dir, '.jsonl')) {
candidates.push(entry);
}
}
// Newest transcripts first so the per-source cap keeps the useful ones
// and old files never get opened at all.
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
const results: ForeignSessionSummary[] = [];
for (const candidate of candidates) {
if (results.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS) break;
if (now - candidate.mtimeMs > FOREIGN_SESSION_SCAN_MAX_AGE_MS) break;
const summary = await this.scanClaudeTranscript(
candidate.path,
candidate.mtimeMs,
options.cwd,
);
if (summary) results.push(summary);
}
return results;
}
private async scanClaudeTranscript(
path: string,
mtimeMs: number,
cwdFilter: string | undefined,
): Promise<ForeignSessionSummary | undefined> {
const id = basename(path, '.jsonl');
if (!isSafeForeignId(id)) return undefined;
// cwd and isSidechain both live in the first `user`/`assistant` record,
// but a continued session can open with a run of `summary`/`mode` lines
// or a huge first message, so a fixed 4KB head silently misses them and
// drops the session. Grow the head window (64KB → 4MB) until cwd is seen.
// isSidechain is a per-file property (every record in the file carries the
// same value), so first-defined wins — no need to scan the whole file.
const meta: ClaudeTranscriptMeta = {};
for (const record of await readClaudeHeadRecords(path)) {
collectClaudeMeta(record, meta);
if (meta.cwd !== undefined && meta.isSidechain !== undefined) break;
}
if (meta.isSidechain === true) return undefined;
if (meta.cwd === undefined) return undefined;
if (cwdFilter !== undefined && normalizePath(meta.cwd) !== normalizePath(cwdFilter))
return undefined;
// Title fields use last-wins (freshest title in the tail beats an older
// one); firstUserMessage uses first-wins (opening request). Feed the head
// window first, then the tail, so both semantics fall out of iteration
// order (see collectClaudeTitle).
const titles: ClaudeTitleCandidates = {};
const titleHead = await readWindow(path, 'head', FOREIGN_SESSION_TITLE_WINDOW_BYTES);
const titleTail = await readWindow(path, 'tail', FOREIGN_SESSION_TITLE_WINDOW_BYTES);
for (const window of [titleHead, titleTail]) {
if (window === undefined) continue;
for (const line of window.split('\n')) {
const record = parseForeignJsonLine(line);
if (record) {
collectClaudeTitle(record, titles);
collectClaudeMeta(record, meta);
}
}
}
return {
source: 'claude-code',
id,
title: pickClaudeTitle(titles) || id,
cwd: meta.cwd,
updatedAtMs: meta.timestampMs ?? mtimeMs,
gitBranch: meta.gitBranch,
transcriptPath: path,
};
}
/* ------------------------------ Codex ------------------------------- */
private async listCodexSessions(
options: ForeignSessionScanOptions,
now: number,
): Promise<ForeignSessionSummary[]> {
// Try state DBs newest-generation first. A DB that cannot be opened or
// lacks the threads schema (rows === undefined) is skipped so a freshly
// created generation missing the schema doesn't shadow an older usable
// one. The FIRST usable DB is authoritative — its result is returned even
// when empty. Descending past it on an empty result would resurface stale
// rows from an older generation (e.g. a session archived in the newest DB
// reappearing active in an older one), and would send every no-match-cwd
// listing down the expensive rollout walk.
for (const dbPath of await codexStateDbsNewestFirst(this.codexRoot)) {
const rows = await readCodexThreadRows(dbPath, options.cwd);
if (rows === undefined) continue;
return this.codexRowsToSummaries(rows, options, now);
}
// No usable state DB at all → fall back to the rollout directory walk.
return this.listCodexSessionsFromRollouts(options, now);
}
private async codexRowsToSummaries(
rows: CodexThreadRow[],
options: ForeignSessionScanOptions,
now: number,
): Promise<ForeignSessionSummary[]> {
const results: ForeignSessionSummary[] = [];
for (const row of rows) {
if (results.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS) break;
const normalized = normalizeCodexThreadRow(row);
if (!normalized) continue;
if (now - normalized.updatedAtMs > FOREIGN_SESSION_SCAN_MAX_AGE_MS) continue;
if (options.cwd !== undefined && normalizePath(normalized.cwd) !== normalizePath(options.cwd))
continue;
const transcriptPath = await this.resolveCodexRolloutPath(
normalized.rolloutPath,
normalized.id,
);
if (transcriptPath === undefined) continue;
results.push({
source: normalized.source,
id: normalized.id,
title: normalized.title,
cwd: normalized.cwd,
updatedAtMs: normalized.updatedAtMs,
gitBranch: normalized.gitBranch,
transcriptPath,
});
}
return results;
}
private async listCodexSessionsFromRollouts(
options: ForeignSessionScanOptions,
now: number,
): Promise<ForeignSessionSummary[]> {
const sessionsRoot = join(this.codexRoot, 'sessions');
const files = await walkRolloutFiles(sessionsRoot, now);
const results: ForeignSessionSummary[] = [];
for (const file of files) {
if (results.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS) break;
const head = await readWindow(file.path, 'head', FOREIGN_SESSION_HEAD_BYTES);
if (head === undefined) continue;
let meta: ReturnType<typeof codexRolloutSessionMeta>;
let firstUserText: string | undefined;
for (const line of head.split('\n')) {
const record = parseForeignJsonLine(line);
if (!record) continue;
meta ??= codexRolloutSessionMeta(record);
if (firstUserText === undefined) {
const message = codexRolloutMessage(record);
if (message?.role === 'user') firstUserText = message.text;
}
if (meta && firstUserText !== undefined) break;
}
if (!meta?.id || meta.cwd === undefined) continue;
if (!isSafeForeignId(meta.id)) continue;
// The transcript filename must belong to this session (defends against
// renamed / planted rollout files, as in the DB path).
if (!rolloutFilenameMatchesId(basename(file.path), meta.id)) continue;
if (options.cwd !== undefined && normalizePath(meta.cwd) !== normalizePath(options.cwd))
continue;
results.push({
source: 'codex',
id: meta.id,
// session_meta has no title; the first user message in the head
// window is the best available label (Grok Build does the same).
title: sanitizeForeignTitle(firstUserText) || meta.id,
cwd: meta.cwd,
updatedAtMs: meta.timestampMs ?? file.mtimeMs,
gitBranch: meta.gitBranch,
transcriptPath: file.path,
});
}
return results;
}
/**
* Realpath-confine a rollout path from the (untrusted) DB to ~/.codex, and
* require the transcript filename to belong to this thread — the id (a uuid)
* is the trailing component of `rollout-<timestamp>-<id>.jsonl`, so a
* mismatch means the row points at some other session's transcript (orphan
* row or a forged path) and is dropped. The timestamp format varies across
* Codex versions (ISO datetime or epoch), so match by the id suffix rather
* than parsing the timestamp.
*/
private async resolveCodexRolloutPath(
rolloutPath: string,
expectedId: string,
): Promise<string | undefined> {
try {
const real = await realpath(resolve(rolloutPath));
const root = await realpath(this.codexRoot);
if (real !== root && !real.startsWith(root + sep)) return undefined;
if (!(await stat(real)).isFile()) return undefined;
if (!rolloutFilenameMatchesId(basename(real), expectedId)) return undefined;
return real;
} catch {
return undefined;
}
}
/* ------------------------------ Digest ------------------------------ */
async readDigest(summary: ForeignSessionSummary): Promise<ForeignSessionDigest> {
// The transcript path was produced by our own scan, but re-confine it
// anyway: digests can be requested long after the scan, and the file
// may have been swapped for a symlink in between.
const root = summary.source === 'claude-code' ? this.claudeRoot : this.codexRoot;
const real = await realpath(resolve(summary.transcriptPath));
const realRoot = await realpath(root);
if (real !== realRoot && !real.startsWith(realRoot + sep)) {
throw new Error('Foreign transcript escaped its source root');
}
const acc = createDigestAccumulator();
// Open ONCE and read through the single fd: a stat-then-readFile pair has
// a TOCTOU window (the regular file could be swapped for a FIFO — which
// would block readFile forever — or grown past the cap between the two
// calls). fstat on the held fd, reject anything but a regular file, and
// never read more than the cap regardless of the size we observe.
let handle: Awaited<ReturnType<typeof open>> | undefined;
let text: string;
try {
handle = await open(real, 'r');
const st = await handle.stat();
if (!st.isFile()) throw new Error('Foreign transcript is not a regular file');
if (st.size > FOREIGN_SESSION_DIGEST_MAX_READ_BYTES) {
text = await readHandleTailWindow(handle, st.size, FOREIGN_SESSION_DIGEST_MAX_READ_BYTES);
acc.warnings.push(
`transcript is ${st.size} bytes; only the trailing ${FOREIGN_SESSION_DIGEST_MAX_READ_BYTES} bytes were read`,
);
} else {
const buffer = Buffer.alloc(st.size);
await handle.read(buffer, 0, st.size, 0);
text = buffer.toString('utf8');
}
} finally {
await handle?.close();
}
let dropped = 0;
for (const line of text.split('\n')) {
if (line.trim().length === 0) continue;
const record = parseForeignJsonLine(line);
if (!record) {
dropped += 1;
continue;
}
if (summary.source === 'claude-code') {
// Sidechain records are a sub-agent's own conversation interleaved
// into the main transcript; they belong to neither role of the main
// session and must not enter its handoff (drop them for BOTH the user
// and assistant branches, not just the user one).
if (record.isSidechain === true) continue;
if (record.type === 'user') {
// claudeUserAuthoredText drops isMeta / isCompactSummary records so
// Claude's own injected context and generated compaction summaries
// never enter the handoff as user-authored text.
const text = claudeUserAuthoredText(record);
if (text !== undefined) pushDigestMessage(acc, 'user', text);
} else if (record.type === 'assistant') {
const text = claudeAssistantText(record);
if (text !== undefined) pushDigestMessage(acc, 'assistant', text);
for (const path of claudeToolFilePaths(record)) pushDigestFile(acc, path);
}
} else {
const message = codexRolloutMessage(record);
if (message) pushDigestMessage(acc, message.role, message.text);
}
}
if (dropped > 0) acc.warnings.push(`${dropped} malformed transcript lines were skipped`);
return finishDigest(acc, {
source: summary.source,
id: summary.id,
title: summary.title,
cwd: summary.cwd,
gitBranch: summary.gitBranch,
updatedAtMs: summary.updatedAtMs,
});
}
}
/* ------------------------------ fs helpers ------------------------------ */
async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
async function listSubdirectories(root: string): Promise<string[]> {
try {
const entries = await readdir(root, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name));
} catch {
return [];
}
}
async function listFilesWithSuffix(
dir: string,
suffix: string,
): Promise<{ path: string; mtimeMs: number }[]> {
const out: { path: string; mtimeMs: number }[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(suffix)) continue;
const path = join(dir, entry.name);
try {
out.push({ path, mtimeMs: (await stat(path)).mtimeMs });
} catch {
// Deleted mid-scan; skip.
}
}
} catch {
// Unreadable project dir; skip.
}
return out;
}
/** Codex sessions/YYYY/MM/DD/rollout-*.jsonl walk, newest days first. */
async function walkRolloutFiles(
root: string,
now: number,
): Promise<{ path: string; mtimeMs: number }[]> {
const out: { path: string; mtimeMs: number }[] = [];
const years = (await listSubdirectories(root)).sort().reverse();
for (const year of years) {
const months = (await listSubdirectories(year)).sort().reverse();
for (const month of months) {
const days = (await listSubdirectories(month)).sort().reverse();
for (const day of days) {
for (const file of await listFilesWithSuffix(day, '.jsonl')) {
if (!basename(file.path).startsWith('rollout-')) continue;
if (now - file.mtimeMs > FOREIGN_SESSION_SCAN_MAX_AGE_MS) continue;
out.push(file);
}
// Enough candidates for the cap even after per-file drops.
if (out.length >= FOREIGN_SESSION_SCAN_MAX_SESSIONS * 2) {
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
return out;
}
}
}
}
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
return out;
}
/** All ~/.codex/state_N.sqlite paths, newest generation first. */
async function codexStateDbsNewestFirst(codexRoot: string): Promise<string[]> {
try {
const entries = await readdir(codexRoot);
return entries
.filter((name) => /^state_\d+\.sqlite$/.test(name))
.sort((a, b) => Number(b.match(/\d+/)?.[0] ?? 0) - Number(a.match(/\d+/)?.[0] ?? 0))
.map((name) => join(codexRoot, name));
} catch {
return [];
}
}
/**
* Codex source tokens as stored in the DB — bare for cli/vscode, JSON-wrapped
* for the `custom` variants. Used as bound `source IN (…)` params so archived
* / foreign-source rows are excluded IN SQL (before LIMIT), not after.
*/
const CODEX_SOURCE_SQL_VALUES = ['cli', 'vscode', '{"custom":"atlas"}', '{"custom":"chatgpt"}'];
/**
* Read candidate thread rows from one state DB, filtered and ordered in SQL.
* undefined = DB unusable (cannot open, or lacks the id/rollout_path columns)
* so the caller descends to an older generation. An empty array is a real
* "this DB has no matching threads".
*/
async function readCodexThreadRows(
dbPath: string,
cwdFilter?: string,
): Promise<CodexThreadRow[] | undefined> {
try {
const sqlite = await import('node:sqlite');
const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
try {
const columns = new Set(
(db.prepare('PRAGMA table_info(threads)').all() as { name?: unknown }[])
.map((c) => (typeof c.name === 'string' ? c.name : ''))
.filter((n) => n.length > 0),
);
if (!columns.has('id') || !columns.has('rollout_path')) return undefined;
// Every identifier below is drawn from this fixed allowlist, never from
// the DB, so interpolation is injection-safe; values are bound params.
const wanted = [
'id',
'rollout_path',
'cwd',
'title',
'first_user_message',
'updated_at_ms',
'updated_at',
'git_branch',
'archived',
'source',
].filter((c) => columns.has(c));
const where: string[] = [];
const params: string[] = [];
if (columns.has('archived')) where.push('(archived IS NULL OR archived = 0)');
if (columns.has('source')) {
where.push(`source IN (${CODEX_SOURCE_SQL_VALUES.map(() => '?').join(', ')})`);
params.push(...CODEX_SOURCE_SQL_VALUES);
}
// Filter cwd IN SQL, before LIMIT: otherwise a multi-project store with
// many newer threads from other directories fills the LIMIT window and
// the target project's older thread never reaches the JS-side filter.
// This is a COARSE pre-filter — the exact normalized path or its
// trailing-separator variant — so a stored `/target/` isn't dropped
// before the authoritative two-sided normalizePath() comparison in
// codexRowsToSummaries(); SQL cannot run normalizePath on the stored side.
if (cwdFilter !== undefined && columns.has('cwd')) {
const norm = normalizePath(cwdFilter);
where.push('(cwd = ? OR cwd = ?)');
params.push(norm, norm + sep);
}
const orderColumn = columns.has('updated_at_ms')
? 'updated_at_ms'
: columns.has('updated_at')
? 'updated_at'
: 'id';
const sql =
`SELECT ${wanted.join(', ')} FROM threads` +
(where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '') +
` ORDER BY ${orderColumn} DESC LIMIT ${FOREIGN_SESSION_SCAN_MAX_SESSIONS * 2}`;
return db.prepare(sql).all(...params) as CodexThreadRow[];
} finally {
db.close();
}
} catch {
return undefined;
}
}
/**
* Parsed records from the head of a Claude transcript, growing the read
* window (64KB → 4MB) so a session that opens with a run of summary lines or
* a very large first message still yields its cwd record. Stops early once a
* record carrying `cwd` is seen.
*/
async function readClaudeHeadRecords(path: string): Promise<Record<string, unknown>[]> {
for (let bytes = 64 * 1024; ; bytes *= 4) {
const capped = Math.min(bytes, CLAUDE_HEAD_MAX_BYTES);
const window = await readWindow(path, 'head', capped);
if (window === undefined) return [];
const records: Record<string, unknown>[] = [];
let sawCwd = false;
for (const line of window.split('\n')) {
const record = parseForeignJsonLine(line);
if (!record) continue;
records.push(record);
if (typeof record.cwd === 'string') sawCwd = true;
}
if (sawCwd || capped >= CLAUDE_HEAD_MAX_BYTES || capped >= (await fileSize(path)))
return records;
}
}
const CLAUDE_HEAD_MAX_BYTES = 4 * 1024 * 1024;
async function fileSize(path: string): Promise<number> {
try {
return (await stat(path)).size;
} catch {
return 0;
}
}
/** Read the trailing `bytes` of an open handle, dropping the partial first line. */
async function readHandleTailWindow(
handle: FileHandle,
size: number,
bytes: number,
): Promise<string> {
const length = Math.min(bytes, size);
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, size - length);
const text = buffer.toString('utf8');
if (length >= size) return text; // whole file — no partial first line
const nl = text.indexOf('\n');
return nl === -1 ? '' : text.slice(nl + 1);
}
/**
* Bounded read of a file's head or tail window; undefined on any error. A
* tail window drops its partial first line so a mid-line cut isn't parsed as
* a malformed record (and isn't reported as one).
*/
async function readWindow(
path: string,
where: 'head' | 'tail',
bytes: number,
): Promise<string | undefined> {
let handle: FileHandle | undefined;
try {
handle = await open(path, 'r');
const size = (await handle.stat()).size;
if (where === 'tail') return await readHandleTailWindow(handle, size, bytes);
const length = Math.min(bytes, size);
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, 0);
return buffer.toString('utf8');
} catch {
return undefined;
} finally {
await handle?.close();
}
}
/**
* A Codex rollout file `rollout-<timestamp>-<id>.jsonl` belongs to thread
* `id` when the basename opens with `rollout-` and ends with `-<id>.jsonl`.
* Timestamp-format-agnostic: the id (a uuid) is always the trailing segment.
*/
function rolloutFilenameMatchesId(base: string, id: string): boolean {
return base.startsWith('rollout-') && base.endsWith(`-${id}.jsonl`);
}
function normalizePath(path: string): string {
const resolved = resolve(path);
return resolved.endsWith(sep) && resolved !== sep ? resolved.slice(0, -1) : resolved;
}