1495 lines · 56.6 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,  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.