pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
156 lines (155 loc) • 5.15 kB
JavaScript
/**
* FileTime Tracking for pi-lens
*
* Prevents race conditions when auto-formatting or external tools modify files.
* Tracks file modification times and sizes to detect external changes.
*
* Inspired by OpenCode's FileTime system - ensures agents re-read files
* that have been modified externally (including by formatters).
*/
import * as fs from "node:fs";
import * as path from "node:path";
// --- Singleton State ---
const globalState = {
reads: new Map(),
locks: new Map(),
};
// --- Public API ---
export class FileTime {
sessionID;
constructor(sessionID) {
this.sessionID = sessionID;
}
/**
* Record a file read with current stats
* Call this after ANY file modification (including formatting)
*/
read(filePath) {
const absolutePath = path.resolve(filePath);
const stamp = createStamp(absolutePath);
let sessionReads = globalState.reads.get(this.sessionID);
if (!sessionReads) {
sessionReads = new Map();
globalState.reads.set(this.sessionID, sessionReads);
}
sessionReads.set(absolutePath, stamp);
return stamp;
}
/**
* Get last recorded stamp for a file
*/
get(filePath) {
const absolutePath = path.resolve(filePath);
const sessionReads = globalState.reads.get(this.sessionID);
return sessionReads?.get(absolutePath);
}
/**
* Assert file hasn't changed since last read
* Throws error if file modified externally - forces agent to re-read
*/
assert(filePath) {
const absolutePath = path.resolve(filePath);
const sessionReads = globalState.reads.get(this.sessionID);
const recorded = sessionReads?.get(absolutePath);
if (!recorded) {
throw new FileTimeError(`You must read file ${absolutePath} before modifying it. Use the read tool first.`, absolutePath, "not-read");
}
const current = createStamp(absolutePath);
const changed = current.mtime !== recorded.mtime ||
current.ctime !== recorded.ctime ||
current.size !== recorded.size;
if (changed) {
throw new FileTimeError(`File ${absolutePath} has been modified since it was last read.\n` +
`Last modification: ${new Date(current.mtime ?? Date.now()).toISOString()}\n` +
`Last read: ${recorded.readAt.toISOString()}\n\n` +
`Please read the file again before modifying it.`, absolutePath, "modified");
}
}
/**
* Check if file has changed (non-throwing version of assert)
*/
hasChanged(filePath) {
const absolutePath = path.resolve(filePath);
const sessionReads = globalState.reads.get(this.sessionID);
const recorded = sessionReads?.get(absolutePath);
if (!recorded)
return true; // Never read = changed
const current = createStamp(absolutePath);
return (current.mtime !== recorded.mtime ||
current.ctime !== recorded.ctime ||
current.size !== recorded.size);
}
/**
* Acquire exclusive lock on file
* Prevents concurrent modifications to same file
*/
async withLock(filePath, fn) {
const absolutePath = path.resolve(filePath);
// Wait for existing lock
while (globalState.locks.has(absolutePath)) {
const existing = globalState.locks.get(absolutePath);
if (existing)
await existing;
}
// Create new lock
const lockPromise = fn().finally(() => {
globalState.locks.delete(absolutePath);
});
globalState.locks.set(absolutePath, lockPromise.then(() => { }));
return lockPromise;
}
/**
* Clear all tracked files for this session
*/
clear() {
globalState.reads.delete(this.sessionID);
}
/**
* Clear specific file tracking
*/
clearFile(filePath) {
const absolutePath = path.resolve(filePath);
const sessionReads = globalState.reads.get(this.sessionID);
sessionReads?.delete(absolutePath);
}
}
// --- Error Type ---
export class FileTimeError extends Error {
filePath;
reason;
constructor(message, filePath, reason) {
super(message);
this.name = "FileTimeError";
this.filePath = filePath;
this.reason = reason;
}
}
// --- Utilities ---
function createStamp(filePath) {
try {
const stats = fs.statSync(filePath);
return {
readAt: new Date(),
mtime: stats.mtime.getTime(),
ctime: stats.ctime.getTime(),
size: stats.size,
};
}
catch {
// File doesn't exist - return empty stamp
return {
readAt: new Date(),
mtime: undefined,
ctime: undefined,
size: undefined,
};
}
}
// --- Global Helpers ---
export function createFileTime(sessionID) {
return new FileTime(sessionID);
}
export function clearAllSessions() {
globalState.reads.clear();
globalState.locks.clear();
}