1/**
2 * AgentSessionScanner - discovery of projects a user already works on.
3 *
4 * Claude Code and Codex both keep a per-session transcript on disk, and each
5 * transcript records the directory the session ran in. Reading those `cwd`
6 * values gives us the set of directories worth offering as projects during
7 * onboarding, without asking the user to browse the filesystem.
8 *
9 * The scan is read-only and best-effort: an unreadable home, a malformed
10 * transcript, or a directory that has since been deleted is skipped rather
11 * than failing the scan. Project creation stays with the client, which
12 * dispatches `project.create` for whichever candidates the user picks.
13 *
14 * @module project/AgentSessionScanner
15 */
16import * as NodeOS from "node:os";
17
18import {
19 AgentSessionScanError,
20 ClaudeSettings,
21 CodexSettings,
22 ProviderDriverKind,
23 ProviderInstanceId,
24 resolveProviderInstanceEnabled,
25 type AgentSessionImportSource,
26 type AgentSessionProjectCandidate,
27 type AgentSessionProjectGit,
28 type AgentSessionScanResult,
29 type ProviderInstanceConfig,
30} from "@t3tools/contracts";
31import * as Context from "effect/Context";
32import * as DateTime from "effect/DateTime";
33import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
35import * as Layer from "effect/Layer";
36import * as Option from "effect/Option";
37import * as Path from "effect/Path";
38import * as Schema from "effect/Schema";
39import * as Semaphore from "effect/Semaphore";
40import * as Stream from "effect/Stream";
41
42import {
43 normalizeGitRemoteUrl,
44 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
45 parseOriginUrlFromGitConfig,
46} from "@t3tools/shared/git";
47import * as HostProcess from "@t3tools/shared/HostProcess";
48import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
49
50import * as ServerConfig from "../config.ts";
51import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
52import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
53import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
54import * as ServerSettings from "../serverSettings.ts";
55import {
56 createTranscriptJsonReader,
57 createTranscriptJsonSelector,
58 TranscriptJsonLimitError,
59} from "./AgentSessionJson.ts";
60
61/** Chunk size for full transcript reads. */
62const TRANSCRIPT_PREFIX_BYTES = 32 * 1024;
63/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */
64const METADATA_READ_BYTES = 8 * 1024;
65/** Prevent malformed transcripts from turning project discovery into a full file scan. */
66const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024;
67
68/**
69 * Upper bound on transcripts inspected (first line read) per source.
70 * Newest-first ordering means the cap drops only stale sessions when a home
71 * directory is unusually large.
72 */
73const MAX_TRANSCRIPTS_PER_SOURCE = 5000;
74
75/**
76 * Upper bound on discovery filesystem operations per source. Newest-first
77 * ordering needs mtimes before the read cap can be applied, so directory reads
78 * and candidate stats share a larger budget. Once it runs out the scan stops.
79 */
80const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4;
81const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024;
82const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4;
83const MAX_METADATA_RECORDS_PER_SOURCE = 100_000;
84const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000;
85const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
86/**
87 * Large tool results (especially screenshots) can make an otherwise ordinary
88 * Codex transcript several GiB. Streaming field selection avoids allocating
89 * those payloads. Raw I/O and selected history have separate budgets.
90 */
91const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024;
92const MAX_IMPORTED_MESSAGES = 200;
93const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024;
94const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024;
95const MAX_IMPORT_TRANSCRIPTS = 100;
96const MAX_IMPORT_RECORDS = 100_000;
97
98const TranscriptContentBlock = Schema.Struct({
99 type: Schema.optional(Schema.String),
100 text: Schema.optional(Schema.String),
101});
102
103const TranscriptMessage = Schema.Struct({
104 role: Schema.optional(Schema.String),
105 content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])),
106 model: Schema.optional(Schema.String),
107});
108
109const CodexTurnMetadata = Schema.Struct({
110 turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])),
111});
112
113const TranscriptRecord = Schema.Struct({
114 type: Schema.optional(Schema.String),
115 timestamp: Schema.optional(Schema.String),
116 cwd: Schema.optional(Schema.String),
117 sessionId: Schema.optional(Schema.String),
118 aiTitle: Schema.optional(Schema.String),
119 isSidechain: Schema.optional(Schema.Boolean),
120 isMeta: Schema.optional(Schema.Boolean),
121 isCompactSummary: Schema.optional(Schema.Boolean),
122 message: Schema.optional(TranscriptMessage),
123 payload: Schema.optional(
124 Schema.Struct({
125 id: Schema.optional(Schema.String),
126 session_id: Schema.optional(Schema.String),
127 type: Schema.optional(Schema.String),
128 role: Schema.optional(Schema.String),
129 message: Schema.optional(Schema.String),
130 model: Schema.optional(Schema.String),
131 cwd: Schema.optional(Schema.String),
132 content: Schema.optional(Schema.Array(TranscriptContentBlock)),
133 internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown),
134 }),
135 ),
136});
137
138const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings);
139const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings);
140const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord));
141const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord);
142const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord);
143const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata);
144
145type DecodedTranscriptRecord = typeof TranscriptRecord.Type;
146
147interface AgentSessionTranscriptMetadata {
148 readonly source: AgentSessionSource;
149 readonly providerInstanceId: ProviderInstanceId;
150 readonly fallbackSessionId: string;
151 readonly lastActiveAtMs: number;
152}
153
154export interface AgentSessionThreadMessage {
155 readonly role: "user" | "assistant";
156 readonly text: string;
157 readonly createdAt: string;
158}
159
160export interface AgentSessionThread {
161 readonly source: AgentSessionSource;
162 readonly providerInstanceId: ProviderInstanceId;
163 readonly providerSessionId: string;
164 readonly title: string;
165 readonly model: string | null;
166 readonly createdAt: string;
167 readonly updatedAt: string;
168 readonly messages: ReadonlyArray<AgentSessionThreadMessage>;
169}
170
171export type AgentSessionRecentThread =
172 | {
173 readonly _tag: "Importable";
174 readonly thread: AgentSessionThread;
175 readonly source: AgentSessionImportSource;
176 }
177 | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource }
178 | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource }
179 | { readonly _tag: "Skipped" };
180
181/** Service tag for agent session discovery. */
182export class AgentSessionScanner extends Context.Service<
183 AgentSessionScanner,
184 {
185 /**
186 * Discover every directory the configured Claude and Codex homes have run
187 * a session in. Candidates are returned newest-first; the client decides
188 * which ones to import and how far back to look. Fails with the contract
189 * error directly — there is no server-local context worth wrapping.
190 */
191 readonly scan: Effect.Effect<AgentSessionScanResult, AgentSessionScanError>;
192 readonly recentThreads: (
193 workspaceRoot: string,
194 completedSources?: ReadonlyArray<AgentSessionImportSource>,
195 ) => Stream.Stream<AgentSessionRecentThread, AgentSessionScanError>;
196 }
197>()("t3/project/AgentSessionScanner") {}
198
199type AgentSessionSource = AgentSessionProjectCandidate["sources"][number];
200
201/** A single directory's worth of evidence from one source. */
202interface RawCandidate {
203 readonly cwd: string;
204 readonly source: AgentSessionSource;
205 readonly providerInstanceId: ProviderInstanceId;
206 readonly threadCount: number;
207 readonly lastActiveAtMs: number | null;
208 readonly transcripts: ReadonlyArray<{
209 readonly filePath: string;
210 readonly mtimeMs: number | null;
211 }>;
212}
213
214interface TranscriptCandidate {
215 readonly filePath: string;
216 readonly mtimeMs: number;
217 readonly providerInstanceId: ProviderInstanceId;
218 readonly size: number;
219}
220
221interface MetadataReadBudget {
222 bytesRemaining: number;
223 operationsRemaining: number;
224 recordsRemaining: number;
225 truncated: boolean;
226}
227
228function selectMetadataTranscripts(transcripts: ReadonlyArray<TranscriptCandidate>) {
229 const selected: Array<TranscriptCandidate> = [];
230 let pending = Array.from(
231 Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(),
232 (entries) => entries.values(),
233 );
234 while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) {
235 const nextRound: typeof pending = [];
236 for (const iterator of pending) {
237 if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break;
238 const next = iterator.next();
239 if (next.done) continue;
240 selected.push(next.value);
241 nextRound.push(iterator);
242 }
243 pending = nextRound;
244 }
245 return selected;
246}
247
248function splitTranscriptRecords(contents: string, limit: number): string[] {
249 const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents;
250 return records.split("\n", limit);
251}
252
253function extractText(
254 content: string | ReadonlyArray<typeof TranscriptContentBlock.Type> | undefined,
255): string {
256 if (typeof content === "string") return content.trim();
257 if (content === undefined) return "";
258 return content
259 .filter(
260 (block) =>
261 block.type === "text" || block.type === "input_text" || block.type === "output_text",
262 )
263 .map((block) => block.text?.trim() ?? "")
264 .filter((text) => text.length > 0)
265 .join("\n");
266}
267
268function normalizeTimestamp(value: string | undefined, fallback: string): string {
269 if (value === undefined) return fallback;
270 const parsed = DateTime.make(value);
271 return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback;
272}
273
274function codexTurnId(metadata: unknown): string | null {
275 const decoded = decodeCodexTurnMetadata(metadata);
276 if (
277 Option.isNone(decoded) ||
278 typeof decoded.value.turn_id !== "string" ||
279 decoded.value.turn_id.trim().length === 0
280 ) {
281 return null;
282 }
283 return decoded.value.turn_id;
284}
285
286/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */
287export function parseAgentSessionTranscript(
288 input: AgentSessionTranscriptMetadata & {
289 readonly contents: string;
290 },
291 lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1),
292): AgentSessionThread | null {
293 if (lines.length > MAX_IMPORT_RECORDS) return null;
294 const records = lines.flatMap((line) => Option.toArray(decodeTranscriptRecord(line)));
295 return parseAgentSessionRecords(input, records);
296}
297
298function parseAgentSessionRecords(
299 input: AgentSessionTranscriptMetadata,
300 records: ReadonlyArray<DecodedTranscriptRecord>,
301): AgentSessionThread | null {
302 const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs));
303 // Claude filenames are session IDs. Codex rollout filenames include extra
304 // timestamp text, so only transcript metadata can provide a resumable ID.
305 let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId;
306 let title: string | null = null;
307 let model: string | null = null;
308 let hasCodexSessionId = false;
309 const messages: Array<AgentSessionThreadMessage & { readonly codexResponseUser: boolean }> = [];
310 let firstUserMessage:
311 | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean })
312 | undefined;
313 // A Codex response item can include generated setup text beside the real
314 // prompt. Suppress response-user records only when the shared turn ID and a
315 // verbatim event copy prove which prompt the user submitted.
316 const canonicalCodexResponseUserIndices = new Set<number>();
317 let canonicalUserTextsInTurn = new Set<string>();
318 let responseUsersInTurn: Array<{
319 readonly index: number;
320 readonly turnId: string;
321 readonly text: string;
322 }> = [];
323 const finishCodexTurn = () => {
324 const canonicalTurnIds = new Set(
325 responseUsersInTurn.flatMap((responseUser) =>
326 canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [],
327 ),
328 );
329 for (const responseUser of responseUsersInTurn) {
330 if (canonicalTurnIds.has(responseUser.turnId)) {
331 canonicalCodexResponseUserIndices.add(responseUser.index);
332 }
333 }
334 canonicalUserTextsInTurn = new Set();
335 responseUsersInTurn = [];
336 };
337 if (input.source === "codex") {
338 let recordIndex = -1;
339 for (const record of records) {
340 recordIndex += 1;
341 if (
342 record.type === "response_item" &&
343 record.payload?.type === "message" &&
344 record.payload.role === "assistant"
345 ) {
346 finishCodexTurn();
347 continue;
348 }
349 if (record.type === "event_msg" && record.payload?.type === "user_message") {
350 const text = record.payload.message?.trim() ?? "";
351 if (text.length > 0) canonicalUserTextsInTurn.add(text);
352 continue;
353 }
354 if (
355 record.type === "response_item" &&
356 record.payload?.type === "message" &&
357 record.payload.role === "user"
358 ) {
359 const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough);
360 const text = extractText(record.payload.content);
361 if (turnId !== null && text.length > 0) {
362 responseUsersInTurn.push({ index: recordIndex, turnId, text });
363 }
364 }
365 }
366 finishCodexTurn();
367 }
368
369 const retainMessage = (
370 message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean },
371 ) => {
372 if (firstUserMessage === undefined && message.role === "user") {
373 firstUserMessage = message;
374 }
375 messages.push(message);
376 if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift();
377 };
378
379 const hasMatchingCodexEventInTurn = (text: string) => {
380 const comparisonText = text.trim();
381 for (let index = messages.length - 1; index >= 0; index--) {
382 const message = messages[index];
383 if (message?.role === "assistant") return false;
384 if (
385 message?.role === "user" &&
386 !message.codexResponseUser &&
387 message.text.trim() === comparisonText
388 ) {
389 return true;
390 }
391 }
392 return false;
393 };
394
395 let recordIndex = -1;
396 for (const record of records) {
397 recordIndex += 1;
398 if (input.source === "claudeAgent") {
399 if (
400 record.isSidechain === true ||
401 record.isMeta === true ||
402 record.isCompactSummary === true
403 ) {
404 continue;
405 }
406 if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim();
407 if (record.aiTitle?.trim()) title = record.aiTitle.trim();
408 const messageModel = record.message?.model?.trim();
409 // Claude uses this sentinel for local error responses. It is not a
410 // model ID that can be selected when the imported session resumes.
411 if (messageModel && messageModel !== "<synthetic>") model = messageModel;
412 if (record.type !== "user" && record.type !== "assistant") {
413 continue;
414 }
415
416 const text = extractText(record.message?.content);
417 if (text.length === 0) continue;
418 retainMessage({
419 role: record.type,
420 text,
421 createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
422 codexResponseUser: false,
423 });
424 continue;
425 }
426
427 if (record.type === "session_meta") {
428 const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim();
429 if (!hasCodexSessionId && sessionId) {
430 providerSessionId = sessionId;
431 hasCodexSessionId = true;
432 }
433 continue;
434 }
435 if (record.type === "turn_context" && record.payload?.model?.trim()) {
436 model = record.payload.model.trim();
437 continue;
438 }
439 if (record.type === "event_msg" && record.payload?.type === "user_message") {
440 const text = record.payload.message ?? "";
441 if (text.trim().length === 0) continue;
442 // Codex can write the same prompt as both a response item and an event.
443 // Remove only the matching response copy so mixed-format logs keep every
444 // distinct user message.
445 for (let index = messages.length - 1; index >= 0; index--) {
446 const message = messages[index];
447 if (message?.role === "assistant") break;
448 if (message?.codexResponseUser === true && message.text.trim() === text.trim()) {
449 if (firstUserMessage === message) firstUserMessage = undefined;
450 messages.splice(index, 1);
451 break;
452 }
453 }
454 retainMessage({
455 role: "user",
456 text,
457 createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
458 codexResponseUser: false,
459 });
460 continue;
461 }
462 if (
463 record.type !== "response_item" ||
464 record.payload?.type !== "message" ||
465 (record.payload.role !== "user" && record.payload.role !== "assistant")
466 ) {
467 continue;
468 }
469
470 const extractedText = extractText(record.payload.content);
471 if (extractedText.length === 0) continue;
472 if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) {
473 continue;
474 }
475 if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) {
476 continue;
477 }
478 retainMessage({
479 role: record.payload.role,
480 text: extractedText,
481 createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp),
482 codexResponseUser: record.payload.role === "user",
483 });
484 }
485
486 const visibleMessages = messages.map(
487 ({ codexResponseUser: _codexResponseUser, ...message }) => message,
488 );
489 if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null;
490 const firstUserMessageRetained = messages.includes(firstUserMessage);
491 const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage;
492 const retainedMessages = firstUserMessageRetained
493 ? visibleMessages
494 : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))];
495 const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim();
496
497 return {
498 source: input.source,
499 providerInstanceId: input.providerInstanceId,
500 providerSessionId,
501 title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"),
502 model,
503 createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp,
504 updatedAt: fallbackTimestamp,
505 messages: retainedMessages,
506 };
507}
508
509function extractDecodedCwd(record: DecodedTranscriptRecord): string | null {
510 const cwd = record.cwd?.trim() || record.payload?.cwd?.trim();
511 return cwd && cwd.length > 0 ? cwd : null;
512}
513
514function shouldRetainDecodedRecord(
515 source: AgentSessionSource,
516 record: DecodedTranscriptRecord,
517): boolean {
518 if (extractDecodedCwd(record) !== null) return true;
519 if (source === "claudeAgent") {
520 return (
521 record.type === "user" ||
522 record.type === "assistant" ||
523 record.sessionId !== undefined ||
524 record.aiTitle !== undefined ||
525 record.message?.model !== undefined
526 );
527 }
528 return (
529 record.type === "session_meta" ||
530 record.type === "turn_context" ||
531 (record.type === "event_msg" && record.payload?.type === "user_message") ||
532 (record.type === "response_item" &&
533 record.payload?.type === "message" &&
534 (record.payload.role === "user" || record.payload.role === "assistant"))
535 );
536}
537
538/**
539 * T3 Code runs its own agent sessions inside disposable worktrees. Their
540 * transcripts look exactly like user sessions, but re-importing the app's own
541 * sandboxes as projects is never right. Matches this server's configured
542 * worktrees directory plus the conventional `.t3/worktrees` layout, which
543 * also catches sandboxes from other T3 homes on the same machine. Separators
544 * are normalized (and, on Windows, case folded) so the prefix match holds
545 * there too. Callers check both the recorded spelling and its realpath so a
546 * symlink into the worktrees directory cannot bypass the filter.
547 */
548function normalizeForWorktreeMatch(value: string, caseFold: boolean): string {
549 const normalized = `${value.replaceAll("\\", "/")}/`;
550 return caseFold ? normalized.toLowerCase() : normalized;
551}
552
553function isT3ManagedWorktree(
554 candidatePath: string,
555 worktreesDir: string,
556 caseFold: boolean,
557): boolean {
558 const normalized = normalizeForWorktreeMatch(candidatePath, caseFold);
559 return (
560 normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) ||
561 normalized.includes("/.t3/worktrees/")
562 );
563}
564
565/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */
566function extractCwd(line: string): string | null {
567 let parsed: unknown;
568 try {
569 parsed = JSON.parse(line);
570 } catch {
571 return null;
572 }
573 if (typeof parsed !== "object" || parsed === null) return null;
574
575 const record = parsed as Record<string, unknown>;
576 if (typeof record.cwd === "string" && record.cwd.trim().length > 0) {
577 return record.cwd;
578 }
579 // Codex nests session metadata under `payload`.
580 const payload = record.payload;
581 if (typeof payload === "object" && payload !== null) {
582 const nested = (payload as Record<string, unknown>).cwd;
583 if (typeof nested === "string" && nested.trim().length > 0) {
584 return nested;
585 }
586 }
587 return null;
588}
589
590function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) {
591 return {
592 filePath,
593 size: Number(stats.size),
594 mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }),
595 device: stats.dev,
596 inode: Option.getOrNull(stats.ino),
597 birthtimeMs: Option.match(stats.birthtime, {
598 onNone: () => null,
599 onSome: (date) => date.getTime(),
600 }),
601 };
602}
603
604function sameTranscriptIdentity(
605 left: ReturnType<typeof transcriptIdentity>,
606 right: ReturnType<typeof transcriptIdentity>,
607): boolean {
608 return (
609 left.filePath === right.filePath &&
610 left.size === right.size &&
611 left.mtimeMs === right.mtimeMs &&
612 left.device === right.device &&
613 left.inode === right.inode &&
614 left.birthtimeMs === right.birthtimeMs
615 );
616}
617
618/** @public Service construction is part of the canonical Effect module API. */
619export const make = Effect.gen(function* () {
620 const fileSystem = yield* FileSystem.FileSystem;
621 // Different project imports can arrive concurrently from multiple clients.
622 // Only one transcript may hold its selected-history budget at a time.
623 const importReadLock = yield* Semaphore.make(1);
624 const path = yield* Path.Path;
625 const serverConfig = yield* ServerConfig.ServerConfig;
626 const serverSettings = yield* ServerSettings.ServerSettingsService;
627 const projectStore = yield* ProjectStore.ProjectStoreV2;
628 const baseDir = path.resolve(serverConfig.baseDir);
629 const worktreesDir = path.resolve(serverConfig.worktreesDir);
630 // Windows filesystems are case-insensitive, so path prefix checks there
631 // must case fold.
632 const foldWorktreeCase = (yield* HostProcess.Platform) === "win32";
633 const hostEnvironment = yield* HostProcess.Environment;
634 const homeDir = yield* HostProcess.HomeDirectory;
635 // `/private/tmp` is what macOS reports for sessions started in `/tmp`.
636 const excludedProjectRoots = new Set(
637 [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) =>
638 normalizeProjectPathForComparison(path.resolve(directory)),
639 ),
640 );
641 // Codex creates one scratch directory per conversation under
642 // ~/Documents/Codex/<date>/<slug>. Neither those nor anything a user
643 // unpacked into Downloads is a project.
644 const excludedProjectAncestors = [
645 path.join(homeDir, "Downloads"),
646 path.join(homeDir, "Documents", "Codex"),
647 ];
648
649 const isExcludedProjectPath = (candidatePath: string) =>
650 excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||
651 excludedProjectAncestors.some((ancestor) =>
652 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
653 normalizeForWorktreeMatch(ancestor, foldWorktreeCase),
654 ),
655 ) ||
656 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
657 normalizeForWorktreeMatch(baseDir, foldWorktreeCase),
658 ) ||
659 isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase);
660
661 const listDirectory = (directory: string) =>
662 fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
663
664 const statOption = (target: string) =>
665 fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none));
666
667 /** Match directory aliases without assuming the host volume is case-insensitive. */
668 const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* (
669 target: string,
670 knownStats?: FileSystem.File.Info,
671 ) {
672 const resolved = path.resolve(target);
673 const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats);
674 if (
675 Option.isSome(stats) &&
676 Option.isSome(stats.value.ino) &&
677 Number.isSafeInteger(stats.value.ino.value) &&
678 stats.value.ino.value > 0
679 ) {
680 return `inode:${stats.value.dev}:${stats.value.ino.value}`;
681 }
682 const realPath = yield* fileSystem
683 .realPath(resolved)
684 .pipe(Effect.orElseSucceed(() => resolved));
685 return `path:${normalizeProjectPathForComparison(realPath)}`;
686 });
687
688 /**
689 * Git identity of a directory, or the reason it has none. Reads `.git`
690 * directly instead of spawning git so a scan over hundreds of candidates
691 * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a
692 * `worktrees/` directory the checkout is a linked worktree, which
693 * onboarding skips because its history belongs to the main checkout.
694 * Submodules use the same pointer shape but live under `modules/`, and
695 * are offered like any other repository.
696 */
697 const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
698 directory: string,
699 ): Effect.fn.Return<
700 | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null }
701 | { readonly _tag: "Worktree" }
702 | { readonly _tag: "NotGit" }
703 > {
704 const gitPath = path.join(directory, ".git");
705 const gitStats = yield* statOption(gitPath);
706 if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const;
707 let gitDir = gitPath;
708 if (gitStats.value.type !== "Directory") {
709 const pointer = yield* fileSystem
710 .readFileString(gitPath)
711 .pipe(Effect.orElseSucceed(() => ""));
712 const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
713 if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const;
714 gitDir = path.resolve(directory, target);
715 if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const;
716 }
717 const configText = yield* fileSystem
718 .readFileString(path.join(gitDir, "config"))
719 .pipe(Effect.orElseSucceed(() => ""));
720 const originUrl = parseOriginUrlFromGitConfig(configText);
721 return {
722 _tag: "Repository",
723 git: {
724 remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl),
725 repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl),
726 },
727 } as const;
728 });
729
730 // A large history snapshot can precede session metadata. Read bounded
731 // chunks until a complete record names its cwd or the safety budget ends.
732 const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (
733 transcript: TranscriptCandidate,
734 budget: MetadataReadBudget,
735 ) {
736 if (transcript.size === 0) return null;
737 if (
738 budget.bytesRemaining === 0 ||
739 budget.operationsRemaining < 2 ||
740 budget.recordsRemaining === 0
741 ) {
742 budget.truncated = true;
743 return null;
744 }
745 budget.operationsRemaining -= 1;
746 return yield* Effect.scoped(
747 fileSystem.open(transcript.filePath, { flag: "r" }).pipe(
748 Effect.flatMap((file) =>
749 Effect.gen(function* () {
750 const decoder = new TextDecoder();
751 let remaining = "";
752 let bytesRead = 0;
753 let recordsRead = 0;
754 const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size);
755 const reserveRecord = () => {
756 if (
757 recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT ||
758 budget.recordsRemaining === 0
759 ) {
760 budget.truncated = true;
761 return false;
762 }
763 recordsRead += 1;
764 budget.recordsRemaining -= 1;
765 return true;
766 };
767 const readLastRecord = () => {
768 const record = remaining + decoder.decode();
769 return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim());
770 };
771
772 while (bytesRead < maxBytes) {
773 if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) {
774 budget.truncated = true;
775 return null;
776 }
777 const readSize = Math.min(
778 METADATA_READ_BYTES,
779 maxBytes - bytesRead,
780 budget.bytesRemaining,
781 );
782 budget.operationsRemaining -= 1;
783 budget.bytesRemaining -= readSize;
784 const next = yield* file.readAlloc(readSize);
785 if (Option.isNone(next)) {
786 return readLastRecord();
787 }
788
789 bytesRead += next.value.byteLength;
790 remaining += decoder.decode(next.value, { stream: true });
791 const lines = remaining.split("\n");
792 remaining = lines.pop() ?? "";
793
794 for (const line of lines) {
795 if (!reserveRecord()) return null;
796 const cwd = extractCwd(line.trim());
797 if (cwd !== null) return cwd;
798 }
799 }
800
801 if (bytesRead < transcript.size) {
802 budget.truncated = true;
803 return null;
804 }
805 return readLastRecord();
806 }),
807 ),
808 ),
809 ).pipe(Effect.orElseSucceed(() => null));
810 });
811
812 /**
813 * Project history fields while reading, before allocating whole JSON records.
814 * Check the file identity on both sides of the read. A selected-history budget
815 * failure rejects the entire transcript before any imported messages persist.
816 */
817 const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* (
818 filePath: string,
819 expected: ReturnType<typeof transcriptIdentity>,
820 recordLimit: number,
821 source: AgentSessionSource,
822 ) {
823 if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null;
824
825 return yield* Effect.scoped(
826 fileSystem.open(filePath, { flag: "r" }).pipe(
827 Effect.flatMap((file) =>
828 Effect.gen(function* () {
829 if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) {
830 return null;
831 }
832 const records: Array<DecodedTranscriptRecord> = [];
833 let historyBytes = 0;
834 let recordBytes = 0;
835 let recordCount = 0;
836 let bytesRead = 0;
837 const reserve = (bytes: number) => {
838 recordBytes += bytes;
839 if (historyBytes + recordBytes > MAX_IMPORT_HISTORY_BYTES) {
840 throw new TranscriptJsonLimitError(
841 "Transcript selected history exceeds the 32 MiB memory budget",
842 );
843 }
844 };
845 let reader = createTranscriptJsonReader(reserve, selectTranscriptPath);
846 let decoder = new TextDecoder();
847 let recordStarted = false;
848
849 const finishRecord = () => {
850 reader.write(decoder.decode());
851 recordCount += 1;
852 if (recordCount > recordLimit) return false;
853 const decoded = decodeTranscriptValue(reader.finish());
854 if (Option.isSome(decoded) && shouldRetainDecodedRecord(source, decoded.value)) {
855 records.push(decoded.value);
856 historyBytes += recordBytes;
857 }
858 recordBytes = 0;
859 reader = createTranscriptJsonReader(reserve, selectTranscriptPath);
860 decoder = new TextDecoder();
861 recordStarted = false;
862 return true;
863 };
864
865 while (bytesRead < expected.size) {
866 const next = yield* file.readAlloc(
867 Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead),
868 );
869 if (Option.isNone(next)) {
870 return null;
871 }
872
873 bytesRead += next.value.byteLength;
874 const withinBudget = yield* Effect.try(() => {
875 let start = 0;
876 while (start < next.value.byteLength) {
877 const newline = next.value.indexOf(10, start);
878 const end = newline === -1 ? next.value.byteLength : newline;
879 recordStarted = true;
880 reader.write(decoder.decode(next.value.subarray(start, end), { stream: true }));
881 if (newline === -1) break;
882 if (!finishRecord()) return false;
883 start = newline + 1;
884 }
885 return true;
886 });
887 if (!withinBudget) return null;
888 }
889
890 if (recordStarted && !(yield* Effect.try(finishRecord))) return null;
891 return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))
892 ? { records, recordCount }
893 : null;
894 }),
895 ),
896 ),
897 ).pipe(
898 Effect.catch((cause) =>
899 Effect.logWarning("Could not read imported transcript", { filePath, cause }).pipe(
900 Effect.as(null),
901 ),
902 ),
903 );
904 });
905
906 /**
907 * Resolve the Claude config directory the CLI would use, matching the
908 * precedence the spawned CLI sees: the instance's `homePath` (exported as
909 * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the
910 * environment, then `~/.claude`.
911 */
912 const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => {
913 const configured = homePath.trim();
914 if (configured.length > 0) {
915 return path.resolve(expandHomePath(configured, homeDir));
916 }
917 const fromEnvironment = environmentHome?.trim() ?? "";
918 if (fromEnvironment.length > 0) {
919 return path.resolve(expandHomePath(fromEnvironment, homeDir));
920 }
921 return path.join(homeDir, ".claude");
922 };
923
924 const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")(
925 function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) {
926 const projectsDir = path.join(homePath, "projects");
927 let operationsRemaining = operationBudget;
928 let truncated = false;
929 const readDirectory = (directory: string) => {
930 if (operationsRemaining <= 0) {
931 truncated = true;
932 return Effect.succeed<ReadonlyArray<string>>([]);
933 }
934 operationsRemaining -= 1;
935 return listDirectory(directory);
936 };
937 const projectDirectories = yield* readDirectory(projectsDir);
938 const transcripts: Array<TranscriptCandidate> = [];
939
940 for (const projectDirectory of projectDirectories) {
941 if (operationsRemaining <= 0) {
942 truncated = true;
943 break;
944 }
945 const directory = path.join(projectsDir, projectDirectory);
946 const directoryTranscripts = (yield* readDirectory(directory))
947 .filter((entry) => entry.endsWith(".jsonl"))
948 .map((entry) => path.join(directory, entry));
949
950 for (const filePath of directoryTranscripts) {
951 if (operationsRemaining <= 0) {
952 truncated = true;
953 break;
954 }
955 operationsRemaining -= 1;
956 const stats = yield* statOption(filePath);
957 if (
958 Option.isNone(stats) ||
959 stats.value.type !== "File" ||
960 Option.isNone(stats.value.mtime)
961 ) {
962 continue;
963 }
964 transcripts.push({
965 filePath,
966 mtimeMs: stats.value.mtime.value.getTime(),
967 providerInstanceId,
968 size: Number(stats.value.size),
969 });
970 }
971 }
972 return { transcripts, truncated };
973 },
974 );
975
976 const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")(
977 function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) {
978 const sessionsDir = path.join(homePath, "sessions");
979
980 const transcripts: Array<TranscriptCandidate> = [];
981 let operationsRemaining = operationBudget;
982 let truncated = false;
983 const readDirectory = (directory: string) => {
984 if (operationsRemaining <= 0) {
985 truncated = true;
986 return Effect.succeed<ReadonlyArray<string>>([]);
987 }
988 operationsRemaining -= 1;
989 return listDirectory(directory);
990 };
991 // Date-partitioned directories sort chronologically, so walking them in
992 // reverse spends each home's share of the operation budget on recent sessions.
993 for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) {
994 if (operationsRemaining <= 0) {
995 truncated = true;
996 break;
997 }
998 for (const month of (yield* readDirectory(path.join(sessionsDir, year)))
999 .toSorted()
1000 .toReversed()) {
1001 if (operationsRemaining <= 0) {
1002 truncated = true;
1003 break;
1004 }
1005 for (const day of (yield* readDirectory(path.join(sessionsDir, year, month)))
1006 .toSorted()
1007 .toReversed()) {
1008 if (operationsRemaining <= 0) {
1009 truncated = true;
1010 break;
1011 }
1012 const directory = path.join(sessionsDir, year, month, day);
1013 for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) {
1014 if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue;
1015 if (operationsRemaining <= 0) {
1016 truncated = true;
1017 break;
1018 }
1019 const filePath = path.join(directory, entry);
1020 operationsRemaining -= 1;
1021 const stats = yield* statOption(filePath);
1022 if (
1023 Option.isSome(stats) &&
1024 stats.value.type === "File" &&
1025 Option.isSome(stats.value.mtime)
1026 ) {
1027 transcripts.push({
1028 filePath,
1029 mtimeMs: stats.value.mtime.value.getTime(),
1030 providerInstanceId,
1031 size: Number(stats.value.size),
1032 });
1033 }
1034 }
1035 }
1036 }
1037 }
1038 return { transcripts, truncated };
1039 },
1040 );
1041
1042 const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* (
1043 source: AgentSessionSource,
1044 transcripts: ReadonlyArray<TranscriptCandidate>,
1045 budget: MetadataReadBudget,
1046 ) {
1047 const byOwnerAndCwd = new Map<
1048 string,
1049 {
1050 cwd: string;
1051 providerInstanceId: ProviderInstanceId;
1052 lastActiveAtMs: number;
1053 transcripts: Array<{ filePath: string; mtimeMs: number }>;
1054 }
1055 >();
1056
1057 for (const transcript of transcripts) {
1058 const cwd = yield* readCwd(transcript, budget);
1059 if (cwd === null) continue;
1060 const key = `${transcript.providerInstanceId}\0${cwd}`;
1061 const existing = byOwnerAndCwd.get(key);
1062 if (existing) {
1063 existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs);
1064 existing.transcripts.push(transcript);
1065 } else {
1066 byOwnerAndCwd.set(key, {
1067 cwd,
1068 providerInstanceId: transcript.providerInstanceId,
1069 lastActiveAtMs: transcript.mtimeMs,
1070 transcripts: [transcript],
1071 });
1072 }
1073 }
1074
1075 return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({
1076 cwd: group.cwd,
1077 source,
1078 providerInstanceId: group.providerInstanceId,
1079 threadCount: group.transcripts.length,
1080 lastActiveAtMs: group.lastActiveAtMs,
1081 transcripts: group.transcripts,
1082 }));
1083 });
1084
1085 const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () {
1086 const settings = yield* serverSettings.getSettings.pipe(
1087 Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })),
1088 );
1089
1090 const raw: Array<RawCandidate> = [];
1091 let truncated = false;
1092
1093 for (const source of ["claudeAgent", "codex"] as const) {
1094 const instances: Array<{
1095 readonly instanceId: ProviderInstanceId;
1096 readonly config: ProviderInstanceConfig;
1097 }> = Object.entries(settings.providerInstances)
1098 .filter(
1099 ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance),
1100 )
1101 .map(([instanceId, config]) => ({
1102 instanceId: ProviderInstanceId.make(instanceId),
1103 config,
1104 }));
1105 // The built-in default instance runs with default config when settings
1106 // have no entry for it.
1107 if (!Object.hasOwn(settings.providerInstances, source)) {
1108 const defaultInstance = {
1109 instanceId: ProviderInstanceId.make(source),
1110 config: { driver: ProviderDriverKind.make(source) },
1111 };
1112 if (resolveProviderInstanceEnabled(defaultInstance.config)) {
1113 instances.push(defaultInstance);
1114 }
1115 }
1116
1117 // A shared home contains one copy of each session. Prefer the built-in
1118 // instance as its owner, then keep configured order for custom accounts.
1119 instances.sort((left, right) => {
1120 const leftDefault = left.instanceId === source ? 0 : 1;
1121 const rightDefault = right.instanceId === source ? 0 : 1;
1122 return leftDefault - rightDefault;
1123 });
1124 const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = [];
1125 const seenHomes = new Set<string>();
1126 for (const { instanceId, config: instance } of instances) {
1127 const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME";
1128 const environmentHome =
1129 instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ??
1130 hostEnvironment[homeVariable];
1131
1132 let homePath: string;
1133 if (source === "claudeAgent") {
1134 const config = decodeClaudeSettings(instance.config ?? {});
1135 if (Option.isNone(config)) continue;
1136 homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome);
1137 } else {
1138 const config = decodeCodexSettings(instance.config ?? {});
1139 if (Option.isNone(config)) continue;
1140 const codexSettings =
1141 config.value.homePath.trim().length === 0 &&
1142 config.value.shadowHomePath.trim().length === 0 &&
1143 environmentHome?.trim()
1144 ? { ...config.value, homePath: environmentHome }
1145 : config.value;
1146 const layout = yield* resolveCodexHomeLayout(codexSettings).pipe(
1147 Effect.provideService(Path.Path, path),
1148 );
1149 homePath = layout.sharedHomePath;
1150 }
1151
1152 const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`;
1153 if (seenHomes.has(homeKey)) continue;
1154 seenHomes.add(homeKey);
1155 homes.push({ homePath, providerInstanceId: instanceId });
1156 }
1157
1158 const transcriptCandidates: Array<TranscriptCandidate> = [];
1159 const baseOperationBudget = Math.floor(
1160 MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length),
1161 );
1162 const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length);
1163 for (const [index, home] of homes.entries()) {
1164 const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0);
1165 if (operationBudget === 0) {
1166 truncated = true;
1167 continue;
1168 }
1169 const discovered = yield* source === "claudeAgent"
1170 ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget)
1171 : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget);
1172 truncated ||= discovered.truncated;
1173 transcriptCandidates.push(...discovered.transcripts);
1174 }
1175
1176 transcriptCandidates.sort(
1177 (left, right) =>
1178 right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath),
1179 );
1180 if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) {
1181 truncated = true;
1182 }
1183 // Give each account a turn before taking another file from the same home.
1184 const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates);
1185 const metadataBudget: MetadataReadBudget = {
1186 bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE,
1187 operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE,
1188 recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE,
1189 truncated: false,
1190 };
1191 raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget)));
1192 truncated ||= metadataBudget.truncated;
1193 }
1194
1195 return { candidates: raw, truncated };
1196 });
1197
1198 let cachedCandidates: ReadonlyArray<RawCandidate> | null = null;
1199
1200 const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () {
1201 const { candidates: raw, truncated } = yield* collectCandidates();
1202 cachedCandidates = raw;
1203
1204 // Filesystem identity merges symlinks and case aliases without collapsing
1205 // distinct case-sensitive directories.
1206 const merged = new Map<
1207 string,
1208 {
1209 path: string;
1210 sources: Array<AgentSessionSource>;
1211 threadCount: number;
1212 lastActiveAtMs: number | null;
1213 git: AgentSessionProjectGit | null;
1214 }
1215 >();
1216 const directoryKeys = new Map<string, string>();
1217 const gitIdentities = new Map<string, AgentSessionProjectGit | null>();
1218
1219 for (const candidate of raw) {
1220 const expanded = expandHomePath(candidate.cwd.trim(), homeDir);
1221 if (!path.isAbsolute(expanded)) continue;
1222 const resolved = path.resolve(expanded);
1223 if (isExcludedProjectPath(resolved)) continue;
1224 let key = directoryKeys.get(resolved);
1225 if (key === undefined) {
1226 const stats = yield* statOption(resolved);
1227 // Directories that no longer exist can't be imported.
1228 if (Option.isNone(stats) || stats.value.type !== "Directory") {
1229 directoryKeys.set(resolved, "");
1230 continue;
1231 }
1232 const realPath = yield* fileSystem
1233 .realPath(resolved)
1234 .pipe(Effect.orElseSucceed(() => resolved));
1235 // A symlink can point into the worktrees directory even when its own
1236 // spelling doesn't; check again with links resolved.
1237 if (isExcludedProjectPath(realPath)) {
1238 key = "";
1239 } else {
1240 const gitIdentity = yield* readGitIdentity(resolved);
1241 if (gitIdentity._tag === "Worktree") {
1242 key = "";
1243 } else {
1244 key = yield* directoryIdentity(resolved, stats.value);
1245 gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null);
1246 }
1247 }
1248 directoryKeys.set(resolved, key);
1249 }
1250 if (key === "") continue;
1251
1252 const existing = merged.get(key);
1253 if (!existing) {
1254 merged.set(key, {
1255 path: resolved,
1256 sources: [candidate.source],
1257 threadCount: candidate.threadCount,
1258 lastActiveAtMs: candidate.lastActiveAtMs,
1259 git: gitIdentities.get(key) ?? null,
1260 });
1261 continue;
1262 }
1263 if (!existing.sources.includes(candidate.source)) {
1264 existing.sources.push(candidate.source);
1265 }
1266 existing.threadCount += candidate.threadCount;
1267 existing.lastActiveAtMs =
1268 existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null
1269 ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs)
1270 : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs);
1271 }
1272
1273 // Resolve persisted roots too. A project and a transcript can name
1274 // different symlinks to the same directory.
1275 const importedProjects = yield* projectStore
1276 .listShells()
1277 .pipe(
1278 Effect.mapError(
1279 (cause) => new AgentSessionScanError({ operation: "read-projects", cause }),
1280 ),
1281 );
1282 const importedProjectsByRoot = new Map<string, (typeof importedProjects)[number]>();
1283 for (const project of importedProjects) {
1284 const projectRoot = path.resolve(expandHomePath(project.workspaceRoot, homeDir));
1285 importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project);
1286 importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project);
1287 }
1288
1289 const candidates: Array<AgentSessionProjectCandidate> = [];
1290 for (const [key, entry] of merged.entries()) {
1291 // Keep the path key for missing roots and use filesystem identity for
1292 // aliases that resolve to the same directory.
1293 const importedProject =
1294 importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ??
1295 importedProjectsByRoot.get(key);
1296 const candidatePath = importedProject?.workspaceRoot ?? entry.path;
1297 candidates.push({
1298 path: candidatePath,
1299 title: path.basename(candidatePath) || candidatePath,
1300 ...(importedProject === undefined ? {} : { projectId: importedProject.id }),
1301 sources: entry.sources,
1302 threadCount: entry.threadCount,
1303 lastActiveAt:
1304 entry.lastActiveAtMs === null
1305 ? null
1306 : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)),
1307 alreadyImported: importedProject !== undefined,
1308 git: entry.git,
1309 });
1310 }
1311
1312 // Newest first, undated candidates last.
1313 candidates.sort((left, right) => {
1314 if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path);
1315 if (left.lastActiveAt === null) return 1;
1316 if (right.lastActiveAt === null) return -1;
1317 return right.lastActiveAt.localeCompare(left.lastActiveAt);
1318 });
1319
1320 return {
1321 candidates,
1322 scannedAt: DateTime.formatIso(yield* DateTime.now),
1323 ...(truncated ? { truncated: true } : {}),
1324 };
1325 });
1326
1327 const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* (
1328 workspaceRoot: string,
1329 completedSources: ReadonlyArray<AgentSessionImportSource>,
1330 ) {
1331 const root = path.resolve(expandHomePath(workspaceRoot, homeDir));
1332 const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
1333 if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty;
1334 const rootIdentity = yield* directoryIdentity(root);
1335 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
1336 const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS;
1337
1338 const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates;
1339 cachedCandidates = candidates;
1340
1341 const eligibleTranscripts: Array<{
1342 readonly candidate: RawCandidate;
1343 readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number };
1344 }> = [];
1345 for (const candidate of candidates) {
1346 const expanded = expandHomePath(candidate.cwd.trim(), homeDir);
1347 if (!path.isAbsolute(expanded)) continue;
1348 const resolved = path.resolve(expanded);
1349 if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue;
1350
1351 for (const transcript of candidate.transcripts) {
1352 if (
1353 transcript.mtimeMs === null ||
1354 transcript.mtimeMs < cutoffMs ||
1355 transcript.mtimeMs > nowMs
1356 ) {
1357 continue;
1358 }
1359 eligibleTranscripts.push({
1360 candidate,
1361 transcript: { ...transcript, mtimeMs: transcript.mtimeMs },
1362 });
1363 }
1364 }
1365
1366 eligibleTranscripts.sort((left, right) => {
1367 if (left.transcript.mtimeMs !== right.transcript.mtimeMs) {
1368 return right.transcript.mtimeMs - left.transcript.mtimeMs;
1369 }
1370 return left.transcript.filePath.localeCompare(right.transcript.filePath);
1371 });
1372
1373 const completedByFile = Map.groupBy(
1374 completedSources,
1375 (source) => `${source.providerInstanceId}\0${source.filePath}`,
1376 );
1377 const importedSessions = new Set<string>();
1378 let bytesRemaining = MAX_IMPORT_BYTES;
1379 let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS;
1380 let recordsRemaining = MAX_IMPORT_RECORDS;
1381 return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe(
1382 Stream.mapEffect(({ candidate, transcript }) =>
1383 Effect.gen(function* () {
1384 const completed = completedByFile.get(
1385 `${candidate.providerInstanceId}\0${transcript.filePath}`,
1386 );
1387 if (
1388 completed === undefined &&
1389 (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0)
1390 ) {
1391 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1392 }
1393 const stats = yield* statOption(transcript.filePath);
1394 if (Option.isNone(stats) || stats.value.type !== "File") {
1395 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1396 }
1397 const identity = transcriptIdentity(transcript.filePath, stats.value);
1398 const completedSource = completed?.find(
1399 (source) =>
1400 source.provider === candidate.source && sameTranscriptIdentity(source, identity),
1401 );
1402 if (completedSource !== undefined) {
1403 const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`;
1404 if (importedSessions.has(sessionKey)) return Option.none<AgentSessionRecentThread>();
1405 importedSessions.add(sessionKey);
1406 return Option.some<AgentSessionRecentThread>({
1407 _tag: "AlreadyImported",
1408 source: completedSource,
1409 });
1410 }
1411 if (
1412 transcriptsRemaining === 0 ||
1413 recordsRemaining === 0 ||
1414 identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES ||
1415 identity.size > bytesRemaining
1416 ) {
1417 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1418 }
1419 // Reserve the whole file even if its read or parse fails.
1420 transcriptsRemaining -= 1;
1421 bytesRemaining -= identity.size;
1422 const snapshot = yield* readTranscript(
1423 transcript.filePath,
1424 identity,
1425 recordsRemaining,
1426 candidate.source,
1427 );
1428 if (snapshot === null) {
1429 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1430 }
1431 recordsRemaining -= snapshot.recordCount;
1432
1433 // A stable replacement file can belong to a different project than the cached candidate.
1434 let snapshotCwd: string | null = null;
1435 for (const record of snapshot.records) {
1436 snapshotCwd = extractDecodedCwd(record);
1437 if (snapshotCwd !== null) break;
1438 }
1439 if (snapshotCwd === null) {
1440 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1441 }
1442 const expandedCwd = expandHomePath(snapshotCwd.trim(), homeDir);
1443 if (
1444 !path.isAbsolute(expandedCwd) ||
1445 (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity
1446 ) {
1447 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1448 }
1449
1450 const parsedThread = parseAgentSessionRecords(
1451 {
1452 source: candidate.source,
1453 providerInstanceId: candidate.providerInstanceId,
1454 fallbackSessionId: path.basename(transcript.filePath, ".jsonl"),
1455 lastActiveAtMs: transcript.mtimeMs,
1456 },
1457 snapshot.records,
1458 );
1459 if (parsedThread === null) {
1460 return Option.some<AgentSessionRecentThread>({ _tag: "Skipped" });
1461 }
1462
1463 const source: AgentSessionImportSource = {
1464 ...identity,
1465 provider: parsedThread.source,
1466 providerInstanceId: parsedThread.providerInstanceId,
1467 providerSessionId: parsedThread.providerSessionId,
1468 };
1469 const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`;
1470 if (importedSessions.has(sessionKey)) {
1471 return Option.some<AgentSessionRecentThread>({ _tag: "Duplicate", source });
1472 }
1473 importedSessions.add(sessionKey);
1474 return Option.some<AgentSessionRecentThread>({
1475 _tag: "Importable",
1476 thread: parsedThread,
1477 source,
1478 });
1479 }).pipe(importReadLock.withPermits(1)),
1480 ),
1481 Stream.map(Option.toArray),
1482 Stream.flattenIterable,
1483 );
1484 });
1485
1486 const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = (
1487 workspaceRoot,
1488 completedSources = [],
1489 ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources));
1490
1491 return AgentSessionScanner.of({ scan, recentThreads });
1492});
1493
1494export const layer = Layer.effect(AgentSessionScanner, make);