Files
boss/local-agent/codex-session-discovery.mjs

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,
};
}