Files
openclaw/extensions/anthropic/session-catalog-scan.ts
Peter Steinberger 3378e07d50 refactor(plugin-sdk): promote shared runtime primitives (#126193)
* refactor(plugin-sdk): promote shared runtime primitives

* test(codex): keep one attempt tools owner
2026-08-19 01:10:18 -07:00

282 lines
8.9 KiB
TypeScript

import type { Dirent, Stats } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isPathInside } from "openclaw/plugin-sdk/file-access-runtime";
const MAX_CATALOG_JSON_CACHE_ENTRIES = 4_000;
export const CLAUDE_CATALOG_IO_CONCURRENCY = 32;
type CatalogJsonCacheEntry = {
mtimeMs: number;
size: number;
value: unknown;
};
type SafeSessionFile = { filePath: string; stat: Stats } | undefined;
type ClaudeProjectDirectorySnapshot = {
directory: string;
childNames: string[];
};
type ClaudeChildFileSignature = readonly [name: string, mtimeMs: number, size: number, ino: number];
export type ClaudeProjectsTreeSnapshot = {
root: string;
resolvedRoot?: string;
projectDirectories: ClaudeProjectDirectorySnapshot[];
treeStamp: string;
};
export type ClaudeSessionScanContext = ClaudeProjectsTreeSnapshot & {
complete: boolean;
safeFiles: Map<string, Promise<SafeSessionFile>>;
};
// Parsed index/Desktop JSON stays valid for one path+mtime+size and is LRU-bounded; read failures are
// never cached, so transient metadata I/O cannot hide a later successful read.
const catalogJsonCache = new Map<string, CatalogJsonCacheEntry>();
export async function mapConcurrent<T, R>(
values: T[],
limit: number,
mapper: (value: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = [];
results.length = values.length;
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
while (nextIndex < values.length) {
const index = nextIndex++;
results[index] = await mapper(values[index]!);
}
});
await Promise.all(workers);
return results;
}
export function setBoundedCache<K, V>(
cache: Map<K, V>,
key: K,
value: V,
maxEntries: number,
): void {
cache.delete(key);
cache.set(key, value);
while (cache.size > maxEntries) {
const oldest = cache.keys().next();
if (oldest.done) {
break;
}
cache.delete(oldest.value);
}
}
async function safeSessionFile(
root: string,
resolvedRoot: string,
candidate: string,
sessionId: string,
): Promise<SafeSessionFile> {
if (!isPathInside(root, candidate) || path.basename(candidate) !== `${sessionId}.jsonl`) {
return undefined;
}
try {
const resolvedCandidate = await fs.realpath(candidate);
if (!isPathInside(resolvedRoot, resolvedCandidate)) {
return undefined;
}
const stat = await fs.stat(resolvedCandidate);
return stat.isFile() ? { filePath: resolvedCandidate, stat } : undefined;
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
if (code === "ENOENT" || code === "ENOTDIR") {
return undefined;
}
throw new Error("Claude session file validation failed", { cause: error });
}
}
export function safeSessionFileForScan(
context: ClaudeSessionScanContext,
candidate: string,
sessionId: string,
): Promise<SafeSessionFile> {
if (!context.resolvedRoot) {
return Promise.resolve(undefined);
}
const key = `${sessionId}\0${path.resolve(candidate)}`;
let pending = context.safeFiles.get(key);
if (!pending) {
// Canonical path + stat are valid only for this assembled scan. Sharing the promise prevents
// index fallback and discovery from serially resolving the same file twice.
const request = safeSessionFile(context.root, context.resolvedRoot, candidate, sessionId);
pending = request.catch(() => {
context.complete = false;
if (context.safeFiles.get(key) === pending) {
context.safeFiles.delete(key);
}
return undefined;
});
context.safeFiles.set(key, pending);
}
return pending;
}
export async function readJsonFile(
filePath: string,
options: { onIoFailure?: () => void } = {},
): Promise<unknown> {
const stat = await fs.stat(filePath).catch(() => {
options.onIoFailure?.();
return undefined;
});
if (!stat?.isFile()) {
catalogJsonCache.delete(filePath);
return undefined;
}
const cached = catalogJsonCache.get(filePath);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
setBoundedCache(catalogJsonCache, filePath, cached, MAX_CATALOG_JSON_CACHE_ENTRIES);
return cached.value;
}
let content: string;
try {
content = await fs.readFile(filePath, "utf8");
} catch {
options.onIoFailure?.();
return undefined;
}
try {
const value = JSON.parse(content) as unknown;
setBoundedCache(
catalogJsonCache,
filePath,
{ mtimeMs: stat.mtimeMs, size: stat.size, value },
MAX_CATALOG_JSON_CACHE_ENTRIES,
);
return value;
} catch {
return undefined;
}
}
export async function childDirectories(root: string): Promise<string[]> {
try {
return (await fs.readdir(root, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(root, entry.name));
} catch {
return [];
}
}
export function projectsDir(homeDir: string, configDir?: string): string {
return path.join(configDir ?? path.join(homeDir, ".claude"), "projects");
}
export async function readProjectsTreeSnapshot(root: string): Promise<ClaudeProjectsTreeSnapshot> {
let entries: Dirent[];
try {
entries = await fs.readdir(root, { withFileTypes: true });
} catch {
return { root, projectDirectories: [], treeStamp: "unavailable" };
}
const directoryEntries = entries.filter((entry) => entry.isDirectory());
const [resolvedRoot, directories] = await Promise.all([
fs.realpath(root).catch(() => undefined),
mapConcurrent(directoryEntries, CLAUDE_CATALOG_IO_CONCURRENCY, async (entry) => {
const directory = path.join(root, entry.name);
const [stat, children] = await Promise.all([
fs.stat(directory).catch(() => undefined),
fs.readdir(directory, { withFileTypes: true }).catch(() => undefined),
]);
return { entry, directory, stat, children };
}),
]);
const childTargets = directories.flatMap(({ directory, children }, directoryIndex) =>
(children ?? []).map((child) => ({ directoryIndex, directory, child })),
);
const childSignatures = await mapConcurrent(
childTargets,
CLAUDE_CATALOG_IO_CONCURRENCY,
async ({ directoryIndex, directory, child }) => {
const childStat = await fs.stat(path.join(directory, child.name)).catch(() => undefined);
const signature = childStat?.isFile()
? ([child.name, childStat.mtimeMs, childStat.size, childStat.ino] as const)
: undefined;
return { directoryIndex, signature };
},
);
const signaturesByDirectory = Array.from(
{ length: directories.length },
(): ClaudeChildFileSignature[] => [],
);
for (const { directoryIndex, signature } of childSignatures) {
if (signature) {
signaturesByDirectory[directoryIndex]?.push(signature);
}
}
const directorySnapshots = directories.map(({ entry, directory, stat, children }, index) => {
const fileSignatures = signaturesByDirectory[index] ?? [];
const maxChildMtime = fileSignatures.reduce<number | null>(
(maximum, [, mtime]) => Math.max(maximum ?? mtime, mtime),
null,
);
return {
directory,
childNames: children?.map((child) => child.name) ?? [],
stamp: [
entry.name,
stat?.isDirectory() === true ? stat.mtimeMs : null,
children?.map((child) => child.name) ?? null,
maxChildMtime ?? null,
fileSignatures,
] as const,
};
});
return {
root,
...(resolvedRoot ? { resolvedRoot } : {}),
projectDirectories: directorySnapshots.map(({ directory, childNames }) => ({
directory,
childNames,
})),
treeStamp: JSON.stringify([resolvedRoot ?? null, directorySnapshots.map(({ stamp }) => stamp)]),
};
}
export async function desktopSessionStoreAvailable(homeDir: string): Promise<boolean> {
const stat = await fs.stat(desktopSessionsDir(homeDir)).catch(() => undefined);
return stat?.isDirectory() === true;
}
export function desktopSessionsDir(homeDir: string): string {
return path.join(homeDir, "Library", "Application Support", "Claude", "claude-code-sessions");
}
export function currentHomeDir(env: NodeJS.ProcessEnv = process.env): string {
return env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
}
export function configuredClaudeConfigDir(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const configured = env.CLAUDE_CONFIG_DIR?.trim();
return configured ? path.resolve(configured) : undefined;
}
export function gatewayClaudeScanOptions(allowProcessHomeFallback?: boolean): {
configDir?: string;
includeDesktop: boolean;
} {
const configDir = configuredClaudeConfigDir();
// Upstream Claude Code's "Respect CLAUDE_CONFIG_DIR everywhere" convention replaces ~/.claude.
// Claude Desktop stays HOME/Library-scoped, so isolated scans exclude its metadata.
return {
...(configDir ? { configDir } : {}),
includeDesktop: allowProcessHomeFallback !== false,
};
}