import { randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { withFileLock } from "@oh-my-pi/pi-utils/file-lock"; import { replaceFileAtomically } from "../utils/atomic-file"; import type { SessionInfo } from "./session-listing"; const PINS_FILENAME = "session-pins.json"; function pinsPath(agentDir: string): string { return path.join(agentDir, PINS_FILENAME); } /** * Read the global set of pinned session ids (`~/.omp/session-pins.json`). Pins * are keyed by session id, not file path, so they survive `/move` renames. * A missing file yields an empty set; a corrupt one degrades to empty with a * warning rather than breaking the resume picker. */ export async function loadPinnedSessionIds(agentDir: string = getAgentDir()): Promise> { try { const pins: unknown = JSON.parse(await fs.readFile(pinsPath(agentDir), "utf-8")); if (!Array.isArray(pins)) return new Set(); return new Set(pins.filter((id): id is string => typeof id === "string")); } catch (err) { if (isEnoent(err)) return new Set(); logger.warn("Failed to read session pins", { error: err }); return new Set(); } } /** * Set one session's pin from its current state and persist the set; returns * whether it was pinned before the update. * * The read-modify-write runs under the shared cross-process file lock and * commits via write-temp-then-atomic-replace, mirroring the MCP config * writer: two omp instances changing pins concurrently can no longer lose * each other's update (load-load-write-write), and a crash mid-write leaves * the previous pins file intact instead of a truncated one that degrades to * an empty set. The replace preserves the destination across Windows * EPERM/EEXIST rename failures (`replaceFileAtomically`). */ async function updateSessionPin( sessionId: string, next: (pinned: boolean) => boolean, agentDir: string, ): Promise { const filePath = pinsPath(agentDir); await fs.mkdir(agentDir, { recursive: true }); return withFileLock(filePath, async () => { const pinned = await loadPinnedSessionIds(agentDir); const wasPinned = pinned.has(sessionId); if (next(wasPinned)) pinned.add(sessionId); else pinned.delete(sessionId); const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; try { await fs.writeFile(tmpPath, JSON.stringify([...pinned], null, "\t"), { encoding: "utf-8", mode: 0o600 }); await replaceFileAtomically(tmpPath, filePath); } catch (error) { await fs.rm(tmpPath, { force: true }).catch(() => {}); throw error; } return wasPinned; }); } /** Toggle one session's pin; returns the new pinned state. */ export async function toggleSessionPin(sessionId: string, agentDir: string = getAgentDir()): Promise { return !(await updateSessionPin(sessionId, pinned => !pinned, agentDir)); } /** Pin or unpin one session; a no-op when it is already in that state. */ export async function setSessionPinned( sessionId: string, pinned: boolean, agentDir: string = getAgentDir(), ): Promise { await updateSessionPin(sessionId, () => pinned, agentDir); } /** Unpin one session; returns whether it was pinned. */ export function unpinSession(sessionId: string, agentDir: string = getAgentDir()): Promise { return updateSessionPin(sessionId, () => false, agentDir); } /** * Stable partition putting pinned sessions on top: within each group the * caller's order (recency) is preserved. Unknown ids are a no-op so stale * pins for deleted sessions never disturb the listing. */ export function sortPinnedFirst(sessions: SessionInfo[], pinnedIds: ReadonlySet): SessionInfo[] { if (pinnedIds.size === 0) return sessions; const top: SessionInfo[] = []; const rest: SessionInfo[] = []; for (const session of sessions) (pinnedIds.has(session.id) ? top : rest).push(session); return top.length > 0 ? [...top, ...rest] : sessions; }