237 lines
7.4 KiB
JavaScript
237 lines
7.4 KiB
JavaScript
import os from "node:os";
|
|
import { basename, resolve } from "node:path";
|
|
import { readFileSync } from "node:fs";
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
|
|
function toIsoFromUnixSeconds(value) {
|
|
if (!Number.isFinite(value) || value <= 0) return null;
|
|
return new Date(value * 1000).toISOString();
|
|
}
|
|
|
|
function sanitizeDisplayName(raw, fallback) {
|
|
const source = typeof raw === "string" ? raw : "";
|
|
const firstLine = source
|
|
.replace(/\u0000/g, "")
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean);
|
|
if (!firstLine) return fallback;
|
|
const compact = firstLine.replace(/\s+/g, " ").trim();
|
|
if (!compact) return fallback;
|
|
return compact.length > 48 ? `${compact.slice(0, 45)}...` : compact;
|
|
}
|
|
|
|
function fallbackDisplayName(thread, folderName) {
|
|
const suffix = thread.id.replace(/-/g, "").slice(0, 6);
|
|
if (thread.agentNickname) {
|
|
return thread.agentNickname;
|
|
}
|
|
if (thread.agentRole) {
|
|
return `${thread.agentRole} · ${suffix}`;
|
|
}
|
|
return `${folderName} · ${suffix}`;
|
|
}
|
|
|
|
function loadThreadWorkspaceHints(globalStatePath) {
|
|
if (!globalStatePath) return new Map();
|
|
try {
|
|
const parsed = JSON.parse(requireText(globalStatePath));
|
|
return new Map(Object.entries(parsed["thread-workspace-root-hints"] ?? {}));
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
function loadSessionNames(sessionIndexPath) {
|
|
if (!sessionIndexPath) return new Map();
|
|
const names = new Map();
|
|
try {
|
|
const raw = requireText(sessionIndexPath);
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
if (!line.trim()) continue;
|
|
const parsed = JSON.parse(line);
|
|
if (!parsed?.id) continue;
|
|
const previous = names.get(parsed.id);
|
|
if (!previous || String(parsed.updated_at ?? "") >= String(previous.updatedAt ?? "")) {
|
|
names.set(parsed.id, {
|
|
threadName: parsed.thread_name,
|
|
updatedAt: parsed.updated_at,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
return names;
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function loadRecentThreadActivity(logsDbPath) {
|
|
if (!logsDbPath) return new Map();
|
|
try {
|
|
const db = new DatabaseSync(logsDbPath, { readonly: true });
|
|
try {
|
|
const rows = db
|
|
.prepare("SELECT thread_id, MAX(ts) AS latest_ts FROM logs WHERE thread_id IS NOT NULL GROUP BY thread_id")
|
|
.all();
|
|
return new Map(
|
|
rows
|
|
.filter((row) => typeof row.thread_id === "string" && Number.isFinite(row.latest_ts))
|
|
.map((row) => [row.thread_id, Number(row.latest_ts)]),
|
|
);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
function loadThreadsFromStateDb(stateDbPath) {
|
|
if (!stateDbPath) return [];
|
|
try {
|
|
const db = new DatabaseSync(stateDbPath, { readonly: true });
|
|
try {
|
|
return db
|
|
.prepare(
|
|
"SELECT id, cwd, updated_at, archived, title, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
|
|
)
|
|
.all()
|
|
.map((row) => ({
|
|
id: String(row.id),
|
|
cwd: String(row.cwd),
|
|
updatedAtSeconds: Number(row.updated_at),
|
|
archived: Boolean(row.archived),
|
|
title: String(row.title ?? ""),
|
|
agentNickname: typeof row.agent_nickname === "string" ? row.agent_nickname : "",
|
|
agentRole: typeof row.agent_role === "string" ? row.agent_role : "",
|
|
}));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function parseSessionMeta(line) {
|
|
try {
|
|
const parsed = JSON.parse(line);
|
|
if (parsed?.type !== "session_meta" || !parsed?.payload?.id || !parsed?.payload?.cwd) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: String(parsed.payload.id),
|
|
cwd: String(parsed.payload.cwd),
|
|
updatedAtSeconds: Math.floor(new Date(parsed.payload.timestamp ?? parsed.timestamp ?? Date.now()).getTime() / 1000),
|
|
archived: false,
|
|
title: "",
|
|
agentNickname: typeof parsed.payload.agent_nickname === "string" ? parsed.payload.agent_nickname : "",
|
|
agentRole: typeof parsed.payload.agent_role === "string" ? parsed.payload.agent_role : "",
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadThreadsFromSessions(sessionsDir) {
|
|
if (!sessionsDir) return [];
|
|
const pending = [resolve(sessionsDir)];
|
|
const threads = [];
|
|
while (pending.length > 0) {
|
|
const dir = pending.pop();
|
|
if (!dir) continue;
|
|
let entries = [];
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
const fullPath = `${dir}/${entry.name}`;
|
|
if (entry.isDirectory()) {
|
|
pending.push(fullPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
try {
|
|
const raw = await readFile(fullPath, "utf8");
|
|
const firstLine = raw.split(/\r?\n/, 1)[0];
|
|
const parsed = parseSessionMeta(firstLine);
|
|
if (parsed) threads.push(parsed);
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
return threads;
|
|
}
|
|
|
|
function requireText(filePath) {
|
|
return readFileSync(resolve(filePath), "utf8");
|
|
}
|
|
|
|
export async function discoverCodexProjectCandidates(options = {}) {
|
|
const now = options.now instanceof Date ? options.now : new Date();
|
|
const lookbackHours = Number.isFinite(options.lookbackHours) ? Number(options.lookbackHours) : 24;
|
|
const cutoffSeconds = Math.floor(now.getTime() / 1000) - lookbackHours * 60 * 60;
|
|
const sessionNames = loadSessionNames(options.sessionIndexPath ?? resolve(os.homedir(), ".codex/session_index.jsonl"));
|
|
const workspaceHints = loadThreadWorkspaceHints(
|
|
options.globalStatePath ?? resolve(os.homedir(), ".codex/.codex-global-state.json"),
|
|
);
|
|
const latestLogByThread = loadRecentThreadActivity(
|
|
options.logsDbPath ?? resolve(os.homedir(), ".codex/logs_1.sqlite"),
|
|
);
|
|
|
|
let threads = loadThreadsFromStateDb(
|
|
options.stateDbPath ?? resolve(os.homedir(), ".codex/state_5.sqlite"),
|
|
);
|
|
if (threads.length === 0) {
|
|
threads = await loadThreadsFromSessions(
|
|
options.sessionsDir ?? resolve(os.homedir(), ".codex/sessions"),
|
|
);
|
|
}
|
|
|
|
const seenThreadIds = new Set();
|
|
const candidates = [];
|
|
for (const thread of threads) {
|
|
if (!thread?.id || seenThreadIds.has(thread.id)) continue;
|
|
const latestActivitySeconds = latestLogByThread.get(thread.id) ?? thread.updatedAtSeconds;
|
|
if (!Number.isFinite(latestActivitySeconds) || latestActivitySeconds < cutoffSeconds) {
|
|
continue;
|
|
}
|
|
|
|
seenThreadIds.add(thread.id);
|
|
const hintedPath = workspaceHints.get(thread.id);
|
|
const folderPath = resolve(hintedPath || thread.cwd || "");
|
|
const folderName = basename(folderPath);
|
|
if (!folderName) continue;
|
|
|
|
const sessionName = sessionNames.get(thread.id)?.threadName;
|
|
const displayName = sanitizeDisplayName(
|
|
sessionName,
|
|
sanitizeDisplayName(thread.title, fallbackDisplayName(thread, folderName)),
|
|
);
|
|
|
|
candidates.push({
|
|
folderName,
|
|
folderRef: folderPath,
|
|
threadId: thread.id,
|
|
threadDisplayName: displayName,
|
|
codexFolderRef: folderPath,
|
|
codexThreadRef: thread.id,
|
|
lastActiveAt: toIsoFromUnixSeconds(latestActivitySeconds) ?? now.toISOString(),
|
|
suggestedImport: true,
|
|
});
|
|
}
|
|
|
|
candidates.sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt));
|
|
const projects = [...new Set(candidates.map((candidate) => candidate.folderName))].sort((a, b) =>
|
|
a.localeCompare(b),
|
|
);
|
|
return {
|
|
projects,
|
|
projectCandidates: candidates,
|
|
};
|
|
}
|