diff --git a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts index bacd73ae5..f4cde105f 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { @@ -9,7 +10,6 @@ import { cwdIsInScope, firstUserTextFromRecords, normalizeExternalSessionLimit, - readFilePrefix, readFileSuffix, readJsonlRecords, recordWithFile, @@ -45,14 +45,30 @@ type CodexSessionMeta = { cwd: string | null; createdAt: number | null; title: string | null; - first: Record; - payload: Record; + importable: boolean; }; type CodexSessionCandidate = ExternalSessionFileCandidate<{ meta?: CodexSessionMeta | null }>; -const CODEX_PROJECT_SCOPE_SCAN_CEILING = 2000; +type CodexScopeIndexEntry = { + mtimeMs: number; + size: number; + meta: CodexSessionMeta | null; +}; + +type CodexScopeIndexState = { + codexDir: string; + entries: Map; + persisted: boolean; + dirty: boolean; + pendingWrite: ReturnType | null; +}; + +const CODEX_SESSION_META_MAX_BYTES = 64 * 1024; +const CODEX_SCOPE_INDEX_VERSION = 1; +const CODEX_SCOPE_INDEX_WRITE_DELAY_MS = 30_000; const CODEX_RECENT_SCAN_FLOOR = 1000; +const codexScopeIndexStates = new Map(); function readCodexIndex(indexPath: string): Map { const map = new Map(); @@ -83,11 +99,35 @@ function titleFromCodexPayload(payload: Record, indexed: CodexI ?? null; } -function readCodexSessionMeta(filePath: string): CodexSessionMeta | null { - const text = readFilePrefix(filePath, 64 * 1024); - const line = text?.split(/\r?\n/u).find((entry) => entry.trim().length > 0); - if (!line) return null; - const first = asRecord(safeParseJson(line)); +function readCodexSessionMeta( + filePath: string, + scratch = Buffer.allocUnsafe(CODEX_SESSION_META_MAX_BYTES), +): CodexSessionMeta | null { + let fd: number | null = null; + let bytesRead = 0; + try { + fd = fs.openSync(filePath, "r"); + bytesRead = fs.readSync(fd, scratch, 0, scratch.length, 0); + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // best effort close + } + } + } + if (bytesRead <= 0) return null; + let lineStart = 0; + while (lineStart < bytesRead && (scratch[lineStart] === 0x0a || scratch[lineStart] === 0x0d)) { + lineStart += 1; + } + if (lineStart >= bytesRead) return null; + const newline = scratch.subarray(lineStart, bytesRead).indexOf(0x0a); + const lineEnd = newline >= 0 ? lineStart + newline : bytesRead; + const first = asRecord(safeParseJson(scratch.subarray(lineStart, lineEnd).toString("utf8"))); const payload = asRecord(first?.payload); const type = asString(first?.type); if (type !== "session_meta" || !payload || !first) return null; @@ -98,8 +138,7 @@ function readCodexSessionMeta(filePath: string): CodexSessionMeta | null { cwd: asString(payload.cwd), createdAt: asEpochMs(payload.timestamp) ?? asEpochMs(first.timestamp), title: titleFromCodexPayload(payload, undefined), - first, - payload, + importable: isImportableCodexPayload(payload), }; } @@ -148,9 +187,8 @@ export function probeCodexRolloutFile( } } -function collectRecentCodexSessionCandidates( +function collectCodexSessionCandidates( root: string, - limit: number, sessionId: string | null = null, ): CodexSessionCandidate[] { const candidates: CodexSessionCandidate[] = []; @@ -172,7 +210,15 @@ function collectRecentCodexSessionCandidates( } } } - return sortFileCandidatesByMtime(candidates, limit); + return candidates; +} + +function collectRecentCodexSessionCandidates( + root: string, + limit: number, + sessionId: string | null = null, +): CodexSessionCandidate[] { + return sortFileCandidatesByMtime(collectCodexSessionCandidates(root, sessionId), limit); } function codexHomeDir(args: ExternalSessionDiscoveryArgs): string { @@ -181,11 +227,16 @@ function codexHomeDir(args: ExternalSessionDiscoveryArgs): string { return configured ? path.resolve(configured) : path.join(resolveHomeDir(args), ".codex"); } -function isImportableCodexSession(meta: CodexSessionMeta): boolean { - const source = asString(meta.payload.source)?.toLowerCase() ?? null; - const originator = asString(meta.payload.originator)?.toLowerCase() ?? null; - const agentRole = asString(meta.payload.agent_role) ?? asString(meta.payload.agentRole); +function isImportableCodexPayload(payload: Record): boolean { + const rawSource = payload.source; + const source = asString(rawSource)?.toLowerCase() ?? null; + const originator = asString(payload.originator)?.toLowerCase() ?? null; + const agentRole = asString(payload.agent_role) ?? asString(payload.agentRole); if (agentRole) return false; + // Current Codex subagents use an object-shaped `source` payload. External + // import is deliberately limited to interactive CLI/user-shell rollouts, so + // fail closed for any structured or otherwise unknown source representation. + if (rawSource != null && !source) return false; if (source === "exec" || source === "vscode") return false; if (source && source !== "cli" && source !== "user_shell") return false; if (!originator) return true; @@ -199,6 +250,169 @@ function isImportableCodexSession(meta: CodexSessionMeta): boolean { ); } +function isImportableCodexSession(meta: CodexSessionMeta): boolean { + return meta.importable; +} + +function codexScopeIndexPath( + args: ExternalSessionDiscoveryArgs, + codexDir: string, +): string { + const env = args.env ?? (args.homeDir ? undefined : process.env); + const configuredAdeHome = typeof env?.ADE_HOME === "string" ? env.ADE_HOME.trim() : ""; + const adeHome = configuredAdeHome + ? path.resolve(configuredAdeHome) + : path.join(resolveHomeDir(args), ".ade"); + const codexHomeKey = createHash("sha256") + .update(path.resolve(codexDir)) + .digest("hex") + .slice(0, 16); + return path.join( + adeHome, + "cache", + "external-sessions", + `codex-cwd-index-${codexHomeKey}.json`, + ); +} + +function scopeIndexMetaFromValue(value: unknown): CodexSessionMeta | null { + const record = asRecord(value); + if (!record) return null; + const id = asString(record.id); + const cwd = record.cwd === null ? null : asString(record.cwd); + let createdAt: number | null; + if (record.createdAt === null) { + createdAt = null; + } else if (typeof record.createdAt === "number" && Number.isFinite(record.createdAt)) { + createdAt = record.createdAt; + } else { + return null; + } + const title = record.title === null ? null : asString(record.title); + if ( + !id + || (record.cwd !== null && cwd === null) + || (record.title !== null && title === null) + || typeof record.importable !== "boolean" + ) return null; + return { + id, + cwd, + createdAt, + title, + importable: record.importable, + }; +} + +function loadCodexScopeIndex( + indexPath: string, + codexDir: string, +): CodexScopeIndexState { + const cached = codexScopeIndexStates.get(indexPath); + if (cached?.codexDir === codexDir) return cached; + const state: CodexScopeIndexState = { + codexDir, + entries: new Map(), + persisted: false, + dirty: false, + pendingWrite: null, + }; + try { + const parsed = asRecord(safeParseJson(fs.readFileSync(indexPath, "utf8"))); + const rawEntries = asRecord(parsed?.entries); + if ( + parsed?.version !== CODEX_SCOPE_INDEX_VERSION + || asString(parsed.codexDir) !== codexDir + || !rawEntries + ) { + codexScopeIndexStates.set(indexPath, state); + return state; + } + for (const [relativePath, rawEntry] of Object.entries(rawEntries)) { + if (!/^\d{4}\/\d{2}\/\d{2}\/[^/]+\.jsonl$/u.test(relativePath)) continue; + const entry = asRecord(rawEntry); + const mtimeMs = entry?.mtimeMs; + const size = entry?.size; + const meta = entry?.meta === null ? null : scopeIndexMetaFromValue(entry?.meta); + if ( + typeof mtimeMs !== "number" + || !Number.isFinite(mtimeMs) + || typeof size !== "number" + || !Number.isFinite(size) + || size < 0 + || (entry?.meta !== null && meta === null) + ) continue; + state.entries.set(relativePath, { mtimeMs, size, meta }); + } + state.persisted = true; + } catch { + // Missing or corrupt indexes are rebuildable from Codex rollout metadata. + } + codexScopeIndexStates.set(indexPath, state); + return state; +} + +function writeCodexScopeIndex( + indexPath: string, + state: CodexScopeIndexState, + logger: ExternalSessionDiscoveryArgs["logger"], +): boolean { + const serializedEntries = Object.fromEntries( + Array.from(state.entries.entries()).sort(([left], [right]) => left.localeCompare(right)), + ); + const serialized = `${JSON.stringify({ + version: CODEX_SCOPE_INDEX_VERSION, + codexDir: state.codexDir, + entries: serializedEntries, + })}\n`; + const tempPath = `${indexPath}.tmp-${process.pid}-${randomUUID()}`; + try { + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(tempPath, serialized, { encoding: "utf8", mode: 0o600 }); + try { + fs.renameSync(tempPath, indexPath); + } catch { + fs.copyFileSync(tempPath, indexPath); + fs.unlinkSync(tempPath); + } + } catch (error) { + try { + fs.unlinkSync(tempPath); + } catch { + // best effort cleanup + } + logger?.warn?.("external_sessions.codex_cwd_index_write_failed", { + indexPath, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + return true; +} + +function scheduleCodexScopeIndexWrite( + indexPath: string, + state: CodexScopeIndexState, + logger: ExternalSessionDiscoveryArgs["logger"], +): void { + if (!state.dirty) return; + if (!state.persisted) { + if (writeCodexScopeIndex(indexPath, state, logger)) { + state.persisted = true; + state.dirty = false; + } + return; + } + if (state.pendingWrite) return; + state.pendingWrite = setTimeout(() => { + state.pendingWrite = null; + if (writeCodexScopeIndex(indexPath, state, logger)) { + state.dirty = false; + } + }, CODEX_SCOPE_INDEX_WRITE_DELAY_MS); + state.pendingWrite.unref(); +} + function firstCodexUserText(records: unknown[]): string | null { const canonical = records.filter((item) => { const record = asRecord(item); @@ -381,57 +595,99 @@ async function codexLaunchForFile( return Object.keys(fallback).length ? fallback : null; } -function collectProjectScopedCodexSessionCandidates( +function collectIndexedProjectScopedCodexSessionCandidates( + args: ExternalSessionDiscoveryArgs, root: string, + codexDir: string, limit: number, scopeRoots: string[], - logger: ExternalSessionDiscoveryArgs["logger"], - sessionId: string | null = null, ): CodexSessionCandidate[] { - const candidates: CodexSessionCandidate[] = []; - let scanned = 0; - let ceilingHit = false; - const finish = (): CodexSessionCandidate[] => { - if (ceilingHit && candidates.length < limit) { - logger?.warn?.("external_sessions.codex_project_scope_scan_truncated", { - ceiling: CODEX_PROJECT_SCOPE_SCAN_CEILING, - scanned, - matched: candidates.length, - limit, - }); + const scratch = Buffer.allocUnsafe(CODEX_SESSION_META_MAX_BYTES); + const indexPath = codexScopeIndexPath(args, codexDir); + const indexState = loadCodexScopeIndex(indexPath, codexDir); + const nextEntries = new Map(); + const candidatesById = new Map(); + let indexChanged = false; + + for (const candidate of collectCodexSessionCandidates(root)) { + if (candidate.filePath.endsWith(".jsonl.zst")) continue; + const relativePath = path.relative(root, candidate.filePath).split(path.sep).join("/"); + const cached = indexState.entries.get(relativePath); + let entry = cached; + if ( + !entry + || entry.mtimeMs !== candidate.mtimeMs + || entry.size !== candidate.size + ) { + const meta = readCodexSessionMeta(candidate.filePath, scratch); + entry = { + mtimeMs: candidate.mtimeMs, + size: candidate.size, + meta, + }; + indexChanged = true; } - return sortFileCandidatesByMtime(candidates, limit); - }; + nextEntries.set(relativePath, entry); + if (!entry.meta?.importable || !cwdIsInScope(entry.meta.cwd, scopeRoots)) continue; + const meta = entry.meta; + const existing = candidatesById.get(meta.id); + if (!existing || candidate.mtimeMs > existing.mtimeMs) { + candidatesById.set(meta.id, { ...candidate, meta }); + } + } - const years = sortedChildDirs(root, /^\d{4}$/u); - for (const year of years) { - const yearDir = path.join(root, year); - for (const month of sortedChildDirs(yearDir, /^\d{2}$/u)) { - const monthDir = path.join(yearDir, month); - for (const day of sortedChildDirs(monthDir, /^\d{2}$/u)) { - const dayDir = path.join(monthDir, day); - for (const entry of safeReadDir(dayDir)) { - if (!entry.isFile() || (!entry.name.endsWith(".jsonl") && !entry.name.endsWith(".jsonl.zst"))) { - continue; - } - if (!matchesCodexLookup(entry.name, sessionId)) continue; - const candidate = sessionFileCandidate(path.join(dayDir, entry.name), {}); - if (!candidate) continue; - if (scanned >= CODEX_PROJECT_SCOPE_SCAN_CEILING) { - ceilingHit = true; - return finish(); - } - scanned += 1; - if (candidate.filePath.endsWith(".jsonl.zst")) continue; - const meta = readCodexSessionMeta(candidate.filePath); - if (!cwdIsInScope(meta?.cwd, scopeRoots)) continue; - candidates.push({ ...candidate, meta }); - if (candidates.length >= limit) return finish(); - } - } + if (nextEntries.size !== indexState.entries.size) indexChanged = true; + indexState.entries = nextEntries; + if (indexChanged) indexState.dirty = true; + scheduleCodexScopeIndexWrite(indexPath, indexState, args.logger); + return sortFileCandidatesByMtime(Array.from(candidatesById.values()), limit); +} + +function collectExactProjectScopedCodexSessionCandidates( + root: string, + limit: number, + scopeRoots: string[], + sessionId: string, +): CodexSessionCandidate[] { + const scratch = Buffer.allocUnsafe(CODEX_SESSION_META_MAX_BYTES); + const candidates: CodexSessionCandidate[] = []; + for (const candidate of collectCodexSessionCandidates(root, sessionId)) { + if (candidate.filePath.endsWith(".jsonl.zst")) continue; + const meta = readCodexSessionMeta(candidate.filePath, scratch); + if ( + meta + && isImportableCodexSession(meta) + && cwdIsInScope(meta.cwd, scopeRoots) + ) { + candidates.push({ ...candidate, meta }); } } - return finish(); + return sortFileCandidatesByMtime(candidates, limit); +} + +function projectScopedCodexSessionCandidates( + args: ExternalSessionDiscoveryArgs, + root: string, + codexDir: string, + limit: number, + scopeRoots: string[], + sessionId: string | null, +): CodexSessionCandidate[] { + if (sessionId) { + return collectExactProjectScopedCodexSessionCandidates( + root, + limit, + scopeRoots, + sessionId, + ); + } + return collectIndexedProjectScopedCodexSessionCandidates( + args, + root, + codexDir, + limit, + scopeRoots, + ); } function idFromCodexFilename(filePath: string): string | null { @@ -473,7 +729,14 @@ export async function discoverCodexSessions( const scanLimit = Math.max(CODEX_RECENT_SCAN_FLOOR, limit * 20); const candidates = args.scopeRoots?.length - ? collectProjectScopedCodexSessionCandidates(sessionsDir, scanLimit, args.scopeRoots, args.logger, lookupId) + ? projectScopedCodexSessionCandidates( + args, + sessionsDir, + codexDir, + limit, + args.scopeRoots, + lookupId, + ) : collectRecentCodexSessionCandidates(sessionsDir, scanLimit, lookupId); for (const candidate of candidates) { @@ -497,25 +760,23 @@ export async function discoverCodexSessions( } const jsonl = readJsonlRecords(filePath); - const first = candidate.meta?.first ?? asRecord(jsonl[0]); - const payload = candidate.meta?.payload ?? asRecord(first?.payload); - const type = asString(first?.type); - if (type !== "session_meta" || !payload) continue; const meta = candidate.meta ?? readCodexSessionMeta(filePath); if (!meta || !isImportableCodexSession(meta)) continue; - const id = candidate.meta?.id ?? asString(payload.id) ?? asString(payload.session_id) ?? asString(payload.sessionId); + const first = asRecord(jsonl[0]); + const payload = asRecord(first?.payload); + const id = meta.id; if (!id || (lookupId && id !== lookupId) || recordsById.has(id)) continue; const indexed = index.get(id); const firstUserText = firstCodexUserText(jsonl); - const title = candidate.meta?.title ?? titleFromCodexPayload(payload, indexed); + const title = meta.title ?? titleFromCodexPayload(payload ?? {}, indexed); const launch = await codexLaunchForFile(filePath, jsonl, lookupId != null, args.logger); recordsById.set(id, recordWithFile({ provider: "codex", id, - cwd: candidate.meta?.cwd ?? asString(payload.cwd), + cwd: meta.cwd ?? asString(payload?.cwd), title, preview: firstUserText, - createdAt: candidate.meta?.createdAt ?? asEpochMs(payload.timestamp) ?? asEpochMs(first?.timestamp), + createdAt: meta.createdAt ?? asEpochMs(payload?.timestamp) ?? asEpochMs(first?.timestamp), updatedAt: Math.max(indexed?.updatedAt ?? 0, candidate.mtimeMs), messageCount: countJsonlUserMessagesCheap(filePath, "codex"), launch, diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index 92a8828ef..38899d57b 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -252,6 +252,195 @@ describe("external session provider discovery", () => { }); }); + it("finds project-scoped Codex sessions beyond 2,000 newer rollouts", async () => { + const homeDir = path.join(root, "home"); + const projectCwd = path.join(root, "repo"); + const otherCwd = path.join(root, "other-repo"); + const outsideDir = path.join(homeDir, ".codex", "sessions", "2026", "07", "08"); + const insideDir = path.join(homeDir, ".codex", "sessions", "2026", "07", "07"); + const outsideId = "26000000-0000-4000-8000-000000000000"; + const insideId = "26000000-0000-4000-8000-000000000001"; + fs.mkdirSync(projectCwd, { recursive: true }); + fs.mkdirSync(otherCwd, { recursive: true }); + + const outsideTemplate = path.join(outsideDir, "rollout-outside-0000.jsonl"); + writeJsonl(outsideTemplate, [ + { + type: "session_meta", + payload: { id: outsideId, cwd: otherCwd, source: "cli", originator: "codex-tui" }, + }, + { type: "event_msg", payload: { type: "user_message", message: "outside project" } }, + ]); + for (let index = 1; index <= 2_000; index += 1) { + fs.linkSync(outsideTemplate, path.join(outsideDir, `rollout-outside-${String(index).padStart(4, "0")}.jsonl`)); + } + fs.utimesSync( + outsideTemplate, + new Date("2026-07-08T12:00:00.000Z"), + new Date("2026-07-08T12:00:00.000Z"), + ); + + const insidePath = path.join(insideDir, `rollout-${insideId}.jsonl`); + writeJsonl(insidePath, [ + { + type: "session_meta", + payload: { id: insideId, cwd: projectCwd, source: "cli", originator: "codex-tui" }, + }, + { type: "event_msg", payload: { type: "user_message", message: "inside project" } }, + ]); + fs.utimesSync( + insidePath, + new Date("2026-07-07T12:00:00.000Z"), + new Date("2026-07-07T12:00:00.000Z"), + ); + + const sessions = await discoverCodexSessions({ + homeDir, + limit: 1, + scopeRoots: [projectCwd], + }); + + expect(sessions).toEqual([ + expect.objectContaining({ + id: insideId, + cwd: projectCwd, + preview: "inside project", + }), + ]); + const cacheDir = path.join(homeDir, ".ade", "cache", "external-sessions"); + expect(fs.readdirSync(cacheDir)).toEqual([ + expect.stringMatching(/^codex-cwd-index-[0-9a-f]{16}\.json$/u), + ]); + + const openSync = vi.spyOn(fs, "openSync"); + let openedPaths: string[] = []; + try { + await expect(discoverCodexSessions({ + homeDir, + limit: 1, + scopeRoots: [projectCwd], + })).resolves.toEqual([ + expect.objectContaining({ id: insideId }), + ]); + openedPaths = openSync.mock.calls.map((call) => String(call[0])); + } finally { + openSync.mockRestore(); + } + expect(openedPaths.some((filePath) => filePath.startsWith(outsideDir))).toBe(false); + }); + + it("batches Codex scope index rewrites while an active rollout changes", async () => { + vi.useFakeTimers(); + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "26000000-0000-4000-8000-000000000002"; + const rolloutPath = path.join( + homeDir, + ".codex", + "sessions", + "2026", + "07", + "08", + `rollout-${id}.jsonl`, + ); + fs.mkdirSync(cwd, { recursive: true }); + writeJsonl(rolloutPath, [ + { + type: "session_meta", + payload: { id, cwd, source: "cli", originator: "codex-tui" }, + }, + { type: "event_msg", payload: { type: "user_message", message: "first turn" } }, + ]); + + const writeFileSync = vi.spyOn(fs, "writeFileSync"); + try { + await discoverCodexSessions({ homeDir, limit: 1, scopeRoots: [cwd] }); + const indexWrites = () => writeFileSync.mock.calls.filter( + ([filePath]) => String(filePath).includes("codex-cwd-index-"), + ); + expect(indexWrites()).toHaveLength(1); + + fs.appendFileSync( + rolloutPath, + `${JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "second turn" } })}\n`, + "utf8", + ); + await discoverCodexSessions({ homeDir, limit: 1, scopeRoots: [cwd] }); + + expect(indexWrites()).toHaveLength(1); + fs.appendFileSync( + rolloutPath, + `${JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "third turn" } })}\n`, + "utf8", + ); + await discoverCodexSessions({ homeDir, limit: 1, scopeRoots: [cwd] }); + expect(indexWrites()).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(30_000); + expect(indexWrites()).toHaveLength(2); + + const cacheDir = path.join(homeDir, ".ade", "cache", "external-sessions"); + const indexPath = path.join(cacheDir, fs.readdirSync(cacheDir)[0]!); + const index = JSON.parse(fs.readFileSync(indexPath, "utf8")) as { + entries: Record; + }; + const relativePath = path.relative( + path.join(homeDir, ".codex", "sessions"), + rolloutPath, + ).split(path.sep).join("/"); + expect(index.entries[relativePath]?.size).toBe(fs.statSync(rolloutPath).size); + } finally { + writeFileSync.mockRestore(); + vi.useRealTimers(); + } + }); + + it("excludes Codex subagent rollouts with object-shaped source metadata", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const parentId = "27000000-0000-4000-8000-000000000000"; + const subagentId = "27000000-0000-4000-8000-000000000001"; + fs.mkdirSync(cwd, { recursive: true }); + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "08", `rollout-${parentId}.jsonl`), [ + { + type: "session_meta", + payload: { id: parentId, cwd, source: "cli", originator: "codex-tui" }, + }, + { type: "event_msg", payload: { type: "user_message", message: "parent request" } }, + ]); + writeJsonl(path.join(homeDir, ".codex", "sessions", "2026", "07", "08", `rollout-${subagentId}.jsonl`), [ + { + type: "session_meta", + payload: { + id: subagentId, + cwd, + source: { + subagent: { + thread_spawn: { + parent_thread_id: parentId, + depth: 1, + agent_path: "/root/reviewer", + }, + }, + }, + originator: "codex-tui", + }, + }, + { type: "event_msg", payload: { type: "user_message", message: "worker request" } }, + ]); + + const sessions = await discoverCodexSessions({ homeDir, limit: 10 }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + provider: "codex", + id: parentId, + cwd, + preview: "parent request", + }); + expect(sessions.some((session) => session.id === subagentId)).toBe(false); + }); + it("finds and merges the latest Codex turn context beyond a 2 MiB tail", async () => { const homeDir = path.join(root, "home"); const cwd = path.join(root, "repo"); diff --git a/docs/features/terminals-and-sessions/external-session-import.md b/docs/features/terminals-and-sessions/external-session-import.md index 7024b61f5..b099e3048 100644 --- a/docs/features/terminals-and-sessions/external-session-import.md +++ b/docs/features/terminals-and-sessions/external-session-import.md @@ -33,7 +33,7 @@ continuation metadata is recorded as soon as ADE knows the provider target. | `apps/desktop/src/main/services/externalSessions/externalSessionsService.ts` | Service entry point. Runs provider discovery, applies the capabilities matrix, filters project/all scope, detects already-imported sessions, validates import ids, enforces optional lane cwd scope, builds CLI resume/fork commands, delegates chat import, and creates tracked PTYs. | | `apps/desktop/src/main/services/externalSessions/discoveryUtils.ts` | Shared discovery helpers: safe stat/read, top-N mtime sorting, JSONL prefix/suffix scans, semantic user-prompt extraction/counting, provider-wrapper cleanup, title cleanup, cwd slug helpers, shell quoting, and path-inside checks. | | `apps/desktop/src/main/services/externalSessions/discoverClaude.ts` | Discovers resumable Claude CLI JSONL transcripts under `CLAUDE_CONFIG_DIR` or `~/.claude/projects//.jsonl`; reads `ai-title`/custom titles and excludes SDK entrypoints. | -| `apps/desktop/src/main/services/externalSessions/discoverCodex.ts` | Discovers interactive Codex rollout JSONL files under `CODEX_HOME/sessions/YYYY/MM/DD/` (default `~/.codex`) and enriches them from `session_index.jsonl`. | +| `apps/desktop/src/main/services/externalSessions/discoverCodex.ts` | Discovers interactive Codex rollout JSONL files under `CODEX_HOME/sessions/YYYY/MM/DD/` (default `~/.codex`), enriches them from `session_index.jsonl`, and maintains the rebuildable cwd/importability index used by project-scoped discovery. | | `apps/desktop/src/main/services/externalSessions/discoverCursor.ts` | Discovers current Cursor sessions from `~/.cursor/chats///store.db`, merges legacy transcript previews from `~/.cursor/projects/.../agent-transcripts`, uses `.workspace-trusted` for exact cwd recovery, and excludes SDK `agent-` sessions. | | `apps/desktop/src/main/services/externalSessions/discoverDroid.ts` | Discovers Factory Droid JSONL sessions under `~/.factory/sessions//`, using the `session_start` row for id/cwd/title. | | `apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts` | Discovers OpenCode sessions by running `opencode session list --pure --format json --max-count ` in the requested/project cwd. | @@ -75,8 +75,12 @@ current project scope unless `scope: "all"` is requested, and returns File-backed providers are stat-first. Discovery gathers candidate files, sorts by mtime, and reads a bounded recent candidate window. Claude keeps scanning past filtered SDK transcripts until it has filled the requested CLI-session -limit; Codex uses a larger bounded window so non-interactive rollouts cannot -starve valid CLI results. The cheap JSONL read is bounded by +limit. Codex all-history discovery uses a larger bounded window so +non-interactive rollouts cannot starve valid CLI results. Codex project-scoped +discovery instead refreshes a rebuildable cwd/importability index under +`$ADE_HOME/cache/external-sessions/`: it stats the rollout inventory, reads the +`session_meta` prefix only for new or changed files, and filters the complete +index without a fixed file-count ceiling. The cheap JSONL read is bounded by `JSONL_SCAN_BYTE_LIMIT` and `JSONL_SCAN_LINE_LIMIT`; meaningful user prompt counts are only computed for files under `MESSAGE_COUNT_MAX_BYTES`. Provider metadata, assistant/tool rows, local-command wrappers, and duplicate @@ -300,7 +304,9 @@ app-server `thread/read`, `thread/fork`, `thread/archive`, resume, and fork surfaces. Only interactive CLI rollouts are importable. ADE excludes exec, VS Code, -desktop/ADE-originated, and subagent rollouts. For preview/count it prefers the +desktop/ADE-originated, and subagent rollouts. Structured/unknown `source` +metadata fails closed; current Codex subagents use an object-shaped source +record rather than the older string form. For preview/count ADE prefers the canonical `event_msg.payload.type = user_message` row so duplicated `response_item` rows and synthetic environment instructions are not shown.