/** * index.ts — File Claiming Extension entry point. * * This extension provides a file-claiming mechanism that tracks active * claims (read / write / exclusive) on file paths so tools and extensions * can detect conflicts before operating on files. * * ## Quick start * * ```ts * import type { FileClaim, ClaimOwner } from "./src/lock-types"; * ``` * * ## Events * * The extension emits the following events on the shared extension bus * (`pi.events`): * * | Event | Payload | Description | * |--------------------|---------------|---------------------------------| * | `claim:acquired` | `ClaimEvent` | A claim was successfully acquired. | * | `claim:released` | `ClaimEvent` | A claim was released. | * | `claim:conflicted` | `ClaimEvent` | A claim could not be acquired. | * | `claim:expired` | `ClaimEvent` | A claim was auto-expired. | * * ## Configuration * * Options are managed through the `config.ts` module and can be * persisted to `{lockDir}/config.json`. Use `/file-claiming-config` * to inspect or update values at runtime. * * @module file-claiming */ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import type { ClaimConflict, ClaimEvent, ClaimEventType, ClaimOwner, ClaimRegistry, ClaimResult, FileClaim, LockEntry, PathLockType, } from "./src/lock-types"; import { type FileClaimingConfig, getConfig, setConfig, loadConfigFromFile, saveConfigToFile, getConfigFilePath, createDefaultConfig, } from "./src/config"; import { injectLockClaimingIntoPrompt, buildLockClaimingGuidelines, buildLockClaimingToolSnippets, } from "./src/system-prompt"; import { buildDiagnosticCollection, formatDiagnostics, } from "./src/diagnostics"; import { registerLockTools } from "./src/tools"; import { createLockNotificationHandler, claimEventToNotification, } from "./src/notifications"; import { updateLockStatus, persistLockState } from "./src/user-interaction"; import { createLockCommandHandler, createNotifyCommandHandler, } from "./src/user-interaction"; import { acquireLock, autoClaim, isFileLocked, getLockInfo, isMutationTool, shouldAutoClaim, handleToolLock, buildBlockingError, checkToolBlocking, cleanupExpiredLocks, type AcquireLockOptions, type LockAcquisitionResult, } from "./src/lock-acquisition"; import { registerEventHandlers } from "./src/event-handlers"; // --------------------------------------------------------------------------- // In-memory registry implementation // --------------------------------------------------------------------------- // We use plain objects as maps because ES Map does not serialize well and // we may persist registry snapshots via pi.appendEntry later. /** Claims indexed by ID. */ let claimsById: Record = {}; /** Lock entries indexed by path. */ let locksByPath: Record = {}; /** Reference to the interval handle for the expiry sweeper. */ let sweepTimer: ReturnType | undefined; /** Shared event bus (set during init). */ let emitEvent: | ((type: ClaimEventType, claim: FileClaim, conflict?: ClaimConflict) => void) | undefined; // --------------------------------------------------------------------------- // Conflict detection logic // --------------------------------------------------------------------------- /** * Returns whether a proposed lock type is compatible with the set of locks * already held on a path, excluding locks owned by `owner`. */ function isCompatible( existing: LockEntry[], requested: PathLockType, owner: ClaimOwner, ): { ok: boolean; blockers: LockEntry[] } { const blockers: LockEntry[] = []; for (const entry of existing) { // Skip locks held by the same owner — they already hold it. if (entry.owner.type === owner.type && entry.owner.id === owner.id) { continue; } // Exclusive blocks everything. if (entry.lockType === "exclusive" || requested === "exclusive") { blockers.push(entry); continue; } // Write blocks write and is blocked by write. if (entry.lockType === "write" || requested === "write") { blockers.push(entry); } // Read is always compatible with other reads. } return { ok: blockers.length === 0, blockers }; } // --------------------------------------------------------------------------- // Registry API // --------------------------------------------------------------------------- const registry: ClaimRegistry = { get claims(): Record { return claimsById; }, get locks(): Record { return locksByPath; }, getActiveClaims(path: string): FileClaim[] { return Object.values(claimsById).filter( (c) => c.path === path && c.status === "active", ); }, getLocks(path: string): LockEntry[] { return locksByPath[path] ?? []; }, checkConflict( path: string, lockType: PathLockType, owner: ClaimOwner, ): ClaimConflict | undefined { const existing = locksByPath[path] ?? []; const { ok, blockers } = isCompatible(existing, lockType, owner); if (ok) return undefined; const blockingClaims = blockers .map((b) => claimsById[b.claimId]) .filter(Boolean); return { path, severity: lockType === "read" ? "info" : "warning", blockedClaim: { id: "", path, lockType, status: "conflicted", owner, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, blockingClaims, message: `Cannot acquire "${lockType}" lock on "${path}": blocked by ${blockers.length} existing lock(s)`, }; }, acquire(claim: FileClaim): ClaimResult { const existing = locksByPath[claim.path] ?? []; const { ok, blockers } = isCompatible( existing, claim.lockType, claim.owner, ); if (!ok) { const blockingClaims = blockers .map((b) => claimsById[b.claimId]) .filter(Boolean); const conflict: ClaimConflict = { path: claim.path, severity: claim.lockType === "read" ? "info" : "warning", blockedClaim: claim, blockingClaims, message: `Cannot acquire "${claim.lockType}" lock on "${claim.path}": blocked by ${blockers.length} existing lock(s)`, }; emitEvent?.("claim:conflicted", claim, conflict); return { success: false, claim, conflict }; } // Store the claim const now = new Date().toISOString(); const stored: FileClaim = { ...claim, status: "active", createdAt: claim.createdAt || now, updatedAt: now, }; claimsById[stored.id] = stored; // Add lock entry const entry: LockEntry = { path: stored.path, lockType: stored.lockType, claimId: stored.id, owner: stored.owner, acquiredAt: now, }; locksByPath[stored.path] = [...(locksByPath[stored.path] ?? []), entry]; emitEvent?.("claim:acquired", stored); return { success: true, claim: stored }; }, release(claimId: string): boolean { const claim = claimsById[claimId]; if (!claim) return false; claim.status = "released"; claim.updatedAt = new Date().toISOString(); // Remove lock entries for this claim const pathEntries = locksByPath[claim.path] ?? []; locksByPath[claim.path] = pathEntries.filter((e) => e.claimId !== claimId); if ((locksByPath[claim.path] ?? []).length === 0) { delete locksByPath[claim.path]; } emitEvent?.("claim:released", claim); return true; }, releaseAllByOwner(owner: ClaimOwner): void { const toRelease = Object.values(claimsById).filter( (c) => c.owner.type === owner.type && c.owner.id === owner.id && c.status === "active", ); for (const claim of toRelease) { registry.release(claim.id); } }, }; // --------------------------------------------------------------------------- // Expiry sweeper // --------------------------------------------------------------------------- function sweepExpiredClaims(): void { const now = Date.now(); const toExpire = Object.values(claimsById).filter((c) => { if (c.status !== "active") return false; if (!c.expiresAt) return false; return new Date(c.expiresAt).getTime() <= now; }); for (const claim of toExpire) { claim.status = "expired"; claim.updatedAt = new Date().toISOString(); // Remove lock entries const pathEntries = locksByPath[claim.path] ?? []; locksByPath[claim.path] = pathEntries.filter((e) => e.claimId !== claim.id); if ((locksByPath[claim.path] ?? []).length === 0) { delete locksByPath[claim.path]; } emitEvent?.("claim:expired", claim); } } // --------------------------------------------------------------------------- // Config-driven helpers // --------------------------------------------------------------------------- /** * Build the current agent-owner context from a turn event or tool event context. * Used by `releaseOnTurnEnd` and `blockedTools` logic. */ function ownerFromContext(ctx: ExtensionContext): ClaimOwner | undefined { // The agent is the primary owner during tool execution. return { type: "agent", id: "main", sessionId: ctx.sessionManager.getSessionFile() ?? undefined, }; } /** * Release all agent-held claims when `releaseOnTurnEnd` is enabled. */ function releaseAgentClaimsOnTurnEnd(ctx: ExtensionContext): void { const config = getConfig(); if (!config.releaseOnTurnEnd) return; const owner = ownerFromContext(ctx); if (owner) { registry.releaseAllByOwner(owner); } } /** * Check whether a given tool name should be blocked based on `blockedTools` * and the current lock state. */ function isToolBlocked(toolName: string): boolean { const config = getConfig(); if (config.blockedTools.length === 0) return false; if (!config.blockedTools.includes(toolName)) return false; // Check if there are any active claims return Object.values(claimsById).some((c) => c.status === "active"); } // --------------------------------------------------------------------------- // Lock acquisition exports // --------------------------------------------------------------------------- /** * Re-export lock acquisition functions from the lock-acquisition module * for use by other extensions and tools. */ export { acquireLock, autoClaim, isFileLocked, getLockInfo, isMutationTool, shouldAutoClaim, handleToolLock, buildBlockingError, checkToolBlocking, cleanupExpiredLocks, type AcquireLockOptions, type LockAcquisitionResult, } from "./src/lock-acquisition"; // --------------------------------------------------------------------------- // Reset for testability // --------------------------------------------------------------------------- /** * Reset all in-memory state. Primarily intended for testing. */ export function resetRegistry(): void { claimsById = {}; locksByPath = {}; } /** * Returns a reference to the in-memory {@link ClaimRegistry} for direct * use by other extensions or tools. */ export function getClaimRegistry(): ClaimRegistry { return registry; } // --------------------------------------------------------------------------- // Extension factory // --------------------------------------------------------------------------- /** * Pi extension factory for the File Claiming extension. * * Handles: * - Configuration loading and command * - Sweeper interval * - Turn-end claim release * - Tool blocking when claims are active * - Diagnostics widget * * @param pi - The {@link ExtensionAPI} instance provided by Pi. */ export default function (pi: ExtensionAPI): void { // ----------------------------------------------------------------------- // Event emitter helper // ----------------------------------------------------------------------- emitEvent = ( type: ClaimEventType, claim: FileClaim, conflict?: ClaimConflict, ) => { const event: ClaimEvent = { type, claim, conflict, timestamp: new Date().toISOString(), }; pi.events.emit(type, event); }; // ----------------------------------------------------------------------- // Session lifecycle // ----------------------------------------------------------------------- pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => { // 1. Load persisted configuration await loadConfigFromFile(); const config = getConfig(); // 2. Start the expiry sweeper (idempotent) if (!sweepTimer) { sweepTimer = setInterval(sweepExpiredClaims, 5_000); } // 3. Set up notification handler const notificationHandler = createLockNotificationHandler( ctx.ui, pi.events, ); // 4. Show footer status if enabled if (config.showDiagnostics && ctx.hasUI) { ctx.ui.setWidget("file-claiming-diagnostics", undefined); ctx.ui.setStatus("file-claiming", "Claims: 0 active"); } // 5. Persist initial lock state persistLockState(pi); // 6. Register dedicated event handlers from event-handlers module registerEventHandlers(pi, ctx); }); pi.on("session_shutdown", async () => { if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = undefined; } // Release all claims registry.releaseAllByOwner({ type: "agent", id: "main" }); cleanupExpiredLocks(); }); // ----------------------------------------------------------------------- // Turn-end: release agent claims // ----------------------------------------------------------------------- pi.on("turn_end", async (_event: unknown, ctx: ExtensionContext) => { releaseAgentClaimsOnTurnEnd(ctx); updateDiagnosticsWidget(ctx); updateLockStatus(ctx.ui, registry); }); function updateDiagnosticsWidget(ctx: ExtensionContext): void { const config = getConfig(); if (!config.showDiagnostics || !ctx.hasUI) return; const activeCount = Object.values(registry.claims).filter( (c) => c.status === "active", ).length; ctx.ui.setStatus("file-claiming", `Claims: ${activeCount} active`); } // ----------------------------------------------------------------------- // /file-claiming-config command // ----------------------------------------------------------------------- pi.registerCommand("file-claiming-config", { description: "View or update File Claiming extension configuration. " + "Usage: /file-claiming-config [key=value ...] — omitting arguments shows current config. " + "Keys: autoReleaseTTL, releaseOnTurnEnd, lockDir, blockedTools, showDiagnostics.", handler: async (args: string, ctx: ExtensionCommandContext) => { const trimmed = args.trim(); // No arguments → display current config if (!trimmed) { const cfg = getConfig(); const lines = [ "── File Claiming Configuration ──", ` autoReleaseTTL: ${cfg.autoReleaseTTL} ms ${cfg.autoReleaseTTL === createDefaultConfig().autoReleaseTTL ? "(default)" : ""}`, ` releaseOnTurnEnd: ${cfg.releaseOnTurnEnd} ${cfg.releaseOnTurnEnd === createDefaultConfig().releaseOnTurnEnd ? "(default)" : ""}`, ` lockDir: ${cfg.lockDir}`, ` blockedTools: [${cfg.blockedTools.join(", ")}] ${jsonEqual(cfg.blockedTools, createDefaultConfig().blockedTools) ? "(default)" : ""}`, ` showDiagnostics: ${cfg.showDiagnostics} ${cfg.showDiagnostics === createDefaultConfig().showDiagnostics ? "(default)" : ""}`, ` config file: ${getConfigFilePath(cfg.lockDir)}`, "", ` Active claims: ${Object.values(claimsById).filter((c) => c.status === "active").length}`, "──────────────────────────────", "", " Set values: /file-claiming-config key=value [key=value ...]", " Examples:", " /file-claiming-config autoReleaseTTL=600000", " /file-claiming-config releaseOnTurnEnd=false", ' /file-claiming-config blockedTools=["edit","write","bash"]', " /file-claiming-config showDiagnostics=false", ]; ctx.ui.notify(lines.join("\n"), "info"); return; } // Parse key=value pairs const pairs = trimmed.split(/\s+/); const updates: Record = {}; for (const pair of pairs) { const eqIdx = pair.indexOf("="); if (eqIdx === -1) { ctx.ui.notify(`Invalid syntax: "${pair}" — use key=value`, "error"); return; } const key = pair.slice(0, eqIdx); const rawValue = pair.slice(eqIdx + 1); // Coerce value to the expected type updates[key] = coerceConfigValue(key, rawValue); } const result = setConfig(updates as Partial); if (!result.valid) { ctx.ui.notify( `Validation errors:\n${result.errors.join("\n")}`, "error", ); return; } // Persist to disk try { await saveConfigToFile(); ctx.ui.notify("Configuration updated and saved.", "info"); } catch (err) { ctx.ui.notify( `Configuration updated in memory but failed to persist: ${err}`, "warning", ); } // Restart sweeper with new config if autoReleaseTTL changed updateDiagnosticsWidget(ctx); }, }); // ----------------------------------------------------------------------- // Register /file-claiming-locks command for interactive lock management // ----------------------------------------------------------------------- const lockCommandHandler = createLockCommandHandler(() => registry); pi.registerCommand("file-claiming-locks", { description: "Interactive lock management. Use /file-claiming-locks for interactive mode, " + "or /file-claiming-locks [list|check|release|reset] [path] [id] for direct commands.", handler: lockCommandHandler, }); // ----------------------------------------------------------------------- // Context event: inject diagnostic messages // ----------------------------------------------------------------------- pi.on( "context", async ( event: { messages: import("@earendil-works/pi-agent-core").AgentMessage[]; }, ctx: ExtensionContext, ) => { const config = getConfig(); if (!config.showDiagnostics) return {}; const activeClaims = Object.values(claimsById).filter( (c) => c.status === "active", ); if (activeClaims.length === 0) return {}; const collection = buildDiagnosticCollection(registry); const diagnosticLines: string[] = [ "## File Claiming Lock Status", "", `Active claims: ${collection.count}`, "", ]; for (const [uri, items] of collection.diagnostics) { for (const item of items) { diagnosticLines.push( `- **${item.lockType} lock** on \`${item.uri}\` by ${item.tool ?? item.source} — ${item.message}`, ); } } const diagnosticMessage: import("@earendil-works/pi-agent-core").AgentMessage = { role: "user", content: [{ type: "text", text: diagnosticLines.join("\n") }], id: `file-claiming-diagnostics-${Date.now()}`, timestamp: Date.now(), }; return { messages: [...event.messages, diagnosticMessage] }; }, ); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Deep-compare two values for equality (simple JSON comparison). */ function jsonEqual(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } /** * Coerce a command-line string value to the expected type for a given config key. */ function coerceConfigValue(key: string, raw: string): unknown { // Booleans if (raw === "true") return true; if (raw === "false") return false; // Numbers if (/^-?\d+(\.\d+)?$/.test(raw)) { const num = Number(raw); if (Number.isFinite(num)) return num; } // Arrays — parse as JSON if (raw.startsWith("[") && raw.endsWith("]")) { try { return JSON.parse(raw); } catch { // Fall through to return raw string } } // Everything else is a string return raw; }