/** * AgentSessionScanner - discovery of projects a user already works on. * * Claude Code and Codex both keep a per-session transcript on disk, and each * transcript records the directory the session ran in. Reading those `cwd` * values gives us the set of directories worth offering as projects during * onboarding, without asking the user to browse the filesystem. * * The scan is read-only and best-effort: an unreadable home, a malformed * transcript, or a directory that has since been deleted is skipped rather * than failing the scan. Project creation stays with the client, which * dispatches `project.create` for whichever candidates the user picks. * * @module project/AgentSessionScanner */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 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 { normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, parseOriginUrlFromGitConfig,} from "@t3tools/shared/git";import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as ServerConfig from "../config.ts";import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";import { expandHomePath } from "../pathExpansion.ts";import * as ServerSettings from "../serverSettings.ts";import { createTranscriptJsonReader, createTranscriptJsonSelector, TranscriptJsonLimitError,} from "./AgentSessionJson.ts"; /** Chunk size for full transcript reads. */const TRANSCRIPT_PREFIX_BYTES = 32 * 1024;/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */const METADATA_READ_BYTES = 8 * 1024;/** Prevent malformed transcripts from turning project discovery into a full file scan. */const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; /** * Upper bound on transcripts inspected (first line read) per source. * Newest-first ordering means the cap drops only stale sessions when a home * directory is unusually large. */const MAX_TRANSCRIPTS_PER_SOURCE = 5000; /** * Upper bound on discovery filesystem operations per source. Newest-first * ordering needs mtimes before the read cap can be applied, so directory reads * and candidate stats share a larger budget. Once it runs out the scan stops. */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;/** * Large tool results (especially screenshots) can make an otherwise ordinary * Codex transcript several GiB. Streaming field selection avoids allocating * those payloads. Raw I/O and selected history have separate budgets. */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; 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), timestamp: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), aiTitle: 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), content: Schema.optional(Schema.Array(TranscriptContentBlock)), internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), }), ),}); const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord));const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord);const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord);const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); 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<AgentSessionThreadMessage>;} 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" }; /** Service tag for agent session discovery. */export class AgentSessionScanner extends Context.Service< AgentSessionScanner, { /** * Discover every directory the configured Claude and Codex homes have run * a session in. Candidates are returned newest-first; the client decides * which ones to import and how far back to look. Fails with the contract * error directly — there is no server-local context worth wrapping. */ readonly scan: Effect.Effect<AgentSessionScanResult, AgentSessionScanError>; readonly recentThreads: ( workspaceRoot: string, completedSources?: ReadonlyArray<AgentSessionImportSource>, ) => Stream.Stream<AgentSessionRecentThread, AgentSessionScanError>; }>()("t3/project/AgentSessionScanner") {} type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; /** A single directory's worth of evidence from one source. */interface RawCandidate { readonly cwd: string; readonly source: AgentSessionSource; readonly providerInstanceId: ProviderInstanceId; 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 size: number;} interface MetadataReadBudget { bytesRemaining: number; operationsRemaining: number; recordsRemaining: number; truncated: boolean;} function selectMetadataTranscripts(transcripts: ReadonlyArray<TranscriptCandidate>) { const selected: Array<TranscriptCandidate> = []; 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 extractText( content: string | ReadonlyArray<typeof TranscriptContentBlock.Type> | undefined,): string { if (typeof content === "string") return content.trim(); if (content === undefined) return ""; return content .filter( (block) => block.type === "text" || block.type === "input_text" || block.type === "output_text", ) .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;} /** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */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))); return parseAgentSessionRecords(input, records);} function parseAgentSessionRecords( input: AgentSessionTranscriptMetadata, records: ReadonlyArray<DecodedTranscriptRecord>,): 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.fallbackSessionId; let title: string | null = null; let model: string | null = null; let hasCodexSessionId = false; const messages: Array<AgentSessionThreadMessage & { readonly codexResponseUser: boolean }> = []; let firstUserMessage: | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) | undefined; // A Codex response item can include generated setup text beside the real // prompt. Suppress response-user records only when the shared turn ID and a // verbatim event copy prove which prompt the user submitted. const canonicalCodexResponseUserIndices = new Set<number>(); let canonicalUserTextsInTurn = new Set<string>(); 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(); } const retainMessage = ( message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, ) => { if (firstUserMessage === undefined && message.role === "user") { firstUserMessage = message; } 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 records) { recordIndex += 1; if (input.source === "claudeAgent") { if ( record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true ) { continue; } if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); if (record.aiTitle?.trim()) title = record.aiTitle.trim(); const messageModel = record.message?.model?.trim(); // Claude uses this sentinel for local error responses. It is not a // model ID that can be selected when the imported session resumes. if (messageModel && messageModel !== "<synthetic>") model = messageModel; 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 write the same prompt as both a response item and an event. // Remove only the matching response copy so mixed-format logs keep every // distinct user message. 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); 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))]; const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); return { source: input.source, providerInstanceId: input.providerInstanceId, providerSessionId,Lines 1–500 of 1495. Load more
/** * AgentSessionScanner - discovery of projects a user already works on. * * Claude Code and Codex both keep a per-session transcript on disk, and each * transcript records the directory the session ran in. Reading those `cwd` * values gives us the set of directories worth offering as projects during * onboarding, without asking the user to browse the filesystem. * * The scan is read-only and best-effort: an unreadable home, a malformed * transcript, or a directory that has since been deleted is skipped rather * than failing the scan. Project creation stays with the client, which * dispatches `project.create` for whichever candidates the user picks. * * @module project/AgentSessionScanner */ 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 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 { normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, parseOriginUrlFromGitConfig, } from "@t3tools/shared/git"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as ServerConfig from "../config.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, TranscriptJsonLimitError, } from "./AgentSessionJson.ts"; /** Chunk size for full transcript reads. */ const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; /** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ const METADATA_READ_BYTES = 8 * 1024; /** Prevent malformed transcripts from turning project discovery into a full file scan. */ const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; /** * Upper bound on transcripts inspected (first line read) per source. * Newest-first ordering means the cap drops only stale sessions when a home * directory is unusually large. */ const MAX_TRANSCRIPTS_PER_SOURCE = 5000; /** * Upper bound on discovery filesystem operations per source. Newest-first * ordering needs mtimes before the read cap can be applied, so directory reads * and candidate stats share a larger budget. Once it runs out the scan stops. */ 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; /** * Large tool results (especially screenshots) can make an otherwise ordinary * Codex transcript several GiB. Streaming field selection avoids allocating * those payloads. Raw I/O and selected history have separate budgets. */ 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; 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), timestamp: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), aiTitle: 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), content: Schema.optional(Schema.Array(TranscriptContentBlock)), internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), }), ), }); const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); 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<AgentSessionThreadMessage>; } 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" }; /** Service tag for agent session discovery. */ export class AgentSessionScanner extends Context.Service< AgentSessionScanner, { /** * Discover every directory the configured Claude and Codex homes have run * a session in. Candidates are returned newest-first; the client decides * which ones to import and how far back to look. Fails with the contract * error directly — there is no server-local context worth wrapping. */ readonly scan: Effect.Effect<AgentSessionScanResult, AgentSessionScanError>; readonly recentThreads: ( workspaceRoot: string, completedSources?: ReadonlyArray<AgentSessionImportSource>, ) => Stream.Stream<AgentSessionRecentThread, AgentSessionScanError>; } >()("t3/project/AgentSessionScanner") {} type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; /** A single directory's worth of evidence from one source. */ interface RawCandidate { readonly cwd: string; readonly source: AgentSessionSource; readonly providerInstanceId: ProviderInstanceId; 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 size: number; } interface MetadataReadBudget { bytesRemaining: number; operationsRemaining: number; recordsRemaining: number; truncated: boolean; } function selectMetadataTranscripts(transcripts: ReadonlyArray<TranscriptCandidate>) { const selected: Array<TranscriptCandidate> = []; 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 extractText( content: string | ReadonlyArray<typeof TranscriptContentBlock.Type> | undefined, ): string { if (typeof content === "string") return content.trim(); if (content === undefined) return ""; return content .filter( (block) => block.type === "text" || block.type === "input_text" || block.type === "output_text", ) .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; } /** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ 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))); return parseAgentSessionRecords(input, records); } function parseAgentSessionRecords( input: AgentSessionTranscriptMetadata, records: ReadonlyArray<DecodedTranscriptRecord>, ): 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.fallbackSessionId; let title: string | null = null; let model: string | null = null; let hasCodexSessionId = false; const messages: Array<AgentSessionThreadMessage & { readonly codexResponseUser: boolean }> = []; let firstUserMessage: | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) | undefined; // A Codex response item can include generated setup text beside the real // prompt. Suppress response-user records only when the shared turn ID and a // verbatim event copy prove which prompt the user submitted. const canonicalCodexResponseUserIndices = new Set<number>(); let canonicalUserTextsInTurn = new Set<string>(); 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(); } const retainMessage = ( message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, ) => { if (firstUserMessage === undefined && message.role === "user") { firstUserMessage = message; } 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 records) { recordIndex += 1; if (input.source === "claudeAgent") { if ( record.isSidechain === true || record.isMeta === true || record.isCompactSummary === true ) { continue; } if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); if (record.aiTitle?.trim()) title = record.aiTitle.trim(); const messageModel = record.message?.model?.trim(); // Claude uses this sentinel for local error responses. It is not a // model ID that can be selected when the imported session resumes. if (messageModel && messageModel !== "<synthetic>") model = messageModel; 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 write the same prompt as both a response item and an event. // Remove only the matching response copy so mixed-format logs keep every // distinct user message. 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); 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))]; const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); return { source: input.source, providerInstanceId: input.providerInstanceId, providerSessionId,