456 lines · 17.4 KB · TypeScript
RawDownload
/** * 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<AgentSessionScanResult, 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 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<string, unknown>;  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<string, unknown>).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<string> => []));   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<string | null> {    const prefix = yield* Effect.scoped(      fileSystem        .open(filePath, { flag: "r" })        .pipe(Effect.flatMap((file) => file.readAlloc(TRANSCRIPT_PREFIX_BYTES))),    ).pipe(Effect.orElseSucceed(Option.none<Uint8Array>));    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<string>,  ) {    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<RawCandidate> = [];    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<string, { threadCount: number; lastActiveAtMs: number }>();      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<typeof resolveCodexHomeLayout>[0],  ) {    const layout = yield* resolveCodexHomeLayout(codexSettings).pipe(      Effect.provideService(Path.Path, path),    );    const sessionsDir = path.join(layout.sharedHomePath, "sessions");     const rollouts: Array<string> = [];    // 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<string, Array<string>>();    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<RawCandidate> = [];    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<AgentSessionSource>;        threadCount: number;        lastActiveAtMs: number | null;      }    >();    const realPathKeys = new Map<string, string>();     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<AgentSessionProjectCandidate> = [];    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);