import * as NodeOS from "node:os"; import { AgentSessionScanError, ClaudeSettings, CodexSettings, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, type AgentSessionImportSource, type AgentSessionProjectCandidate, type AgentSessionProjectGit, type AgentSessionScanResult, type ProviderInstanceConfig, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { isBareGitConfig, parseOriginUrlFromGitConfig } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { resolvePiSessionsDir } from "@t3tools/provider-pi/server/usage"; import * as ServerSettings from "../serverSettings.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, TranscriptJsonLimitError, } from "./AgentSessionJson.ts"; const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; class TranscriptParseError extends Schema.TaggedError()( "TranscriptParseError", { filePath: Schema.String, cause: Schema.Defect() }, ) { override get message(): string { return `Could not parse the transcript at ${this.filePath}.`; } } const METADATA_READ_BYTES = 8 * 1024; const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; const MAX_CODEX_SESSION_INDEX_BYTES = 1024 * 1024; const MAX_TRANSCRIPTS_PER_SOURCE = 5000; const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; // Screenshot-heavy transcripts can reach GiB; select fields while streaming to avoid allocating payloads. const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORTED_MESSAGES = 200; const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORT_TRANSCRIPTS = 100; const MAX_IMPORT_RECORDS = 100_000; /** * open() of a file under macOS privacy protection waits on a consent prompt * that a headless server never gets, and a FIFO waits for a writer. Either * pins a libuv pool thread that interruption cannot free, so git files of * arbitrary transcript cwds are read by a `cat` child we can kill instead. */ const GIT_FILE_READ_DEADLINE = "2 seconds"; const MAX_GIT_FILE_BYTES = 1024 * 1024; // Reads run concurrently, so each phase waits one deadline per batch of this // many blocked files rather than one per blocked file. const GIT_FILE_READ_CONCURRENCY = 16; // A blocked path is skipped for a while, then read again in case it was fixed. const BLOCKED_GIT_FILE_RETRY_MS = 5 * 60 * 1000; const MAX_BLOCKED_GIT_FILE_PATHS = 1024; const TranscriptContentBlock = Schema.Struct({ type: Schema.optional(Schema.String), text: Schema.optional(Schema.String), }); const TranscriptMessage = Schema.Struct({ role: Schema.optional(Schema.String), content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), model: Schema.optional(Schema.String), }); const CodexTurnMetadata = Schema.Struct({ turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), }); const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), // Pi entries form a tree through id/parentId; session_info entries carry its name. id: Schema.optional(Schema.String), parentId: Schema.optional(Schema.NullOr(Schema.String)), name: Schema.optional(Schema.String), timestamp: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), aiTitle: Schema.optional(Schema.String), customTitle: Schema.optional(Schema.String), isSidechain: Schema.optional(Schema.Boolean), isMeta: Schema.optional(Schema.Boolean), isCompactSummary: Schema.optional(Schema.Boolean), message: Schema.optional(TranscriptMessage), payload: Schema.optional( Schema.Struct({ id: Schema.optional(Schema.String), session_id: Schema.optional(Schema.String), type: Schema.optional(Schema.String), role: Schema.optional(Schema.String), message: Schema.optional(Schema.String), model: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), history_base: Schema.optional(Schema.Unknown), content: Schema.optional(Schema.Array(TranscriptContentBlock)), internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), }), ), }); const CodexSessionIndexEntry = Schema.Struct({ id: Schema.String, thread_name: Schema.String, }); const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); const decodePiEntryLinks = Schema.decodeUnknownOption( Schema.Struct({ type: TranscriptRecord.fields.type, id: TranscriptRecord.fields.id, parentId: TranscriptRecord.fields.parentId, }), ); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); const decodeCodexHistoryBase = Schema.decodeUnknownOption( Schema.Struct({ thread_id: Schema.String, end_byte_offset: Schema.Number }), ); // Original rollouts end in their thread ID, rewound ones in `_`. const CODEX_ROLLOUT_ID_PATTERN = /[-_]([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption( Schema.fromJsonString(CodexSessionIndexEntry), ); type DecodedTranscriptRecord = typeof TranscriptRecord.Type; interface AgentSessionTranscriptMetadata { readonly source: AgentSessionSource; readonly providerInstanceId: ProviderInstanceId; readonly fallbackSessionId: string; readonly lastActiveAtMs: number; } export interface AgentSessionThreadMessage { readonly role: "user" | "assistant"; readonly text: string; readonly createdAt: string; } export interface AgentSessionThread { readonly source: AgentSessionSource; readonly providerInstanceId: ProviderInstanceId; readonly providerSessionId: string; readonly title: string; readonly model: string | null; readonly createdAt: string; readonly updatedAt: string; readonly messages: ReadonlyArray; readonly omittedMessageCount: number; } export type AgentSessionRecentThread = | { readonly _tag: "Importable"; readonly thread: AgentSessionThread; readonly source: AgentSessionImportSource; } | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } | { readonly _tag: "Skipped" }; export class AgentSessionScanner extends Context.Service< AgentSessionScanner, { readonly scan: Effect.Effect; readonly recentThreads: ( workspaceRoot: string, completedSources?: ReadonlyArray, ) => Stream.Stream; } >()("t3/project/AgentSessionScanner") {} type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; interface RawCandidate { readonly cwd: string; readonly source: AgentSessionSource; readonly providerInstanceId: ProviderInstanceId; readonly homePath: string; readonly threadCount: number; readonly lastActiveAtMs: number | null; readonly transcripts: ReadonlyArray<{ readonly filePath: string; readonly mtimeMs: number | null; }>; } interface TranscriptCandidate { readonly filePath: string; readonly mtimeMs: number; readonly providerInstanceId: ProviderInstanceId; readonly homePath: string; readonly size: number; } interface MetadataReadBudget { bytesRemaining: number; operationsRemaining: number; recordsRemaining: number; truncated: boolean; } function selectMetadataTranscripts(transcripts: ReadonlyArray) { const selected: Array = []; let pending = Array.from( Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), (entries) => entries.values(), ); while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { const nextRound: typeof pending = []; for (const iterator of pending) { if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; const next = iterator.next(); if (next.done) continue; selected.push(next.value); nextRound.push(iterator); } pending = nextRound; } return selected; } function splitTranscriptRecords(contents: string, limit: number): string[] { const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; return records.split("\n", limit); } function isTextContentBlock(block: typeof TranscriptContentBlock.Type): boolean { return block.type === "text" || block.type === "input_text" || block.type === "output_text"; } function extractText( content: string | ReadonlyArray | undefined, ): string { if (typeof content === "string") return content.trim(); if (content === undefined) return ""; return content .filter(isTextContentBlock) .map((block) => block.text?.trim() ?? "") .filter((text) => text.length > 0) .join("\n"); } function normalizeTimestamp(value: string | undefined, fallback: string): string { if (value === undefined) return fallback; const parsed = DateTime.make(value); return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; } function codexTurnId(metadata: unknown): string | null { const decoded = decodeCodexTurnMetadata(metadata); if ( Option.isNone(decoded) || typeof decoded.value.turn_id !== "string" || decoded.value.turn_id.trim().length === 0 ) { return null; } return decoded.value.turn_id; } function isHiddenClaudeRecord(record: DecodedTranscriptRecord): boolean { return record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true; } // Claude repeats session fields on records that need not be retained. interface ClaudeFields { readonly sessionId: string | undefined; readonly title: string | undefined; readonly customTitle: string | null; readonly model: string | undefined; } const emptyClaudeFields: ClaudeFields = { sessionId: undefined, title: undefined, customTitle: null, model: undefined, }; function foldClaudeFields(fields: ClaudeFields, record: DecodedTranscriptRecord): ClaudeFields { if (isHiddenClaudeRecord(record)) return fields; const model = record.message?.model?.trim(); return { sessionId: record.sessionId?.trim() || fields.sessionId, title: record.aiTitle?.trim() || fields.title, // Claude flushes the AI title after the user's rename; an empty rename clears it. customTitle: record.customTitle === undefined ? fields.customTitle : record.customTitle.trim() || null, // Claude uses this sentinel for local error responses. It is not a // model ID that can be selected when the imported session resumes. model: model && model !== "" ? model : fields.model, }; } // Codex records setup context as user text ahead of the real request. Skip // these leading blocks when deriving a title; imported messages keep them. const CODEX_TITLE_PREAMBLE = /^(?:\s*(?:<(recommended_plugins|environment_context|user_instructions|INSTRUCTIONS|in-app-browser-context)(?:\s[^>]*)?>[\s\S]*?<\/\1>|# AGENTS\.md instructions\b[^\n]*|## My request for Codex:))+/; // Codex appends an entry per rename; like Codex, the newest nonempty name wins. function parseCodexThreadNames(contents: string): ReadonlyMap { const names = new Map(); for (const line of contents.split("\n")) { const entry = Option.getOrUndefined(decodeCodexSessionIndexEntry(line)); const name = entry?.thread_name.trim(); if (entry !== undefined && name) names.set(entry.id, name); } return names; } // A rewound paginated Codex thread continues in a new rollout that inherits a // byte prefix of the rollout it replaced. function codexHistoryBase(records: ReadonlyArray) { const base = Option.getOrUndefined( decodeCodexHistoryBase( records.find((record) => record.type === "session_meta")?.payload?.history_base, ), ); return base !== undefined && Number.isSafeInteger(base.end_byte_offset) && base.end_byte_offset >= 0 ? base : undefined; } function deriveTitle(source: AgentSessionSource, text: string): string | undefined { const request = source === "codex" ? text.replace(CODEX_TITLE_PREAMBLE, "") : text; return request.trim().split("\n")[0]?.slice(0, 100).trim() || undefined; } // Pi keeps abandoned branches in the file; its active branch runs from the last entry to the root. function activePiBranch( records: ReadonlyArray, ): ReadonlyArray { const entries = records.filter((record) => record.type !== "session"); const leaf = entries.at(-1); // Version 1 sessions predate the tree and are linear. if (leaf?.id === undefined) return entries; const byId = new Map( entries.flatMap((entry) => (entry.id === undefined ? [] : [[entry.id, entry] as const])), ); const branch: Array = []; for ( let entry: DecodedTranscriptRecord | undefined = leaf; entry !== undefined && branch.length < entries.length; entry = entry.parentId ? byId.get(entry.parentId) : undefined ) { branch.push(entry); } return branch.toReversed(); } function isPiConversationRecord( record: DecodedTranscriptRecord, ): record is DecodedTranscriptRecord & { readonly message: typeof TranscriptMessage.Type & { readonly role: "user" | "assistant" }; } { return ( record.type === "message" && (record.message?.role === "user" || record.message?.role === "assistant") ); } export function parseAgentSessionTranscript( input: AgentSessionTranscriptMetadata & { readonly contents: string; }, lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), ): AgentSessionThread | null { if (lines.length > MAX_IMPORT_RECORDS) return null; const records = lines.flatMap((line) => Option.toArray(decodeTranscriptRecord(line))); const claudeFields = input.source === "claudeAgent" ? records.reduce(foldClaudeFields, emptyClaudeFields) : emptyClaudeFields; return parseAgentSessionRecords(input, records, claudeFields); } function parseAgentSessionRecords( input: AgentSessionTranscriptMetadata, records: ReadonlyArray, claudeFields: ClaudeFields, ): AgentSessionThread | null { const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); // Claude filenames are session IDs. Codex rollout filenames include extra // timestamp text, so only transcript metadata can provide a resumable ID. let providerSessionId = input.source === "codex" ? "" : input.source === "pi" ? records.find((record) => record.type === "session")?.id?.trim() || input.fallbackSessionId : (claudeFields.sessionId ?? input.fallbackSessionId); // Pi titles a session by its latest session_info on any branch; an empty name clears it. const title = input.source === "pi" ? records.findLast((record) => record.type === "session_info")?.name?.trim() || null : (claudeFields.title ?? null); let model = claudeFields.model ?? null; let derivedTitle: string | undefined; let hasCodexSessionId = false; const messages: Array = []; let firstUserMessage: | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) | undefined; const canonicalCodexResponseUserIndices = new Set(); let canonicalUserTextsInTurn = new Set(); let responseUsersInTurn: Array<{ readonly index: number; readonly turnId: string; readonly text: string; }> = []; const finishCodexTurn = () => { const canonicalTurnIds = new Set( responseUsersInTurn.flatMap((responseUser) => canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], ), ); for (const responseUser of responseUsersInTurn) { if (canonicalTurnIds.has(responseUser.turnId)) { canonicalCodexResponseUserIndices.add(responseUser.index); } } canonicalUserTextsInTurn = new Set(); responseUsersInTurn = []; }; if (input.source === "codex") { let recordIndex = -1; for (const record of records) { recordIndex += 1; if ( record.type === "response_item" && record.payload?.type === "message" && record.payload.role === "assistant" ) { finishCodexTurn(); continue; } if (record.type === "event_msg" && record.payload?.type === "user_message") { const text = record.payload.message?.trim() ?? ""; if (text.length > 0) canonicalUserTextsInTurn.add(text); continue; } if ( record.type === "response_item" && record.payload?.type === "message" && record.payload.role === "user" ) { const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); const text = extractText(record.payload.content); if (turnId !== null && text.length > 0) { responseUsersInTurn.push({ index: recordIndex, turnId, text }); } } } finishCodexTurn(); } let conversationMessageCount = 0; const retainMessage = ( message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, ) => { if (firstUserMessage === undefined && message.role === "user") { firstUserMessage = message; } if (derivedTitle === undefined && message.role === "user") { derivedTitle = deriveTitle(input.source, message.text); } conversationMessageCount += 1; messages.push(message); if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); }; const hasMatchingCodexEventInTurn = (text: string) => { const comparisonText = text.trim(); for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message?.role === "assistant") return false; if ( message?.role === "user" && !message.codexResponseUser && message.text.trim() === comparisonText ) { return true; } } return false; }; let recordIndex = -1; for (const record of input.source === "pi" ? activePiBranch(records) : records) { recordIndex += 1; if (input.source === "pi") { if (!isPiConversationRecord(record)) continue; const text = extractText(record.message.content); if (text.length === 0) continue; retainMessage({ role: record.message.role, text, createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), codexResponseUser: false, }); continue; } if (input.source === "claudeAgent") { if (isHiddenClaudeRecord(record)) continue; if (record.type !== "user" && record.type !== "assistant") continue; const text = extractText(record.message?.content); if (text.length === 0) continue; retainMessage({ role: record.type, text, createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), codexResponseUser: false, }); continue; } if (record.type === "session_meta") { const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); if (!hasCodexSessionId && sessionId) { providerSessionId = sessionId; hasCodexSessionId = true; } continue; } if (record.type === "turn_context" && record.payload?.model?.trim()) { model = record.payload.model.trim(); continue; } if (record.type === "event_msg" && record.payload?.type === "user_message") { const text = record.payload.message ?? ""; if (text.trim().length === 0) continue; // Codex can duplicate prompts across formats; remove only matching response copies. for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message?.role === "assistant") break; if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { if (firstUserMessage === message) firstUserMessage = undefined; messages.splice(index, 1); conversationMessageCount -= 1; break; } } retainMessage({ role: "user", text, createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), codexResponseUser: false, }); continue; } if ( record.type !== "response_item" || record.payload?.type !== "message" || (record.payload.role !== "user" && record.payload.role !== "assistant") ) { continue; } const extractedText = extractText(record.payload.content); if (extractedText.length === 0) continue; if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { continue; } if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { continue; } retainMessage({ role: record.payload.role, text: extractedText, createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), codexResponseUser: record.payload.role === "user", }); } const visibleMessages = messages.map( ({ codexResponseUser: _codexResponseUser, ...message }) => message, ); if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; const firstUserMessageRetained = messages.includes(firstUserMessage); const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; const retainedMessages = firstUserMessageRetained ? visibleMessages : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; return { source: input.source, providerInstanceId: input.providerInstanceId, providerSessionId, title: claudeFields.customTitle ?? title ?? derivedTitle ?? "Imported thread", model, createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, updatedAt: fallbackTimestamp, messages: retainedMessages, omittedMessageCount: conversationMessageCount - retainedMessages.length, }; } function extractDecodedCwd(record: DecodedTranscriptRecord): string | null { const cwd = record.cwd?.trim() || record.payload?.cwd?.trim(); return cwd && cwd.length > 0 ? cwd : null; } function shouldRetainDecodedRecord( source: AgentSessionSource, record: DecodedTranscriptRecord, ): boolean { // Any Pi entry can be a link on the active branch. if (source === "pi") return true; if (source === "claudeAgent") { if (isHiddenClaudeRecord(record) || (record.type !== "user" && record.type !== "assistant")) { return false; } const content = record.message?.content; return typeof content === "string" ? content.trim().length > 0 : (content?.some( (block) => isTextContentBlock(block) && (block.text?.trim().length ?? 0) > 0, ) ?? false); } if (extractDecodedCwd(record) !== null) return true; return ( record.type === "session_meta" || record.type === "turn_context" || (record.type === "event_msg" && record.payload?.type === "user_message") || (record.type === "response_item" && record.payload?.type === "message" && (record.payload.role === "user" || record.payload.role === "assistant")) ); } function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { const normalized = `${value.replaceAll("\\", "/")}/`; return caseFold ? normalized.toLowerCase() : normalized; } function isT3ManagedWorktree( candidatePath: string, worktreesDir: string, caseFold: boolean, ): boolean { const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); return ( normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || normalized.includes("/.t3/worktrees/") ); } // false stops metadata scanning for an excluded rollout, even if later records contain a cwd. function extractCwd(line: string, source: AgentSessionSource): string | null | false { let parsed: unknown; try { parsed = JSON.parse(line); } catch { return null; } if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { return record.cwd; } const payload = record.payload; if (typeof payload === "object" && payload !== null) { if (source === "codex" && record.type === "session_meta") { const sessionSource = (payload as Record).source; if ( typeof sessionSource === "object" && sessionSource !== null && !Array.isArray(sessionSource) && (Object.hasOwn(sessionSource, "subagent") || Object.hasOwn(sessionSource, "subAgent")) ) { return false; } } const nested = (payload as Record).cwd; if (typeof nested === "string" && nested.trim().length > 0) { return nested; } } return null; } function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { return { filePath, size: Number(stats.size), mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), device: Number(stats.dev), inode: Option.match(stats.ino, { onNone: () => null, onSome: String }), birthtimeMs: Option.match(stats.birthtime, { onNone: () => null, onSome: (date) => date.getTime(), }), }; } type TranscriptIdentity = Pick< AgentSessionImportSource, "filePath" | "size" | "mtimeMs" | "device" | "inode" | "birthtimeMs" >; const fileIdKey = (inode: TranscriptIdentity["inode"]) => (inode === null ? null : String(inode)); function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdentity): boolean { // Unsafe legacy numbers may already have lost file ID precision. if ( (typeof left.inode === "number" && !Number.isSafeInteger(left.inode)) || (typeof right.inode === "number" && !Number.isSafeInteger(right.inode)) ) { return false; } return ( left.filePath === right.filePath && left.size === right.size && left.mtimeMs === right.mtimeMs && left.device === right.device && fileIdKey(left.inode) === fileIdKey(right.inode) && left.birthtimeMs === right.birthtimeMs ); } /** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const processRunner = yield* ProcessRunner.ProcessRunner; // Concurrent client imports must not hold multiple selected-history budgets. const importReadLock = yield* Semaphore.make(1); const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; const projectStore = yield* ProjectStore.ProjectStoreV2; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); const isWindows = (yield* HostProcess.Platform) === "win32"; const foldWorktreeCase = isWindows; const hostEnvironment = yield* HostProcess.Environment; const homeDir = yield* HostProcess.HomeDirectory; // `/private/tmp` is what macOS reports for sessions started in `/tmp`. const excludedProjectRoots = new Set( [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => normalizeProjectPathForComparison(path.resolve(directory)), ), ); const excludedProjectAncestors = [ path.join(homeDir, "Downloads"), path.join(homeDir, "Documents", "Codex"), ]; const isExcludedProjectPath = (candidatePath: string) => excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || excludedProjectAncestors.some((ancestor) => normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( normalizeForWorktreeMatch(ancestor, foldWorktreeCase), ), ) || normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( normalizeForWorktreeMatch(baseDir, foldWorktreeCase), ) || isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); const listDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const statOption = (target: string) => fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( target: string, knownStats?: FileSystem.File.Info, ) { const resolved = path.resolve(target); const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); if (Option.isSome(stats) && Option.isSome(stats.value.ino) && stats.value.ino.value > 0n) { return `inode:${stats.value.dev}:${stats.value.ino.value}`; } const realPath = yield* fileSystem .realPath(resolved) .pipe(Effect.orElseSucceed(() => resolved)); return `path:${normalizeProjectPathForComparison(realPath)}`; }); // Paths whose own read outlived the deadline, mapped to when they may be read // again. Without this every later scan would wait out the deadline again for // each phase that touches them. const blockedGitFilePaths = new Map(); /** Reads small files without the libuv pool; "" when missing, unreadable or blocked. */ const readGitFiles = Effect.fn("AgentSessionScanner.readGitFiles")(function* ( filePaths: ReadonlyArray, ) { const unique = Array.from(new Set(filePaths)); const now = yield* Clock.currentTimeMillis; const contents = yield* Effect.forEach( unique, (filePath) => { // No privacy prompts or FIFOs to block open() here. if (isWindows) { return fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); } if ((blockedGitFilePaths.get(filePath) ?? 0) > now) return Effect.succeed(""); blockedGitFilePaths.delete(filePath); return processRunner .run({ command: "cat", args: ["--", filePath], timeout: GIT_FILE_READ_DEADLINE, timeoutBehavior: "timedOutResult", maxOutputBytes: MAX_GIT_FILE_BYTES, outputMode: "truncate", }) .pipe( Effect.map((result) => { if (result.timedOut) { if (blockedGitFilePaths.size >= MAX_BLOCKED_GIT_FILE_PATHS) { blockedGitFilePaths.delete(blockedGitFilePaths.keys().next().value!); } blockedGitFilePaths.set(filePath, now + BLOCKED_GIT_FILE_RETRY_MS); } return result.code === 0 ? result.stdout : ""; }), Effect.orElseSucceed(() => ""), ); }, { concurrency: isWindows ? 1 : GIT_FILE_READ_CONCURRENCY }, ); return new Map(unique.map((filePath, index) => [filePath, contents[index]!])); }); /** Reads every directory's git identity in three phases of concurrent reads. */ const readGitIdentities = Effect.fn("AgentSessionScanner.readGitIdentities")(function* ( directories: ReadonlyArray, ) { type GitIdentity = | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } | { readonly _tag: "Worktree" } | { readonly _tag: "NotGit" }; const identities = new Map(); const repositories: Array<{ directory: string; gitDir: string; linkedWorktree: boolean }> = []; const pointers: Array<{ directory: string; gitPath: string }> = []; for (const directory of directories) { const gitPath = path.join(directory, ".git"); const gitStats = yield* statOption(gitPath); if (Option.isNone(gitStats)) identities.set(directory, { _tag: "NotGit" }); else if (gitStats.value.type === "Directory") { repositories.push({ directory, gitDir: gitPath, linkedWorktree: false }); } else pointers.push({ directory, gitPath }); } const pointerTexts = yield* readGitFiles(pointers.map(({ gitPath }) => gitPath)); const linkedWorktrees: Array<{ directory: string; gitDir: string }> = []; for (const { directory, gitPath } of pointers) { const target = /^gitdir:\s*(.+)$/m.exec(pointerTexts.get(gitPath) ?? "")?.[1]?.trim(); if (target === undefined || target.length === 0) { identities.set(directory, { _tag: "NotGit" }); continue; } const gitDir = path.resolve(directory, target); if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) { linkedWorktrees.push({ directory, gitDir }); } else repositories.push({ directory, gitDir, linkedWorktree: false }); } const commonDirTexts = yield* readGitFiles( linkedWorktrees.map(({ gitDir }) => path.join(gitDir, "commondir")), ); for (const { directory, gitDir } of linkedWorktrees) { const commonDir = (commonDirTexts.get(path.join(gitDir, "commondir")) ?? "").trim(); if (commonDir.length === 0) identities.set(directory, { _tag: "Worktree" }); else repositories.push({ directory, gitDir: path.resolve(gitDir, commonDir), linkedWorktree: true, }); } const configTexts = yield* readGitFiles( repositories.map(({ gitDir }) => path.join(gitDir, "config")), ); for (const { directory, gitDir, linkedWorktree } of repositories) { const configText = configTexts.get(path.join(gitDir, "config")) ?? ""; // Bare repositories have no main checkout, so offer their linked worktrees independently. if (linkedWorktree && !isBareGitConfig(configText)) { identities.set(directory, { _tag: "Worktree" }); continue; } const originUrl = parseOriginUrlFromGitConfig(configText); identities.set(directory, { _tag: "Repository", git: { remoteKey: originUrl === null ? null : sourceControlDefinitions.repositoryKeyFromRemote(originUrl), repository: repositoryPathFromRemoteUrl(originUrl), }, }); } return identities; }); const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( source: AgentSessionSource, transcript: TranscriptCandidate, budget: MetadataReadBudget, ) { if (transcript.size === 0) return null; if ( budget.bytesRemaining === 0 || budget.operationsRemaining < 2 || budget.recordsRemaining === 0 ) { budget.truncated = true; return null; } budget.operationsRemaining -= 1; return yield* Effect.scoped( fileSystem.open(transcript.filePath, { flag: "r" }).pipe( Effect.flatMap((file) => Effect.gen(function* () { const decoder = new TextDecoder(); let remaining = ""; let bytesRead = 0; let recordsRead = 0; const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); const reserveRecord = () => { if ( recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || budget.recordsRemaining === 0 ) { budget.truncated = true; return false; } recordsRead += 1; budget.recordsRemaining -= 1; return true; }; const readLastRecord = () => { const record = remaining + decoder.decode(); return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim(), source); }; while (bytesRead < maxBytes) { if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { budget.truncated = true; return null; } const readSize = Math.min( METADATA_READ_BYTES, maxBytes - bytesRead, budget.bytesRemaining, ); budget.operationsRemaining -= 1; budget.bytesRemaining -= readSize; const next = yield* file.readAlloc(readSize); if (Option.isNone(next)) { return readLastRecord(); } bytesRead += next.value.byteLength; remaining += decoder.decode(next.value, { stream: true }); const lines = remaining.split("\n"); remaining = lines.pop() ?? ""; for (const line of lines) { if (!reserveRecord()) return null; const cwd = extractCwd(line.trim(), source); if (cwd !== null) return cwd; } } if (bytesRead < transcript.size) { budget.truncated = true; return null; } return readLastRecord(); }), ), ), ).pipe(Effect.orElseSucceed(() => null)); }); const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( filePath: string, expected: ReturnType, source: AgentSessionSource, prefix?: { readonly endByteOffset: number; readonly historyBytes: number }, ) { const readBytes = prefix?.endByteOffset ?? expected.size; if (readBytes > expected.size || readBytes > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; return yield* Effect.scoped( fileSystem.open(filePath, { flag: "r" }).pipe( Effect.flatMap((file) => Effect.gen(function* () { if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { return null; } const records: Array = []; let cwd = Option.none(); let claudeFields = emptyClaudeFields; let historyBytes = prefix?.historyBytes ?? 0; let recordBytes = 0; let recordCount = 0; let bytesRead = 0; const reserve = (bytes: number) => { recordBytes += bytes; if (historyBytes + recordBytes > MAX_IMPORT_HISTORY_BYTES) { throw new TranscriptJsonLimitError({ message: "Transcript selected history exceeds the 32 MiB memory budget", }); } }; let reader = createTranscriptJsonReader(reserve, selectTranscriptPath); let decoder = new TextDecoder(); let recordStarted = false; const finishRecord = () => { reader.write(decoder.decode()); recordCount += 1; if (recordCount > MAX_IMPORT_RECORDS) return false; const value = reader.finish(); const decoded = decodeTranscriptValue(value); if (Option.isNone(decoded) && source === "pi") { // Pi accepts messages this schema rejects, such as null content; keep their tree links. records.push(...Option.toArray(decodePiEntryLinks(value))); } else if (Option.isSome(decoded)) { if (Option.isNone(cwd)) cwd = Option.fromNullOr(extractDecodedCwd(decoded.value)); if (source === "claudeAgent") { claudeFields = foldClaudeFields(claudeFields, decoded.value); } if (shouldRetainDecodedRecord(source, decoded.value)) { if ( source === "pi" && decoded.value.type === "message" && !isPiConversationRecord(decoded.value) ) { // Tool output can dwarf the conversation; keep only the tree links. const { message: _message, ...links } = decoded.value; records.push(links); } else { records.push(decoded.value); historyBytes += recordBytes; } } } recordBytes = 0; reader = createTranscriptJsonReader(reserve, selectTranscriptPath); decoder = new TextDecoder(); recordStarted = false; return true; }; while (bytesRead < readBytes) { const next = yield* file.readAlloc( Math.min(TRANSCRIPT_PREFIX_BYTES, readBytes - bytesRead), ); if (Option.isNone(next)) { return null; } bytesRead += next.value.byteLength; const withinBudget = yield* Effect.try({ try: () => { let start = 0; while (start < next.value.byteLength) { const newline = next.value.indexOf(10, start); const end = newline === -1 ? next.value.byteLength : newline; recordStarted = true; reader.write(decoder.decode(next.value.subarray(start, end), { stream: true })); if (newline === -1) break; if (!finishRecord()) return false; start = newline + 1; } return true; }, catch: (cause) => new TranscriptParseError({ filePath, cause }), }); if (!withinBudget) return null; } if ( recordStarted && !(yield* Effect.try({ try: finishRecord, catch: (cause) => new TranscriptParseError({ filePath, cause }), })) ) return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) ? { records, cwd, claudeFields, historyBytes } : null; }), ), ), ).pipe( Effect.catch((cause) => Effect.logWarning("Could not read imported transcript", { filePath, cause }).pipe( Effect.as(null), ), ), ); }); const readCodexThreadNames = Effect.fn("AgentSessionScanner.readCodexThreadNames")(function* ( homePath: string, ) { const indexPath = path.join(homePath, "session_index.jsonl"); return yield* Effect.scoped( Effect.gen(function* () { const file = yield* fileSystem.open(indexPath, { flag: "r" }); const size = Number((yield* file.stat).size); const start = Math.max(0, size - MAX_CODEX_SESSION_INDEX_BYTES); yield* file.seek(BigInt(start), "start"); const bytes = yield* file.readAlloc(size - start); const contents = Option.match(bytes, { onNone: () => "", onSome: (value) => new TextDecoder().decode(value), }); // A tail read can start mid-record. return parseCodexThreadNames( start > 0 ? contents.slice(contents.indexOf("\n") + 1) : contents, ); }), ).pipe(Effect.orElseSucceed((): ReadonlyMap => new Map())); }); const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { const configured = homePath.trim(); if (configured.length > 0) { return path.resolve(expandHomePath(configured, homeDir)); } const fromEnvironment = environmentHome?.trim() ?? ""; if (fromEnvironment.length > 0) { return path.resolve(expandHomePath(fromEnvironment, homeDir)); } return path.join(homeDir, ".claude"); }; const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { const projectsDir = path.join(homePath, "projects"); let operationsRemaining = operationBudget; let truncated = false; const readDirectory = (directory: string) => { if (operationsRemaining <= 0) { truncated = true; return Effect.succeed>([]); } operationsRemaining -= 1; return listDirectory(directory); }; const projectDirectories = yield* readDirectory(projectsDir); const transcripts: Array = []; for (const projectDirectory of projectDirectories) { if (operationsRemaining <= 0) { truncated = true; break; } const directory = path.join(projectsDir, projectDirectory); const directoryTranscripts = (yield* readDirectory(directory)) .filter((entry) => entry.endsWith(".jsonl")) .map((entry) => path.join(directory, entry)); for (const filePath of directoryTranscripts) { if (operationsRemaining <= 0) { truncated = true; break; } operationsRemaining -= 1; const stats = yield* statOption(filePath); if ( Option.isNone(stats) || stats.value.type !== "File" || Option.isNone(stats.value.mtime) ) { continue; } transcripts.push({ filePath, mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath, size: Number(stats.value.size), }); } } return { transcripts, truncated }; }, ); const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { const sessionsDir = path.join(homePath, "sessions"); const transcripts: Array = []; let operationsRemaining = operationBudget; let truncated = false; const readDirectory = (directory: string) => { if (operationsRemaining <= 0) { truncated = true; return Effect.succeed>([]); } operationsRemaining -= 1; return listDirectory(directory); }; for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { if (operationsRemaining <= 0) { truncated = true; break; } for (const month of (yield* readDirectory(path.join(sessionsDir, year))) .toSorted() .toReversed()) { if (operationsRemaining <= 0) { truncated = true; break; } for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) .toSorted() .toReversed()) { if (operationsRemaining <= 0) { truncated = true; break; } const directory = path.join(sessionsDir, year, month, day); for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; if (operationsRemaining <= 0) { truncated = true; break; } const filePath = path.join(directory, entry); operationsRemaining -= 1; const stats = yield* statOption(filePath); if ( Option.isSome(stats) && stats.value.type === "File" && Option.isSome(stats.value.mtime) ) { transcripts.push({ filePath, mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath, size: Number(stats.value.size), }); } } } } } return { transcripts, truncated }; }, ); // Pi nests sessions in one directory per cwd, but a custom session directory holds them flat. const discoverPiTranscripts = Effect.fn("AgentSessionScanner.discoverPiTranscripts")(function* ( sessionsDir: string, providerInstanceId: ProviderInstanceId, operationBudget: number, ) { let operationsRemaining = operationBudget; let truncated = false; const readDirectory = (directory: string) => { if (operationsRemaining <= 0) { truncated = true; return Effect.succeed>([]); } operationsRemaining -= 1; return listDirectory(directory); }; const transcripts: Array = []; for (const entry of yield* readDirectory(sessionsDir)) { if (operationsRemaining <= 0) { truncated = true; break; } const entryPath = path.join(sessionsDir, entry); const filePaths = entry.endsWith(".jsonl") ? [entryPath] : (yield* readDirectory(entryPath)) .filter((nested) => nested.endsWith(".jsonl")) .map((nested) => path.join(entryPath, nested)); for (const filePath of filePaths) { if (operationsRemaining <= 0) { truncated = true; break; } operationsRemaining -= 1; const stats = yield* statOption(filePath); if ( Option.isSome(stats) && stats.value.type === "File" && Option.isSome(stats.value.mtime) ) { transcripts.push({ filePath, mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath: sessionsDir, size: Number(stats.value.size), }); } } } return { transcripts, truncated }; }); const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( source: AgentSessionSource, transcripts: ReadonlyArray, budget: MetadataReadBudget, ) { const byOwnerAndCwd = new Map< string, { cwd: string; providerInstanceId: ProviderInstanceId; homePath: string; lastActiveAtMs: number; transcripts: Array<{ filePath: string; mtimeMs: number }>; } >(); for (const transcript of transcripts) { const cwd = yield* readCwd(source, transcript, budget); if (cwd === null || cwd === false) continue; const key = `${transcript.providerInstanceId}\0${cwd}`; const existing = byOwnerAndCwd.get(key); if (existing) { existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); existing.transcripts.push(transcript); } else { byOwnerAndCwd.set(key, { cwd, providerInstanceId: transcript.providerInstanceId, homePath: transcript.homePath, lastActiveAtMs: transcript.mtimeMs, transcripts: [transcript], }); } } return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({ cwd: group.cwd, source, providerInstanceId: group.providerInstanceId, homePath: group.homePath, threadCount: group.transcripts.length, lastActiveAtMs: group.lastActiveAtMs, transcripts: group.transcripts, })); }); const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { const settings = yield* serverSettings.getSettings.pipe( Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), ); const raw: Array = []; let truncated = false; for (const source of ["claudeAgent", "codex", "pi"] as const) { const instances: Array<{ readonly instanceId: ProviderInstanceId; readonly config: ProviderInstanceConfig; }> = Object.entries(settings.providerInstances) .filter( ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), ) .map(([instanceId, config]) => ({ instanceId: ProviderInstanceId.make(instanceId), config, })); if (!Object.hasOwn(settings.providerInstances, source)) { const defaultInstance = { instanceId: ProviderInstanceId.make(source), config: { driver: ProviderDriverKind.make(source) }, }; if (resolveProviderInstanceEnabled(defaultInstance.config)) { instances.push(defaultInstance); } } instances.sort((left, right) => { const leftDefault = left.instanceId === source ? 0 : 1; const rightDefault = right.instanceId === source ? 0 : 1; return leftDefault - rightDefault; }); const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { const environmentHome = (name: "CLAUDE_CONFIG_DIR" | "CODEX_HOME") => { const configured = instance.environment?.findLast( (variable) => variable.name === name, )?.value; return typeof configured === "string" ? configured : hostEnvironment[name]; }; let homePath: string; if (source === "pi") { const environment = yield* mergeProviderInstanceEnvironment( instance.environment, hostEnvironment, ).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDir)); homePath = resolvePiSessionsDir(environment, homeDir, path); } else if (source === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); if (Option.isNone(config)) continue; homePath = resolveClaudeConfigDir( config.value.homePath, environmentHome("CLAUDE_CONFIG_DIR"), ); } else { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(config)) continue; const codexHome = environmentHome("CODEX_HOME"); const codexSettings = config.value.homePath.trim().length === 0 && config.value.shadowHomePath.trim().length === 0 && codexHome?.trim() ? { ...config.value, homePath: codexHome } : config.value; const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( Effect.provideService(Path.Path, path), ); homePath = layout.sharedHomePath; } const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; if (seenHomes.has(homeKey)) continue; seenHomes.add(homeKey); homes.push({ homePath, providerInstanceId: instanceId }); } const transcriptCandidates: Array = []; const baseOperationBudget = Math.floor( MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), ); const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); for (const [index, home] of homes.entries()) { const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); if (operationBudget === 0) { truncated = true; continue; } const discover = source === "claudeAgent" ? discoverClaudeTranscripts : source === "codex" ? discoverCodexTranscripts : discoverPiTranscripts; const discovered = yield* discover(home.homePath, home.providerInstanceId, operationBudget); truncated ||= discovered.truncated; transcriptCandidates.push(...discovered.transcripts); } transcriptCandidates.sort( (left, right) => right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), ); if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { truncated = true; } const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); const metadataBudget: MetadataReadBudget = { bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, truncated: false, }; raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); truncated ||= metadataBudget.truncated; } return { candidates: raw, truncated }; }); let cachedCandidates: ReadonlyArray | null = null; const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { const { candidates: raw, truncated } = yield* collectCandidates(); cachedCandidates = raw; const merged = new Map< string, { path: string; sources: Array; threadCount: number; lastActiveAtMs: number | null; git: AgentSessionProjectGit | null; } >(); // Resolve every candidate directory first so one batch reads all git identities. const directories = new Map>(); for (const candidate of raw) { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); if (directories.has(resolved) || isExcludedProjectPath(resolved)) continue; const stats = yield* statOption(resolved); if (Option.isNone(stats) || stats.value.type !== "Directory") { directories.set(resolved, Option.none()); continue; } const realPath = yield* fileSystem .realPath(resolved) .pipe(Effect.orElseSucceed(() => resolved)); // Symlinks can disguise excluded worktree paths; re-check their targets. directories.set(resolved, isExcludedProjectPath(realPath) ? Option.none() : stats); } const directoryGitIdentities = yield* readGitIdentities( Array.from(directories).flatMap(([directory, stats]) => Option.isSome(stats) ? [directory] : [], ), ); const directoryKeys = new Map(); const gitIdentities = new Map(); for (const candidate of raw) { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); const stats = directories.get(resolved); if (stats === undefined || Option.isNone(stats)) continue; let key = directoryKeys.get(resolved); if (key === undefined) { const gitIdentity = directoryGitIdentities.get(resolved); if (gitIdentity === undefined || gitIdentity._tag === "Worktree") { key = ""; } else { key = yield* directoryIdentity(resolved, stats.value); gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); } directoryKeys.set(resolved, key); } if (key === "") continue; const existing = merged.get(key); if (!existing) { merged.set(key, { path: resolved, sources: [candidate.source], threadCount: candidate.threadCount, lastActiveAtMs: candidate.lastActiveAtMs, git: gitIdentities.get(key) ?? null, }); continue; } if (!existing.sources.includes(candidate.source)) { existing.sources.push(candidate.source); } existing.threadCount += candidate.threadCount; existing.lastActiveAtMs = existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); } // Resolve persisted roots too; transcripts may use different symlinks to the same directory. const importedProjects = yield* projectStore .listShells() .pipe( Effect.mapError( (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), ), ); const importedProjectsByRoot = new Map(); for (const project of importedProjects) { const projectRoot = path.resolve(expandHomePath(project.workspaceRoot, homeDir)); importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); } const candidates: Array = []; for (const [key, entry] of merged.entries()) { const importedProject = importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? importedProjectsByRoot.get(key); const candidatePath = importedProject?.workspaceRoot ?? entry.path; candidates.push({ path: candidatePath, title: path.basename(candidatePath) || candidatePath, ...(importedProject === undefined ? {} : { projectId: importedProject.id }), sources: entry.sources, threadCount: entry.threadCount, lastActiveAt: entry.lastActiveAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), alreadyImported: importedProject !== undefined, git: entry.git, }); } candidates.sort((left, right) => { if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); if (left.lastActiveAt === null) return 1; if (right.lastActiveAt === null) return -1; return right.lastActiveAt.localeCompare(left.lastActiveAt); }); return { candidates, scannedAt: DateTime.formatIso(yield* DateTime.now), ...(truncated ? { truncated: true } : {}), }; }); const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( workspaceRoot: string, completedSources: ReadonlyArray, ) { const root = path.resolve(expandHomePath(workspaceRoot, homeDir)); const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; const rootIdentity = yield* directoryIdentity(root); const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; cachedCandidates = candidates; const eligibleTranscripts: Array<{ readonly candidate: RawCandidate; readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; }> = []; for (const candidate of candidates) { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; for (const transcript of candidate.transcripts) { if ( transcript.mtimeMs === null || transcript.mtimeMs < cutoffMs || transcript.mtimeMs > nowMs ) { continue; } eligibleTranscripts.push({ candidate, transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, }); } } eligibleTranscripts.sort((left, right) => { if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { return right.transcript.mtimeMs - left.transcript.mtimeMs; } return left.transcript.filePath.localeCompare(right.transcript.filePath); }); const completedByFile = Map.groupBy( completedSources, (source) => `${source.providerInstanceId}\0${source.filePath}`, ); const importedSessions = new Set(); const codexThreadNamesByHome = new Map>(); let bytesRemaining = MAX_IMPORT_BYTES; let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; const codexRolloutPaths = new Map>(); const codexRolloutPath = (providerInstanceId: ProviderInstanceId, rolloutId: string) => { let paths = codexRolloutPaths.get(providerInstanceId); if (paths === undefined) { paths = new Map( candidates.flatMap((candidate) => candidate.source === "codex" && candidate.providerInstanceId === providerInstanceId ? candidate.transcripts.flatMap((transcript) => { const id = CODEX_ROLLOUT_ID_PATTERN.exec(transcript.filePath)?.[1]; return id === undefined ? [] : [[id, transcript.filePath] as const]; }) : [], ), ); codexRolloutPaths.set(providerInstanceId, paths); } return paths.get(rolloutId); }; // Reads the prefixes a rewound rollout inherits, oldest first. Without all of // them the rollout is imported on its own, as before. const readCodexHistoryBase = Effect.fn("AgentSessionScanner.readCodexHistoryBase")(function* ( providerInstanceId: ProviderInstanceId, records: ReadonlyArray, historyBytes: number, ) { const prefixes: Array> = []; const visited = new Set(); for (let base = codexHistoryBase(records); base !== undefined;) { const filePath = visited.has(base.thread_id) ? undefined : codexRolloutPath(providerInstanceId, base.thread_id); if (filePath === undefined || base.end_byte_offset > bytesRemaining) return null; visited.add(base.thread_id); const stats = yield* statOption(filePath); if (Option.isNone(stats) || stats.value.type !== "File") return null; bytesRemaining -= base.end_byte_offset; const inherited = yield* readTranscript( filePath, transcriptIdentity(filePath, stats.value), "codex", { endByteOffset: base.end_byte_offset, historyBytes }, ); if (inherited === null) return null; historyBytes = inherited.historyBytes; // The rollout's own session_meta names the thread; inherited ones may name a fork parent. prefixes.unshift(inherited.records.filter((record) => record.type !== "session_meta")); base = codexHistoryBase(inherited.records); } return prefixes.flat(); }); return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( Stream.mapEffect(({ candidate, transcript }) => Effect.gen(function* () { const completed = completedByFile.get( `${candidate.providerInstanceId}\0${transcript.filePath}`, ); if (completed === undefined && (transcriptsRemaining === 0 || bytesRemaining === 0)) { return Option.some({ _tag: "Skipped" }); } const stats = yield* statOption(transcript.filePath); if (Option.isNone(stats) || stats.value.type !== "File") { return Option.some({ _tag: "Skipped" }); } const identity = transcriptIdentity(transcript.filePath, stats.value); const completedSource = completed?.find( (source) => source.provider === candidate.source && sameTranscriptIdentity(source, identity), ); if (completedSource !== undefined) { const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; if (importedSessions.has(sessionKey)) return Option.none(); importedSessions.add(sessionKey); return Option.some({ _tag: "AlreadyImported", source: completedSource, }); } if ( transcriptsRemaining === 0 || identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || identity.size > bytesRemaining ) { return Option.some({ _tag: "Skipped" }); } transcriptsRemaining -= 1; bytesRemaining -= identity.size; const snapshot = yield* readTranscript(transcript.filePath, identity, candidate.source); if (snapshot === null) { return Option.some({ _tag: "Skipped" }); } if (Option.isNone(snapshot.cwd)) { return Option.some({ _tag: "Skipped" }); } const expandedCwd = expandHomePath(snapshot.cwd.value.trim(), homeDir); if ( !path.isAbsolute(expandedCwd) || (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity ) { return Option.some({ _tag: "Skipped" }); } const inheritedRecords = candidate.source === "codex" ? yield* readCodexHistoryBase( candidate.providerInstanceId, snapshot.records, snapshot.historyBytes, ) : null; const parsedThread = parseAgentSessionRecords( { source: candidate.source, providerInstanceId: candidate.providerInstanceId, fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), lastActiveAtMs: transcript.mtimeMs, }, inheritedRecords === null ? snapshot.records : [...inheritedRecords, ...snapshot.records], snapshot.claudeFields, ); if (parsedThread === null) { return Option.some({ _tag: "Skipped" }); } let savedTitle: string | undefined; if (candidate.source === "codex") { const names = codexThreadNamesByHome.get(candidate.homePath) ?? (yield* readCodexThreadNames(candidate.homePath)); codexThreadNamesByHome.set(candidate.homePath, names); savedTitle = names.get(parsedThread.providerSessionId); } const source: AgentSessionImportSource = { ...identity, provider: parsedThread.source, providerInstanceId: parsedThread.providerInstanceId, providerSessionId: parsedThread.providerSessionId, }; const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; if (importedSessions.has(sessionKey)) { return Option.some({ _tag: "Duplicate", source }); } importedSessions.add(sessionKey); return Option.some({ _tag: "Importable", thread: savedTitle === undefined ? parsedThread : { ...parsedThread, title: savedTitle }, source, }); }).pipe(importReadLock.withPermits(1)), ), Stream.map(Option.toArray), Stream.flattenIterable, ); }); const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( workspaceRoot, completedSources = [], ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); return AgentSessionScanner.of({ scan, recentThreads }); }); export const layer = Layer.effect(AgentSessionScanner, make).pipe( Layer.provide(ProcessRunner.layer), );