/** * 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, type AgentSessionProjectCandidate, type AgentSessionScanResult, } 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 { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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"; /** * Bytes read from the head of each transcript. Session metadata (including * `cwd`) is written in the first record, so reading a prefix keeps the scan * cheap even when a transcript is hundreds of megabytes. */ const TRANSCRIPT_PREFIX_BYTES = 32 * 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 `stat` calls per source. Newest-first ordering needs mtimes * before the read cap can be applied, so stats get their own larger budget; * once it runs out the scan stops rather than walking a pathological home * indefinitely. */ const MAX_STATS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; /** 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; } >()("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 threadCount: number; readonly lastActiveAtMs: number | null; } const decoder = new TextDecoder(); /** * T3 Code runs its own agent sessions inside disposable worktrees. Their * transcripts look exactly like user sessions, but re-importing the app's own * sandboxes as projects is never right. Matches this server's configured * worktrees directory plus the conventional `.t3/worktrees` layout, which * also catches sandboxes from other T3 homes on the same machine. Separators * are normalized (and, on Windows, case folded) so the prefix match holds * there too. Callers check both the recorded spelling and its realpath so a * symlink into the worktrees directory cannot bypass the filter. */ 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/") ); } /** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ function extractCwd(line: string): string | null { 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; } // Codex nests session metadata under `payload`. const payload = record.payload; if (typeof payload === "object" && payload !== null) { const nested = (payload as Record).cwd; if (typeof nested === "string" && nested.trim().length > 0) { return nested; } } return null; } export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const worktreesDir = path.resolve(serverConfig.worktreesDir); // Windows filesystems are case-insensitive, so path prefix checks there // must case fold. const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; const hostEnvironment = yield* HostProcessEnvironment; const listDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const statOption = (target: string) => fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); /** * Read the head of a transcript and return its first complete line. Returns * `null` when the file is unreadable or its first line exceeds the prefix. */ const readFirstLine = Effect.fn("AgentSessionScanner.readFirstLine")(function* ( filePath: string, ): Effect.fn.Return { const prefix = yield* Effect.scoped( fileSystem .open(filePath, { flag: "r" }) .pipe(Effect.flatMap((file) => file.readAlloc(TRANSCRIPT_PREFIX_BYTES))), ).pipe(Effect.orElseSucceed(Option.none)); if (Option.isNone(prefix)) return null; const text = decoder.decode(prefix.value); const newlineIndex = text.indexOf("\n"); if (newlineIndex === -1) { // Either a single-line file smaller than the prefix, or a first record // too large to trust as complete JSON. return prefix.value.length < TRANSCRIPT_PREFIX_BYTES ? text : null; } return text.slice(0, newlineIndex); }); const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (filePath: string) { const line = yield* readFirstLine(filePath); return line === null ? null : extractCwd(line.trim()); }); const latestMtimeMs = Effect.fn("AgentSessionScanner.latestMtimeMs")(function* ( filePaths: ReadonlyArray, ) { let latest: number | null = null; for (const filePath of filePaths) { const stats = yield* statOption(filePath); if (Option.isNone(stats)) continue; const mtime = stats.value.mtime; if (Option.isNone(mtime)) continue; const value = mtime.value.getTime(); if (latest === null || value > latest) { latest = value; } } return latest; }); /** * Resolve the Claude config directory the CLI would use, matching the * precedence the spawned CLI sees: the instance's `homePath` (exported as * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the * environment, then `~/.claude`. */ const resolveClaudeConfigDir = (homePath: string): string => { const configured = homePath.trim(); if (configured.length > 0) { return path.resolve(expandHomePath(configured)); } const fromEnvironment = hostEnvironment.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (fromEnvironment.length > 0) { return path.resolve(expandHomePath(fromEnvironment)); } return path.join(NodeOS.homedir(), ".claude"); }; /** * Claude keeps one directory per project under `projects/`, named after a * lossy slug of the path. The slug can't be decoded (both `/` and `.` become * `-`), so the real path comes from the `cwd` recorded in the newest * transcript inside it. */ const scanClaude = Effect.fn("AgentSessionScanner.scanClaude")(function* (homePath: string) { const projectsDir = path.join(resolveClaudeConfigDir(homePath), "projects"); const projectDirectories = yield* listDirectory(projectsDir); const candidates: Array = []; let readBudget = MAX_TRANSCRIPTS_PER_SOURCE; let statBudget = MAX_STATS_PER_SOURCE; for (const projectDirectory of projectDirectories) { if (readBudget <= 0 || statBudget <= 0) break; const directory = path.join(projectsDir, projectDirectory); const transcripts = (yield* listDirectory(directory)) .filter((entry) => entry.endsWith(".jsonl")) .map((entry) => path.join(directory, entry)); if (transcripts.length === 0) continue; // Stat (bounded), then spend the read budget newest-first, so the cap // only ever drops the oldest transcripts. const statted = transcripts.slice(0, statBudget); statBudget -= statted.length; const withMtimes: Array<{ filePath: string; mtimeMs: number }> = []; for (const filePath of statted) { const stats = yield* statOption(filePath); if (Option.isNone(stats) || Option.isNone(stats.value.mtime)) continue; withMtimes.push({ filePath, mtimeMs: stats.value.mtime.value.getTime() }); } withMtimes.sort((left, right) => right.mtimeMs - left.mtimeMs); withMtimes.splice(readBudget); readBudget -= withMtimes.length; // The slug is lossy (`/a/b.c` and `/a/b/c` share a directory), so a // directory can hold sessions from several distinct paths — group by // the recorded cwd like the Codex scan does. const byCwd = new Map(); for (const entry of withMtimes) { const cwd = yield* readCwd(entry.filePath); if (cwd === null) continue; const existing = byCwd.get(cwd); if (existing) { existing.threadCount += 1; existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, entry.mtimeMs); } else { byCwd.set(cwd, { threadCount: 1, lastActiveAtMs: entry.mtimeMs }); } } for (const [cwd, group] of byCwd) { candidates.push({ cwd, source: "claudeAgent", threadCount: group.threadCount, lastActiveAtMs: group.lastActiveAtMs, }); } } return candidates; }); /** * Codex writes `sessions/YYYY/MM/DD/rollout-*.jsonl`. Always scan the shared * home: in auth-overlay mode the effective home only symlinks to it. */ const scanCodex = Effect.fn("AgentSessionScanner.scanCodex")(function* ( codexSettings: Parameters[0], ) { const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( Effect.provideService(Path.Path, path), ); const sessionsDir = path.join(layout.sharedHomePath, "sessions"); const rollouts: Array = []; // Date-partitioned directories sort chronologically, so walking them in // reverse keeps the newest sessions when the budget runs out. for (const year of (yield* listDirectory(sessionsDir)).toSorted().toReversed()) { for (const month of (yield* listDirectory(path.join(sessionsDir, year))) .toSorted() .toReversed()) { for (const day of (yield* listDirectory(path.join(sessionsDir, year, month))) .toSorted() .toReversed()) { const directory = path.join(sessionsDir, year, month, day); for (const entry of (yield* listDirectory(directory)).toSorted().toReversed()) { if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; rollouts.push(path.join(directory, entry)); if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; } if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; } if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; } if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; } const byCwd = new Map>(); for (const rollout of rollouts) { const cwd = yield* readCwd(rollout); if (cwd === null) continue; const existing = byCwd.get(cwd); if (existing) { existing.push(rollout); } else { byCwd.set(cwd, [rollout]); } } const candidates: Array = []; for (const [cwd, filePaths] of byCwd) { candidates.push({ cwd, source: "codex", threadCount: filePaths.length, lastActiveAtMs: yield* latestMtimeMs(filePaths), }); } return candidates; }); const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { const settings = yield* serverSettings.getSettings.pipe( Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), ); const raw = [ ...(yield* scanClaude(settings.providers.claudeAgent.homePath)), ...(yield* scanCodex(settings.providers.codex)), ]; // Merge by resolved path first so both sources agree on a key, then by // realpath so a symlinked home and its target collapse into one candidate. const merged = new Map< string, { path: string; sources: Array; threadCount: number; lastActiveAtMs: number | null; } >(); const realPathKeys = new Map(); for (const candidate of raw) { const resolved = path.resolve(expandHomePath(candidate.cwd.trim())); if (isT3ManagedWorktree(resolved, worktreesDir, foldWorktreeCase)) continue; let key = realPathKeys.get(resolved); if (key === undefined) { const stats = yield* statOption(resolved); // Directories that no longer exist can't be imported. if (Option.isNone(stats) || stats.value.type !== "Directory") { realPathKeys.set(resolved, ""); continue; } key = yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); // A symlink can point into the worktrees directory even when its own // spelling doesn't; check again with links resolved. if (isT3ManagedWorktree(key, worktreesDir, foldWorktreeCase)) { key = ""; } realPathKeys.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, }); 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); } const candidates: Array = []; for (const [key, entry] of merged.entries()) { // Projects may have been created under either the recorded spelling or // the resolved realpath (e.g. a symlinked home) — check both. const lookupPaths = key === entry.path ? [entry.path] : [entry.path, key]; let alreadyImported = false; for (const lookupPath of lookupPaths) { const existingProject = yield* projectionSnapshotQuery .getActiveProjectByWorkspaceRoot(lookupPath) .pipe( Effect.mapError( (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), ), ); if (Option.isSome(existingProject)) { alreadyImported = true; break; } } candidates.push({ path: entry.path, title: path.basename(entry.path) || entry.path, sources: entry.sources, threadCount: entry.threadCount, lastActiveAt: entry.lastActiveAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), alreadyImported, }); } // Newest first, undated candidates last. 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), }; }); return AgentSessionScanner.of({ scan }); }); export const layer = Layer.effect(AgentSessionScanner, make);